From 43c1912285fee477c848e4137ba767e703263291 Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Fri, 23 Oct 2026 19:24:38 +0000 Subject: [PATCH 1/7] Pin every view card's markup and every kind's page-facing facts, before reorganizing views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view snapshots pinned what each view computes and draws, not the lab page's cards, and the per-kind code about to move includes each card's controls, subtitle and body and the page's per-kind rules. Two more checks, so the move has to leave those byte for byte too: - .html, a fifth file per snapshot case: each view's card rendered as the lab page draws it (app/lab/view-cards.tsx ViewCard: header, controls, body), from the same replayed result, so it costs no extra engine run and gets the reversed-delivery check. Drawn with a fixed stand-in page context (test/support/render-views.ts), without timings and the query list, and with each figure's SVG reduced to a mark: .sql and .svg already pin those. 0.8 MB in all. - test/view-kinds.test.ts → test/snapshots/kinds.json: for every kind, its add-menu entry (label, why it can't be added, loading) for a study that has everything, one that has nothing and one answered aggregate-only; the spec a new view starts from (rich study, bare study, two cohorts); its card's title, limits, empty-view hint, add restrictions and subtitle; whether it draws its own header and waiting state; its help text, federation need, preview and NEW marks, and figure title. Read through the functions the page itself calls (useViewOptions, useViewEdits — plain functions, no React hooks inside — kind-cards.tsx, view-cards.tsx). Node's type stripping cannot run JSX, so test/support/tsx-loader.mjs (a module loader registered by those tests) compiles app/ files with esbuild (new dev dependency, 0.28.2) and resolves the app's extension-less and "@/" imports as Next does; lib/ keeps node's own stripping. Verified: npm test 1611/1611 (61 s); the HTML and kinds snapshots are identical across runs and across sent/reversed delivery. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CuBjiBEjYispDSEYDZDWM1 --- package-lock.json | 485 +++ package.json | 1 + test/snapshots/acquired.html | 4 + test/snapshots/actionability.html | 4 + test/snapshots/adjusted.html | 4 + test/snapshots/alterations.html | 4 + test/snapshots/assays.html | 4 + test/snapshots/attrition.html | 4 + test/snapshots/cancertypes.html | 4 + test/snapshots/clinical.html | 4 + test/snapshots/clonality.html | 4 + test/snapshots/coexpression.html | 4 + test/snapshots/compare.html | 4 + test/snapshots/composition.html | 8 + test/snapshots/cooccurrence.html | 4 + test/snapshots/correlates.html | 4 + test/snapshots/course.html | 4 + test/snapshots/cox.html | 4 + test/snapshots/ctdna-chfilter.html | 4 + test/snapshots/ctdna.html | 4 + test/snapshots/cutpoint.html | 4 + test/snapshots/diffexpr.html | 4 + test/snapshots/drivernegative.html | 4 + test/snapshots/enrichment.html | 4 + test/snapshots/exclusivity.html | 4 + test/snapshots/forest.html | 4 + test/snapshots/genome.html | 4 + test/snapshots/germline.html | 4 + test/snapshots/hla.html | 6 + test/snapshots/hotspots.html | 4 + test/snapshots/incidence.html | 4 + test/snapshots/instability.html | 4 + test/snapshots/kinds.json | 3471 ++++++++++++++++++++ test/snapshots/labs.html | 4 + test/snapshots/logistic.html | 4 + test/snapshots/molsubtype.html | 4 + test/snapshots/multistate.html | 4 + test/snapshots/mutations.html | 4 + test/snapshots/organotropism.html | 4 + test/snapshots/overlap.html | 4 + test/snapshots/paired.html | 4 + test/snapshots/pathways.html | 4 + test/snapshots/plot.html | 8 + test/snapshots/pooled-paired.html | 12 + test/snapshots/predictive.html | 4 + test/snapshots/riskmodel-validateon.html | 4 + test/snapshots/riskmodel.html | 4 + test/snapshots/rmst.html | 4 + test/snapshots/sequence.html | 4 + test/snapshots/survival.html | 4 + test/snapshots/table.html | 4 + test/snapshots/timeline.html | 8 + test/snapshots/two-studies-four-views.html | 20 + test/snapshots/two-studies-plot.html | 10 + test/snapshots/two-studies-survival.html | 5 + test/snapshots/txendpoints.html | 4 + test/support/render-views.ts | 42 + test/support/tsx-loader.mjs | 40 + test/support/view-snapshots.ts | 6 +- test/view-kinds.test.ts | 101 + 60 files changed, 4402 insertions(+), 1 deletion(-) create mode 100644 test/snapshots/acquired.html create mode 100644 test/snapshots/actionability.html create mode 100644 test/snapshots/adjusted.html create mode 100644 test/snapshots/alterations.html create mode 100644 test/snapshots/assays.html create mode 100644 test/snapshots/attrition.html create mode 100644 test/snapshots/cancertypes.html create mode 100644 test/snapshots/clinical.html create mode 100644 test/snapshots/clonality.html create mode 100644 test/snapshots/coexpression.html create mode 100644 test/snapshots/compare.html create mode 100644 test/snapshots/composition.html create mode 100644 test/snapshots/cooccurrence.html create mode 100644 test/snapshots/correlates.html create mode 100644 test/snapshots/course.html create mode 100644 test/snapshots/cox.html create mode 100644 test/snapshots/ctdna-chfilter.html create mode 100644 test/snapshots/ctdna.html create mode 100644 test/snapshots/cutpoint.html create mode 100644 test/snapshots/diffexpr.html create mode 100644 test/snapshots/drivernegative.html create mode 100644 test/snapshots/enrichment.html create mode 100644 test/snapshots/exclusivity.html create mode 100644 test/snapshots/forest.html create mode 100644 test/snapshots/genome.html create mode 100644 test/snapshots/germline.html create mode 100644 test/snapshots/hla.html create mode 100644 test/snapshots/hotspots.html create mode 100644 test/snapshots/incidence.html create mode 100644 test/snapshots/instability.html create mode 100644 test/snapshots/kinds.json create mode 100644 test/snapshots/labs.html create mode 100644 test/snapshots/logistic.html create mode 100644 test/snapshots/molsubtype.html create mode 100644 test/snapshots/multistate.html create mode 100644 test/snapshots/mutations.html create mode 100644 test/snapshots/organotropism.html create mode 100644 test/snapshots/overlap.html create mode 100644 test/snapshots/paired.html create mode 100644 test/snapshots/pathways.html create mode 100644 test/snapshots/plot.html create mode 100644 test/snapshots/pooled-paired.html create mode 100644 test/snapshots/predictive.html create mode 100644 test/snapshots/riskmodel-validateon.html create mode 100644 test/snapshots/riskmodel.html create mode 100644 test/snapshots/rmst.html create mode 100644 test/snapshots/sequence.html create mode 100644 test/snapshots/survival.html create mode 100644 test/snapshots/table.html create mode 100644 test/snapshots/timeline.html create mode 100644 test/snapshots/two-studies-four-views.html create mode 100644 test/snapshots/two-studies-plot.html create mode 100644 test/snapshots/two-studies-survival.html create mode 100644 test/snapshots/txendpoints.html create mode 100644 test/support/render-views.ts create mode 100644 test/support/tsx-loader.mjs create mode 100644 test/view-kinds.test.ts diff --git a/package-lock.json b/package-lock.json index dc67f44..d6368db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "@types/pngjs": "^6.0.5", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", + "esbuild": "^0.28.2", "eslint": "^10.12.0", "eslint-plugin-jsx-a11y-x": "^0.2.0", "eslint-plugin-react-hooks": "^7.1.1", @@ -1232,6 +1233,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -7399,6 +7842,48 @@ "tests/browser-compat" ] }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/package.json b/package.json index 44e44aa..d86e318 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "@types/pngjs": "^6.0.5", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", + "esbuild": "^0.28.2", "eslint": "^10.12.0", "eslint-plugin-jsx-a11y-x": "^0.2.0", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/test/snapshots/acquired.html b/test/snapshots/acquired.html new file mode 100644 index 0000000..2d58a9b --- /dev/null +++ b/test/snapshots/acquired.html @@ -0,0 +1,4 @@ + +

Acquired under therapy — last sample before the first Treatment [SUBTYPE=Systemic therapy] vs the first ≥30 days after, oncogenic only, every gene scanned

Previewaggregate-only

Showingall patients

clock
data
diff --git a/test/snapshots/actionability.html b/test/snapshots/actionability.html new file mode 100644 index 0000000..d25853c --- /dev/null +++ b/test/snapshots/actionability.html @@ -0,0 +1,4 @@ + +

Clinical actionability — highest OncoKB level per patient, any cancer type · by CANCER_TYPE · SAMPLE_TYPE concordance

Previewaggregate-only

Showingall patients

data
diff --git a/test/snapshots/adjusted.html b/test/snapshots/adjusted.html new file mode 100644 index 0000000..029f305 --- /dev/null +++ b/test/snapshots/adjusted.html @@ -0,0 +1,4 @@ + +

Adjusted enrichment — mutations in every gene mutated in ≥5% · adjusted for MSI_TYPE, TMB_SCORE

Previewpatient-level

ShowingEarly-onset CRCAverage-onset CRC

data
diff --git a/test/snapshots/alterations.html b/test/snapshots/alterations.html new file mode 100644 index 0000000..11566ce --- /dev/null +++ b/test/snapshots/alterations.html @@ -0,0 +1,4 @@ + +

Patient alterations — Prostate · 3,211 patients · mutations, copy-number alterations and structural variants

patient-level
patient 22 of 3,211
P-0024249msk_chord_2024P-0024249-T01-IM6P-0024249-T02-IM7Each row is one alteration in one gene across these samples: a sample's marker where it carries it, -- where it does not. A gene's rows sit together, shaded alike, in sample order.
3 of 7 Mutations
KRASG12VOncogenic · Level 1Missense_Mutation28.5%0.08
PTENG127VLikely Oncogenic · Level 1Missense_Mutation4.8%0.62
TP53P151RLikely OncogenicMissense_Mutation52.4%0.76
Showing 1–3 of 3 Mutations
3 Copy Number Alterations
MAP3K1HOMDELLikely Oncogenic5q11.20.3%
RAD51BHOMDELLikely Oncogenic · Level 114q24.10.1%
PLK2HOMDELUnknown5q11.20.2%
Showing 1–3 of 3 Copy Number Alterations
3 Structural Variants
IRS2CHMP1ATRANSLOCATIONProtein Fusion: mid-exon {IRS2:CHMP1A}IMPPRECISE3to5
TCF7L2DELETIONTCF7L2-intragenicPRECISE3to5
TMPRSS2ERGDELETIONProtein Fusion: out of frame {TMPRSS2:ERG}IMPPRECISE3to5
Showing 1–3 of 3 Structural Variants

P-0024249 — Mutations: 3 of 7 rows (rows Annotation (OncoKB) contains “Oncogenic”); Copy Number Alterations: 3 rows; Structural Variants: 3 rows. Each table lists a variant, a gene's call or an event once however many of the patient's samples carry it (Samples names them); allele frequency and read counts are the highest among those samples. Copy number is the deep calls only (AMP, HOMDEL), as the portal's patient view lists them; Copy # on a mutation is the gene's deep calls anywhere in the patient. Cohort % is the share of the study's samples profiled for the gene by their panel with a mutation in it, or with the same copy-number call. Annotation is OncoKB v7.5 (oncogenicity and its highest sensitivity level); structural variants carry the study's own annotation, not OncoKB's. The patient list: 3,211 patients. Sorted by Structural Variants (highest first), missing values last, across the cohort; every page is a slice of that one order, and the download holds every row kept, with every column.

diff --git a/test/snapshots/assays.html b/test/snapshots/assays.html new file mode 100644 index 0000000..793e987 --- /dev/null +++ b/test/snapshots/assays.html @@ -0,0 +1,4 @@ + +

Generic assays — Mutational Signature Contribution Scores (COSMIC v2) · Welch's t-test, per sample

aggregate-only

ShowingMelanomaNon-small cell lung

data

Mutational Signature Contribution Scores (COSMIC v2) · Welch's t-test · 30 entities tested · 26 at q < 0.05

EntityMelanoma meanMelanoma SDNon-small cell lung meanNon-small cell lung SDp-Valueq-Value ▴Higher in
Signature4 (Smoking)0.01030.0580.1660.243<1e-300<1e-300Non-small cell lung
Signature7 (UV)0.5190.3840.02380.0833<1e-300<1e-300Melanoma
Signature24 (Aflatoxin)0.009140.05710.06970.1444.3e-1774.3e-176Non-small cell lung
Signature13 (APOBEC)0.009570.0490.05890.127.8e-1655.9e-164Non-small cell lung
Signature18 (Unknown)0.008720.05480.03890.1091.2e-647.4e-64Non-small cell lung
Signature29 (Tobacco)0.00760.060.03650.1112e-539.1e-53Non-small cell lung
Signature2 (APOBEC)0.01570.07210.04830.1122.1e-539.1e-53Non-small cell lung
Signature20 (MMR)0.01270.070.03150.1049e-213.4e-20Non-small cell lung
Signature16 (Unknown)0.007450.04690.01850.07458.1e-162.7e-15Non-small cell lung
Signature8 (Unknown)0.006190.04710.01630.07038.1e-142.4e-13Non-small cell lung
Signature3 (HRD)0.03340.110.05010.1249.4e-92.6e-8Non-small cell lung
Signature15 (MMR)0.00890.05380.01750.07511.1e-82.7e-8Non-small cell lung
Signature14 (MMR)0.003380.03250.008220.04912.1e-74.8e-7Non-small cell lung
Signature11 (TMZ)0.0250.0830.01460.07387.5e-71.6e-6Melanoma
Signature22 (Aristolochic acid)0.03040.1210.01740.07017.9e-61.6e-5Melanoma
Signature26 (MMR)0.005960.04390.01150.06571.1e-52e-5Non-small cell lung
Signature6 (MMR)0.01580.08180.02390.09131.8e-43.2e-4Non-small cell lung
Signature1 (Aging)0.04330.1310.0560.1352e-43.3e-4Non-small cell lung
Signature19 (Unknown)0.004370.03510.007580.04618.7e-40.0014Non-small cell lung
Signature9 (IGHV)0.006910.04330.01060.05670.00170.0025Non-small cell lung
Signature10 (POLE)0.008370.05520.01290.06380.00180.0025Non-small cell lung
Signature17 (Unknown)0.006710.04640.009560.05180.020.027Non-small cell lung
Signature30 (Unknown)0.01220.0650.01620.07480.0230.03Non-small cell lung
Signature21 (MMR)0.01450.06390.01830.07430.0240.03Non-small cell lung
Signature12 (Unknown)0.01050.05940.0140.06580.0270.032Non-small cell lung
Showing 1–25 of 30

Every entity of “Mutational Signature Contribution Scores (COSMIC v2)” (mutational_signatures_contribution_v2) compared across the cohorts, one value per sample as the study recorded it, from every sample of each cohort's patients. Values that are not numbers (a limit such as ">8", or NA) are left out (0 of them). Each entity is tested with Welch's t-test from each cohort's n, mean and variance; with thousands of samples a tiny difference is significant, so read the means too. 30 entities tested; q is Benjamini–Hochberg across all of them. 0 were not tested: fewer than 3 samples with a value in some cohort, or no spread.

diff --git a/test/snapshots/attrition.html b/test/snapshots/attrition.html new file mode 100644 index 0000000..886ac9e --- /dev/null +++ b/test/snapshots/attrition.html @@ -0,0 +1,4 @@ + +

Cohort attrition — patients left after each clause, in order

aggregate-only

ShowingLUAD KRAS G12C, immunotherapyLUAD KRAS wild-type

diff --git a/test/snapshots/cancertypes.html b/test/snapshots/cancertypes.html new file mode 100644 index 0000000..c21d27a --- /dev/null +++ b/test/snapshots/cancertypes.html @@ -0,0 +1,4 @@ + +

Cancer types summary — by CANCER_TYPE, any alteration, % of profiled samples

aggregate-only

Showingall patients

data
view

Patients altered in at least one of 3 genes per CANCER_TYPE group, over the patients profiled for at least one of them (the union of their panels, as an oncoprint's merged row counts it). Any mutation, high-level copy-number change (amplification, deep deletion) or structural variant counts; low-level gains and losses do not. A patient with more than one kind of alteration across the genes counts once, under "multiple". A patient counts in every group one of their samples falls in, profiled if any of those samples is, altered if any is. CANCER_TYPE is read per sample and folded on case and whitespace; samples with no value form their own group. Groups with fewer than 200 profiled patients are pooled into one bar rather than dropped.

diff --git a/test/snapshots/clinical.html b/test/snapshots/clinical.html new file mode 100644 index 0000000..38db33b --- /dev/null +++ b/test/snapshots/clinical.html @@ -0,0 +1,4 @@ + +

Clinical comparison — attribute distributions across cohorts

aggregate-only

Showingliver metsno liver mets

Categorical attributes are compared with a Pearson chi-squared over the full value vocabulary; numeric ones with Welch's unequal-variance t-test (2 cohorts) or Welch's ANOVA (3+) computed from (n, Σx, Σx²) per cohort — those totals are ADDITIVE across sites, so the pooled mean and variance are exact from per-site aggregates and no individual value is ever transmitted. Counting unit follows the attribute: patients for patient-level, samples for sample-level. q-values are Benjamini–Hochberg across the 2 tested attributes. Numeric attributes also show median/IQR and a rank-based test alongside the moment-based one — a Mann-Whitney U for 2 cohorts, Kruskal-Wallis for 3+ (neither itself part of the q-value correction) — computed from the attribute's complete value distribution, which is only exact and only requested because this view is confined to a single study.

attribute liver mets no liver metstestpq
MSI_SCORE
sample-level · numeric
1.09 ± 2.264 (n=227)
median 0.6 (0.26–1.29)
2.892 ± 8.147 (n=98)
median 0.595 (0.205–1.248)
Welch t*0.0340.067
AGE_AT_MET
patient-level · numeric
58.952 ± 13.101 (n=227)
median 59 (51–68)
59.439 ± 13.677 (n=98)
median 58.5 (50.25–68.75)
Welch t*0.770.77
diff --git a/test/snapshots/clonality.html b/test/snapshots/clonality.html new file mode 100644 index 0000000..f4491f2 --- /dev/null +++ b/test/snapshots/clonality.html @@ -0,0 +1,4 @@ + +

Clonality — FACETS allele-specific copy number, driver mutations, QC-passing samples

aggregate-only

ShowingColorectalBreast

data
diff --git a/test/snapshots/coexpression.html b/test/snapshots/coexpression.html new file mode 100644 index 0000000..9c9fdd6 --- /dev/null +++ b/test/snapshots/coexpression.html @@ -0,0 +1,4 @@ + +

Co-expression — Pearson correlation from per-site sums

aggregate-only

ShowingBRCA

Pearson correlation on “rna_seq_v2_mrna_median_Zscores” within “BRCA”, over the patients with BOTH values (values averaged per patient, matching the heatmap convention). Each site reports six sums — n, Σx, Σy, Σxy, Σx², Σy² — and nothing else; those sums are additive, so the pooled correlation is EXACT rather than an approximation of a centralized one. q-values are Benjamini–Hochberg across the 10 pairs with a defined correlation.

pairnrpq
ERBB2 / GRB71,0640.8713<1e-300<1e-300
ESR1 / MKI671,064-0.23341.2e-146.2e-14
ESR1 / PGR1,0640.22638e-142.7e-13
PGR / MKI671,064-0.18391.5e-93.8e-9
GRB7 / ESR11,064-0.17231.6e-83.1e-8
ERBB2 / ESR11,064-0.16516e-81e-7
ERBB2 / PGR1,064-0.1273.2e-54.6e-5
GRB7 / PGR1,064-0.11851.1e-41.3e-4
GRB7 / MKI671,0640.04570.140.14
ERBB2 / MKI671,0640.0450.140.14
diff --git a/test/snapshots/compare.html b/test/snapshots/compare.html new file mode 100644 index 0000000..e2837f2 --- /dev/null +++ b/test/snapshots/compare.html @@ -0,0 +1,4 @@ + +

Compare — alteration frequencies · OncoKB v7.5 (pinned)

aggregate-only

ShowingWell-diff liposarcoma

no copy-number data exists for sarcoma_msk_2022 — altered% reflects only mutation/structural-variant status, not a real negative for the missing type(s)

gene Well-diff liposarcoma
driver / any
MDM20% / 0%*
TP531.6% / 3.8%*
diff --git a/test/snapshots/composition.html b/test/snapshots/composition.html new file mode 100644 index 0000000..6889bff --- /dev/null +++ b/test/snapshots/composition.html @@ -0,0 +1,8 @@ + +

Composition — clinical attributes · 10 parts per patient, as shares · sorted by CD8 T CXCL13

aggregate-only

ShowingHRD-DupHRD-DelFBI

data
view
+ +

Composition — Cell Type Relative Fractions · 10 parts per sample, as shares · sorted by T_cell

aggregate-only

ShowingHRD-DupHRD-DelFBI

data
view
diff --git a/test/snapshots/cooccurrence.html b/test/snapshots/cooccurrence.html new file mode 100644 index 0000000..59c9713 --- /dev/null +++ b/test/snapshots/cooccurrence.html @@ -0,0 +1,4 @@ + +

Co-occurrence — pairwise, among patients profiled for both genes

aggregate-only

ShowingNSCLC

Pairwise co-occurrence within “NSCLC”. Denominators are the patients profiled for BOTH genes, computed per study against that study's own panels — a pair is never scored on patients whose panel could not have detected it. Two-sided Fisher exact test. q-values are Benjamini–Hochberg across the 15 assessable pairs. Every site returns four counts per pair and nothing else.

pairbothA onlyB onlyneitherlog2 ORpqtendency
STK11 / KEAP15725375126,1883.693.4e-2495.1e-248co-occurrence
EGFR / KRAS751,9472,1943,593-3.991.5e-2381.1e-237mutual exclusivity
EGFR / STK11521,9701,0574,730-3.088.9e-884.5e-87mutual exclusivity
KRAS / STK115981,6715115,0291.824.9e-791.9e-78co-occurrence
EGFR / KEAP1661,9561,0184,769-2.661.5e-724.5e-72mutual exclusivity
KRAS / TP538731,3963,2152,325-1.151.1e-552.6e-55mutual exclusivity
EGFR / ALK491,9735035,284-1.943.2e-256.9e-25mutual exclusivity
KRAS / KEAP14561,8136284,9120.985.5e-231e-22co-occurrence
TP53 / STK114533,6356563,065-0.781.3e-162.2e-16mutual exclusivity
EGFR / TP531,1748482,9142,8730.452.3e-93.4e-9co-occurrence
KRAS / ALK1182,1514345,106-0.632.8e-53.8e-5mutual exclusivity
STK11 / ALK661,0434866,214-0.310.130.16mutual exclusivity
TP53 / ALK3033,7852493,4720.160.220.24co-occurrence
TP53 / KEAP15863,5024983,2230.120.230.24co-occurrence
KEAP1 / ALK721,0124806,245-0.110.610.61mutual exclusivity
diff --git a/test/snapshots/correlates.html b/test/snapshots/correlates.html new file mode 100644 index 0000000..adb6dc7 --- /dev/null +++ b/test/snapshots/correlates.html @@ -0,0 +1,4 @@ + +

Co-expression — ESR1 on rna_seq_v2_mrna · Spearman ρ, every gene

aggregate-only

Showingall patients

data

Genes on rna_seq_v2_mrna correlated with ESR1 on rna_seq_v2_mrna, over all patients · 20,057 genes tested · 15,572 at q < 0.05

Correlated GeneCytobandSpearman's Correlationp-Valueq-Value ▴
CCDC1706q25.10.7922.2e-2334.5e-229
DCPH16q25.10.7845.1e-2265.1e-222
THSD415q230.7786.8e-2204.6e-216
FOXA114q21.10.7482.5e-1941.3e-190
TBC1D94q31.210.7466.2e-1932.5e-189
RABEP117p13.20.7281.8e-1795.9e-176
SLC39A618q12.20.7229.3e-1752.7e-171
GATA310p140.7201.4e-1733.4e-170
CA1215q22.20.6942.2e-1565e-153
CT6215q230.6943.6e-1567.3e-153
MYB6q23.30.6946e-1561.1e-152
PARD6B20q13.130.6924.2e-1557e-152
AGR37p21.10.6927.3e-1551.1e-151
RERG12p12.30.6861.2e-1511.7e-148
RUNDC117q21.310.6862e-1512.6e-148
ARSG17q24.20.6831.2e-1491.5e-146
HAPLN315q26.1-0.6791.9e-1472.3e-144
YBX11p34.2-0.6787.2e-1478.1e-144
XBP122q12.10.6782.1e-1462.3e-143
SMCO411q21-0.6754.7e-1454.7e-142
C5AR219q13.320.6751.5e-1441.4e-141
PSAT19q21.2-0.6739.9e-1449e-141
ACADSB10q26.130.6691.8e-1411.6e-138
AFF32q11.20.6634.4e-1383.7e-135
NUDT125q21.20.6616.2e-1375e-134
Showing 1–25 of 200the 100 most positive and 100 most negative of 20,057

ESR1 vs CCDC170 · Spearman 0.79 (p = 2.2e-233) · Pearson 0.67 (p = 1.9e-140)

drawing ESR1 vs CCDC170…

ESR1 on “rna_seq_v2_mrna” against every gene on “rna_seq_v2_mrna”, over the patients in “all patients” with both values (a patient's samples averaged, as the heatmap takes them). Ranked by Spearman's ρ, as the portal ranks, with Pearson's r beside it; q is Benjamini–Hochberg over ρ's p across all 20,057 genes tested. A correlation is not a regulatory link: genes that rise together across a cohort often both track a third thing — a subtype, the tumour's purity, proliferation. 280 genes were not tested: fewer than 10 patients with both values, or no spread.

diff --git a/test/snapshots/course.html b/test/snapshots/course.html new file mode 100644 index 0000000..b2ec372 --- /dev/null +++ b/test/snapshots/course.html @@ -0,0 +1,4 @@ + +

Clinical course — Treatment, Surgery · 3-month bins

aggregate-only

ShowingTP53 mutTP53 wt

diff --git a/test/snapshots/cox.html b/test/snapshots/cox.html new file mode 100644 index 0000000..7c94d2e --- /dev/null +++ b/test/snapshots/cox.html @@ -0,0 +1,4 @@ + +

Cox regression — Multivariate Cox, overall survival

aggregate-only

Showingall patients

endpointcutoffmo
covariate 1
diff --git a/test/snapshots/ctdna-chfilter.html b/test/snapshots/ctdna-chfilter.html new file mode 100644 index 0000000..8f4d818 --- /dev/null +++ b/test/snapshots/ctdna-chfilter.html @@ -0,0 +1,4 @@ + +

ctDNA vs tissue — plasma SAMPLE_CLASS = cfDNA/ctDNA vs tissue Tumor, any alteration, clonal haematopoiesis filtered, the most altered genes

Previewaggregate-only

Showingall patients

data
diff --git a/test/snapshots/ctdna.html b/test/snapshots/ctdna.html new file mode 100644 index 0000000..c365b8f --- /dev/null +++ b/test/snapshots/ctdna.html @@ -0,0 +1,4 @@ + +

ctDNA vs tissue — plasma SAMPLE_CLASS = cfDNA/ctDNA vs tissue Tumor, any alteration

Previewaggregate-only

Showingall patients

data
diff --git a/test/snapshots/cutpoint.html b/test/snapshots/cutpoint.html new file mode 100644 index 0000000..79970a9 --- /dev/null +++ b/test/snapshots/cutpoint.html @@ -0,0 +1,4 @@ + +

Cut-point — TMB_NONSYNONYMOUS on OS · best log-rank cut, 1000 permutations · per CANCER_TYPE

Previewpatient-level

Showingall patients

data
diff --git a/test/snapshots/diffexpr.html b/test/snapshots/diffexpr.html new file mode 100644 index 0000000..5e0aeed --- /dev/null +++ b/test/snapshots/diffexpr.html @@ -0,0 +1,4 @@ + +

Differential expression — rna_seq_v2_mrna_median_all_sample_Zscores · volcano and GSEA

aggregate-only

ShowingBasal-likeLuminal A

data
diff --git a/test/snapshots/drivernegative.html b/test/snapshots/drivernegative.html new file mode 100644 index 0000000..a5093b2 --- /dev/null +++ b/test/snapshots/drivernegative.html @@ -0,0 +1,4 @@ + +

Driver-negative analysis — mutations without vs with a driver in the NSCLC set, per patient

Previewaggregate-only

ShowingNSCLC

data
diff --git a/test/snapshots/enrichment.html b/test/snapshots/enrichment.html new file mode 100644 index 0000000..e4888a8 --- /dev/null +++ b/test/snapshots/enrichment.html @@ -0,0 +1,4 @@ + +

Alteration enrichment — mutations in every gene, per sample

aggregate-only

ShowingColorectal, MSSPancreatic, MSS

data
GeneCytobandColorectal, MSSPancreatic, MSSLog2 Ratiop-Valueq-Value ▴Enriched in
APC5q22.23,815 (76.5%)45 (1.4%)5.73<1e-300<1e-300Colorectal, MSS
KRAS12p12.12,178 (43.7%)2,518 (80.8%)-0.891.5e-2503.8e-248Pancreatic, MSS
CDKN2A9p21.352 (1.0%)629 (20.2%)-4.275e-2118.2e-209Pancreatic, MSS
TCF7L210q25.2-q25.3547 (11.4%)3 (0.1%)6.871.4e-1171.7e-115Colorectal, MSS
PIK3CA3q26.32876 (17.6%)71 (2.3%)2.951.9e-1171.9e-115Colorectal, MSS
FBXW74q31.3635 (12.7%)34 (1.1%)3.551.8e-971.4e-95Colorectal, MSS
SOX917q24.3470 (9.4%)25 (0.8%)3.552.5e-711.7e-69Colorectal, MSS
BRAF7q34373 (7.5%)41 (1.3%)2.516.8e-414.2e-39Colorectal, MSS
AMER1Xq11.2281 (5.6%)26 (0.8%)2.765.6e-343.1e-32Colorectal, MSS
SMAD218q21.1211 (4.2%)12 (0.4%)3.462.5e-311.3e-29Colorectal, MSS
MEN111q1325 (0.5%)128 (4.1%)-3.036.6e-313e-29Pancreatic, MSS
TP5317p13.13,890 (78.0%)2,077 (66.6%)0.233e-291.2e-27Colorectal, MSS
CTNNB13p22.1269 (5.4%)34 (1.1%)2.315.6e-272.1e-25Colorectal, MSS
NRAS1p13.2196 (3.9%)16 (0.5%)2.942.5e-258.8e-24Colorectal, MSS
PIK3R15q13.1178 (3.6%)12 (0.4%)3.216e-252e-23Colorectal, MSS
PTPRT20q12-q13.11315 (6.3%)58 (1.9%)1.766.7e-232.1e-21Colorectal, MSS
PTPRS19p13.3261 (5.2%)49 (1.6%)1.747.9e-192.3e-17Colorectal, MSS
EPHA33p11.1176 (3.5%)24 (0.8%)2.204.4e-171.2e-15Colorectal, MSS
CARD117p22.2166 (3.3%)22 (0.7%)2.242.1e-165.6e-15Colorectal, MSS
GRIN2A16p13.2188 (3.8%)30 (1.0%)1.975.3e-161.3e-14Colorectal, MSS
ASXL120q11.21128 (2.6%)13 (0.4%)2.623.3e-157.7e-14Colorectal, MSS
MTOR1p36.22150 (3.0%)20 (0.6%)2.236.5e-151.5e-13Colorectal, MSS
PLCG216q24.1123 (2.6%)13 (0.4%)2.601e-142.2e-13Colorectal, MSS
KDR4q12148 (3.0%)20 (0.6%)2.211.6e-143.3e-13Colorectal, MSS
ERBB217q12155 (3.1%)23 (0.7%)2.073.1e-146.2e-13Colorectal, MSS
Showing 1–25 of 300the 300 most significant of 494 genes tested

Every gene's mutation frequency in each cohort, counted in SAMPLES over the samples profiled for that gene (a panel that lists it, or whole-exome; a sample altered off-panel counts as measured), uncalled mutations excluded as the portal excludes them. 494 genes tested with a two-sided Fisher's exact test; q is Benjamini–Hochberg across all of them. 8 altered genes were not tested: fewer than 5 altered samples across the cohorts, or not profiled in one of them. The log ratio is log2 of the first cohort's altered share over the second's, undefined (drawn at the edge) when either is zero. A gene enriched here is more often altered, not necessarily more important: a cohort holding hypermutated tumours (MSI-high, POLE) is enriched for mutations in almost every gene at once — exclude them in the cohort definition to see past that.

diff --git a/test/snapshots/exclusivity.html b/test/snapshots/exclusivity.html new file mode 100644 index 0000000..3eba8f1 --- /dev/null +++ b/test/snapshots/exclusivity.html @@ -0,0 +1,4 @@ + +

Mutual exclusivity — NSCLC · any alteration, background from each tumour's burden

Previewaggregate-only
data
diff --git a/test/snapshots/forest.html b/test/snapshots/forest.html new file mode 100644 index 0000000..27ef732 --- /dev/null +++ b/test/snapshots/forest.html @@ -0,0 +1,4 @@ + +

Forest plot — TP53 mutated · OS hazard ratio per CANCER_TYPE, random-effects pooled

Previewpatient-level

Showingall patients

exposure
rows
diff --git a/test/snapshots/genome.html b/test/snapshots/genome.html new file mode 100644 index 0000000..030c638 --- /dev/null +++ b/test/snapshots/genome.html @@ -0,0 +1,4 @@ + +

Genome-wide copy number — hg19, 2 Mb bins, from segments

aggregate-only

ShowingColorectalBreast

data
diff --git a/test/snapshots/germline.html b/test/snapshots/germline.html new file mode 100644 index 0000000..80fdf28 --- /dev/null +++ b/test/snapshots/germline.html @@ -0,0 +1,4 @@ + +

Germline carriers — germline carriers per patient sequenced, every gene with ≥2 carriers · somatic second hit vs non-carriers · OS by BRCA2 carrier status

Previewaggregate-only

ShowingmCRPC

data
diff --git a/test/snapshots/hla.html b/test/snapshots/hla.html new file mode 100644 index 0000000..40ae590 --- /dev/null +++ b/test/snapshots/hla.html @@ -0,0 +1,6 @@ + +

HLA class I genotype — alleles at 2 fields · OS by homozygosity and by HLA LOH

Previewpatient-level

Showingmelanoma on checkpoint blockade

data
diff --git a/test/snapshots/hotspots.html b/test/snapshots/hotspots.html new file mode 100644 index 0000000..3653262 --- /dev/null +++ b/test/snapshots/hotspots.html @@ -0,0 +1,4 @@ + +

Mutations — lollipop by protein position, as the portal's mutation tab draws it

aggregate-only

ShowingCRC

Somatic Mutation Frequency 42.5%
Somatic Mutation Frequency 11.2%
Somatic Mutation Frequency 20.5%
Somatic Mutation Frequency 73.2%

Mutations in “CRC” by protein position, as cBioPortal's mutation tab draws them: one stem per codon (the stored protein start position), its height the number of distinct patient + protein change pairs there. Colour is the codon's dominant mutation type, putative drivers before VUS (driver = OncoKB oncogenic, pinned; the portal also counts hotspots and custom annotations). VUS and germline mutations are included, as they are by default on the portal. A suppression floor of 3 patients per change was applied BY EACH SITE before returning anything, withholding 1,112 mutations.

diff --git a/test/snapshots/incidence.html b/test/snapshots/incidence.html new file mode 100644 index 0000000..f7e1a18 --- /dev/null +++ b/test/snapshots/incidence.html @@ -0,0 +1,4 @@ + +

Cumulative incidence — brain metastasis from day 0, death competing

aggregate-only

ShowingNSCLCBreastColorectal

event
clock
diff --git a/test/snapshots/instability.html b/test/snapshots/instability.html new file mode 100644 index 0000000..228cc6c --- /dev/null +++ b/test/snapshots/instability.html @@ -0,0 +1,4 @@ + +

Genomic instability — from hg19 segments, ±0.2, arm called past 50% of its length

Previewaggregate-only

ShowingColorectalBreastPancreatic

data
diff --git a/test/snapshots/kinds.json b/test/snapshots/kinds.json new file mode 100644 index 0000000..3174325 --- /dev/null +++ b/test/snapshots/kinds.json @@ -0,0 +1,3471 @@ +{ + "compare": { + "menu": { + "label": "Compare genes", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "compare", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "compare", + "genes": [], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "compare", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ], + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Compare genes", + "headTitle": "Compare", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Compares how often specific genes are altered — mutated, gained, or lost — between two or more groups of patients. If a gene is altered much more often in one group than another, that's a clue it might be connected to whatever makes the groups different, like a cancer subtype or how patients respond to treatment." + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "Alteration frequencies — A vs B" + }, + "survival": { + "menu": { + "label": "Survival", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "survival", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "survival", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "survival", + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Survival", + "headTitle": "Survival", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Shows what share of patients in each group were still alive (or still free of disease progression) at each point in time, drawn as a curve that steps downward every time a patient's outcome is recorded. Comparing the curves for two or more groups answers: did one group tend to do better than the other, and if so, is the difference too large to be chance?" + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "OS survival — A vs B" + }, + "cox": { + "menu": { + "label": "Cox regression (forest plot)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "cox", + "covariates": [ + { + "source": "alteration", + "gene": "TP53", + "by": "mutated" + } + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "cox", + "covariates": [ + { + "source": "alteration", + "gene": "TP53", + "by": "mutated" + } + ], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "cox", + "covariates": [ + { + "source": "alteration", + "gene": "TP53", + "by": "mutated" + } + ], + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Cox regression (forest plot)", + "headTitle": "Cox regression", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "A more advanced version of the survival comparison above, which can weigh several factors at once — for example, a patient's age, a specific gene, and how far the disease had spread — to estimate how much each one changes risk on its own, holding the others equal. Each result is a \"hazard ratio\": above 1 means that factor is linked to worse outcomes, below 1 means it's linked to better ones." + }, + "need": "patients", + "preview": null, + "isNew": true, + "figureTitle": "Cox regression — OS — A vs B" + }, + "logistic": { + "menu": { + "label": "Logistic regression (odds ratios)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "logistic", + "outcome": { + "source": "alteration", + "gene": "TP53", + "by": "mutated" + }, + "outcomeEvent": "Mutated", + "covariates": [ + { + "source": "clinical", + "attribute": "TMB_NONSYNONYMOUS" + } + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "logistic", + "outcome": { + "source": "alteration", + "gene": "TP53", + "by": "mutated" + }, + "outcomeEvent": "Mutated", + "covariates": [ + { + "source": "clinical", + "attribute": "SEX" + } + ], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "logistic", + "outcome": { + "source": "alteration", + "gene": "TP53", + "by": "mutated" + }, + "outcomeEvent": "Mutated", + "covariates": [ + { + "source": "clinical", + "attribute": "TMB_NONSYNONYMOUS" + } + ], + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Logistic regression (odds ratios)", + "headTitle": "Logistic regression", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Estimates how strongly different factors are linked to a yes-or-no outcome — for example, whether a patient responded to a treatment — while accounting for several factors together. Each result is an \"odds ratio\": above 1 means that factor makes the outcome more likely, below 1 means less likely." + }, + "need": "patients", + "preview": null, + "isNew": true, + "figureTitle": "Logistic regression — A vs B" + }, + "oncoprint": { + "menu": { + "label": "Oncoprint", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "oncoprint", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "oncoprint", + "genes": [], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "oncoprint", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "cohorts": [] + }, + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Oncoprint", + "headTitle": "Oncoprint", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "A grid with one column per patient and one row per gene, color-coded to show what kind of genetic change (if any) each patient has in each gene. It's a fast way to see patterns across many patients and genes at once — for example, whether certain alterations tend to show up in the same patients, or never together." + }, + "need": "patients", + "preview": null, + "isNew": false, + "figureTitle": "Oncoprint — A vs B" + }, + "hotspots": { + "menu": { + "label": "Lollipop (hotspots)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "hotspots", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11" + ], + "minCount": 1, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "hotspots", + "genes": [], + "minCount": 1, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "hotspots", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11" + ], + "minCount": 1, + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Lollipop (hotspots)", + "headTitle": "Mutations", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Shows exactly where along a gene's protein its mutations land, drawn as a lollipop chart — a taller stem means more patients had a mutation at that exact spot. Mutations clustering tightly at one or two spots, rather than scattering evenly, usually points to a location that matters for how the protein works." + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "Recurrent protein changes — A vs B" + }, + "plot": { + "menu": { + "label": "Plot", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study declares no clinical attributes to plot", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "plot", + "x": { + "source": "clinical", + "attribute": "TMB_NONSYNONYMOUS" + }, + "y": { + "source": "clinical", + "attribute": "AGE" + }, + "unit": "sample", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "plot", + "unit": "sample", + "x": { + "source": "alteration", + "gene": "TP53", + "by": "mutationType" + }, + "y": { + "source": "alteration", + "gene": "KRAS", + "by": "mutated" + }, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "plot", + "x": { + "source": "clinical", + "attribute": "TMB_NONSYNONYMOUS" + }, + "y": { + "source": "clinical", + "attribute": "AGE" + }, + "unit": "sample", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Plot", + "headTitle": "Plot", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Plots two measurements against each other for the same patients or samples — for example, a gene's activity level against a patient's age — so you can see whether they tend to rise and fall together, split into distinct clusters, or show no relationship at all." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "plot — A vs B" + }, + "cooccurrence": { + "menu": { + "label": "Co-occurrence", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "cooccurrence", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "cooccurrence", + "genes": [], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "cooccurrence", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA" + ], + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Co-occurrence", + "headTitle": "Co-occurrence", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Checks whether alterations in two genes tend to show up in the same patients more often than you'd expect by chance (\"co-occurring\"), or rarely appear together (\"mutually exclusive\"). Genes that are mutually exclusive often play overlapping roles — if one is already altered, changing the other adds little, so cells rarely need both." + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "Co-occurrence and mutual exclusivity — A vs B" + }, + "clinical": { + "menu": { + "label": "Clinical comparison", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study declares no clinical attributes to compare", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "clinical", + "attributes": [ + "TMB_NONSYNONYMOUS", + "AGE", + "OS_MONTHS" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "clinical", + "attributes": [], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "clinical", + "attributes": [ + "TMB_NONSYNONYMOUS", + "AGE", + "OS_MONTHS" + ], + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Clinical comparison", + "headTitle": "Clinical comparison", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Compares things recorded about the patients themselves — age, sex, a lab result, and similar details — rather than their genetics, between two or more groups. It answers whether the groups differ in ways beyond whatever genetic difference defined them, which matters for judging whether a genetic finding is really the explanation." + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "Clinical comparison — A vs B" + }, + "coexpression": { + "menu": { + "label": "Co-expression", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no continuous molecular profile (mRNA z-scores) to correlate", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "coexpression", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "profileType": "rna_seq_v2_mrna", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "coexpression", + "genes": [], + "profileType": null, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "coexpression", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "profileType": "rna_seq_v2_mrna", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Co-expression", + "headTitle": "Co-expression", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Checks whether two genes tend to be more or less active together across patients — measured by how much each gene is \"read out\" into the working molecules a cell actually uses. Genes that rise and fall together are often working in the same biological pathway, or controlled by the same switch." + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "Co-expression — A vs B" + }, + "course": { + "menu": { + "label": "Clinical course", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study records no timeline events (only 131 of 545 studies have any)", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "course", + "eventTypes": [ + "treatment", + "diagnosis" + ], + "anchor": { + "eventType": "Treatment", + "occurrence": "FIRST" + }, + "binMonths": 3, + "fromMonths": -12, + "toMonths": 60, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "course", + "eventTypes": [], + "binMonths": 3, + "fromMonths": -12, + "toMonths": 60, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "course", + "eventTypes": [ + "treatment", + "diagnosis" + ], + "anchor": { + "eventType": "Treatment", + "occurrence": "FIRST" + }, + "binMonths": 3, + "fromMonths": -12, + "toMonths": 60, + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Clinical course", + "headTitle": "Clinical course", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Shows what share of patients were in each stage of their care — on treatment, in follow-up, and so on — at each point in time after diagnosis or another starting event. It's a picture of the typical patient's journey through care, not any one individual's." + }, + "need": null, + "preview": null, + "isNew": true, + "figureTitle": "Clinical course — A vs B" + }, + "sequence": { + "menu": { + "label": "Treatment sequence", + "why": "this study records no treatment events, so there are no lines of therapy to sequence", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no treatment events, so there are no lines of therapy to sequence", + "loading": false + }, + "menuAggregateOnly": { + "why": "this study records no treatment events, so there are no lines of therapy to sequence" + }, + "added": { + "kind": "sequence", + "eventType": "treatment", + "labelKey": "SUBTYPE", + "lineGap": 90, + "maxSteps": 3, + "topN": 8, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "sequence", + "labelKey": "SUBTYPE", + "lineGap": 90, + "maxSteps": 3, + "topN": 8, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "sequence", + "eventType": "treatment", + "labelKey": "SUBTYPE", + "lineGap": 90, + "maxSteps": 3, + "topN": 8, + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Treatment sequence", + "headTitle": "Treatment sequence", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Shows the order in which patients moved from one treatment to the next, drawn as flows between them. It answers a simple but useful question: after this treatment, what did doctors try next, and how often?" + }, + "need": null, + "preview": null, + "isNew": true, + "figureTitle": "Treatment sequence — A vs B" + }, + "attrition": { + "menu": { + "label": "Cohort attrition (CONSORT)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "attrition", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "attrition", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "attrition", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Cohort attrition (CONSORT)", + "headTitle": "Cohort attrition", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Cohort attrition (CONSORT)", + "title": "Cohort attrition", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "attrition" + }, + "defaultsBareStudy": { + "kind": "attrition" + } + }, + "drawsOwnWaiting": false, + "sub": "patients left after each clause, in order", + "help": { + "title": "What is this?", + "text": "Shows how each group of patients was narrowed down, one criterion at a time: how many patients the study started with, how many were left after each rule in the group's definition, and how many each rule removed. It lets a reader see who was left out before trusting any result drawn from the patients who remain." + }, + "need": null, + "preview": null, + "isNew": true, + "figureTitle": "Cohort attrition — A vs B" + }, + "genome": { + "menu": { + "label": "Genome-wide copy number", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "genome", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "genome", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "genome", + "cohorts": [] + }, + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Genome-wide copy number", + "headTitle": "Genome-wide copy number", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Genome-wide copy number", + "title": "Genome-wide copy number", + "maxCohorts": 4, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "genome" + }, + "defaultsBareStudy": { + "kind": "genome" + } + }, + "drawsOwnWaiting": false, + "sub": "from copy-number segments", + "help": { + "title": "What is this?", + "text": "Walks along every chromosome and shows, at each stretch, what share of the group's tumours have extra copies of that stretch of DNA (drawn upward, in red) or have lost copies of it (drawn downward, in blue). Stretches gained or lost in many tumours often hold genes that help the cancer grow, or genes that would have held it back." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Genome-wide copy number — A vs B" + }, + "pathways": { + "menu": { + "label": "Oncogenic pathways", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "pathways", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "pathways", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "pathways", + "cohorts": [] + }, + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Oncogenic pathways", + "headTitle": "Oncogenic signalling pathways", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Oncogenic pathways", + "title": "Oncogenic signalling pathways", + "maxCohorts": 4, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "pathways" + }, + "defaultsBareStudy": { + "kind": "pathways" + } + }, + "drawsOwnWaiting": false, + "sub": "OncoKB drivers, Sanchez-Vega et al. 2018", + "help": { + "title": "What is this?", + "text": "Groups genes into ten well-known signalling pathways — chains of genes that together tell a cell when to grow, divide or die — and shows what share of each group of patients has a harmful change in at least one gene of each pathway. Two cancers can reach the same broken pathway through different genes, and this view makes that visible." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Oncogenic signalling pathways — A vs B" + }, + "diffexpr": { + "menu": { + "label": "Differential expression + GSEA", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no molecular profile to compare expression on", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "diffexpr", + "profileType": "rna_seq_v2_mrna_median_Zscores", + "cohorts": [] + }, + "addedBareStudy": { + "kind": "diffexpr", + "profileType": "", + "cohorts": [] + }, + "addedWithTwoCohorts": { + "kind": "diffexpr", + "profileType": "rna_seq_v2_mrna_median_Zscores", + "cohorts": [] + }, + "maxCohorts": 2, + "emptyHint": "It compares exactly 2 cohorts.", + "title": "Differential expression + GSEA", + "headTitle": "Differential expression", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Differential expression + GSEA", + "title": "Differential expression", + "maxCohorts": 2, + "minCohorts": 2, + "why": null, + "whyBareStudy": "this study has no molecular profile to compare expression on", + "defaults": { + "kind": "diffexpr", + "profileType": "rna_seq_v2_mrna_median_Zscores" + }, + "defaultsBareStudy": { + "kind": "diffexpr", + "profileType": "" + } + }, + "drawsOwnWaiting": false, + "sub": "rna_seq_v2_mrna_median_Zscores", + "help": { + "title": "What is this?", + "text": "Compares how active every gene is between two groups of patients. Each dot is a gene: further left or right means a bigger difference in activity between the groups, higher up means the difference is less likely to be chance. The panel beside it asks whether whole families of related genes, such as those that drive cell division, lean towards one group." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Differential expression — A vs B" + }, + "paired": { + "menu": { + "label": "Paired samples (primary vs metastasis, before vs after)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "paired", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "paired", + "genes": [ + "TP53", + "KRAS", + "PIK3CA", + "ESR1", + "AR", + "CDKN2A", + "MYC", + "TERT" + ], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "paired", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [] + }, + "maxCohorts": 3, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "title": "Paired samples (primary vs metastasis, before vs after)", + "headTitle": "Paired samples", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Paired samples (primary vs metastasis, before vs after)", + "title": "Paired samples", + "genes": true, + "maxCohorts": 3, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "paired", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ] + }, + "defaultsBareStudy": { + "kind": "paired", + "genes": [ + "TP53", + "KRAS", + "PIK3CA", + "ESR1", + "AR", + "CDKN2A", + "MYC", + "TERT" + ] + } + }, + "drawsOwnWaiting": false, + "sub": "SAMPLE_TYPE: Primary vs Metastasis, drivers", + "help": { + "title": "What is this?", + "text": "For patients who had two kinds of tumour sample tested — usually the original tumour and a later spread of it elsewhere in the body — shows, gene by gene, how often a change was found in both samples, only in the original, or only in the spread. Changes found only in the spread can point to how the cancer adapted, for example to a treatment. The two kinds can also be samples taken before and after a treatment. Without a list of genes, it checks every gene and shows the ones most often found on one side only." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Paired samples — A vs B" + }, + "clonality": { + "menu": { + "label": "Clonality (FACETS)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "clonality", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "clonality", + "genes": [ + "TP53", + "KRAS", + "APC", + "PIK3CA", + "SMAD4", + "CDH1" + ], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "clonality", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [] + }, + "maxCohorts": 3, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "title": "Clonality (FACETS)", + "headTitle": "Clonality", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Clonality (FACETS)", + "title": "Clonality", + "genes": true, + "maxCohorts": 3, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "clonality", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ] + }, + "defaultsBareStudy": { + "kind": "clonality", + "genes": [ + "TP53", + "KRAS", + "APC", + "PIK3CA", + "SMAD4", + "CDH1" + ] + } + }, + "drawsOwnWaiting": false, + "sub": "FACETS allele-specific copy number, driver mutations, QC-passing samples", + "help": { + "title": "What is this?", + "text": "A tumour is a population of cells, and a genetic change can be in all of them (present from early on) or only in some (arriving later). For each gene, this shows how often its changes are in all the tumour's cells, and how often the cell has also lost its backup copy of that gene. Beside it: how often tumours have doubled their entire set of chromosomes." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Clonality (FACETS) — A vs B" + }, + "incidence": { + "menu": { + "label": "Cumulative incidence (competing risks)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study records no timeline events to estimate the incidence of", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "incidence", + "event": { + "eventType": "Diagnosis" + }, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "incidence", + "event": { + "eventType": "" + }, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "incidence", + "event": { + "eventType": "Diagnosis" + }, + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Cumulative incidence (competing risks)", + "headTitle": "Cumulative incidence", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Cumulative incidence (competing risks)", + "title": "Cumulative incidence", + "maxCohorts": 6, + "why": null, + "whyBareStudy": "this study records no timeline events to estimate the incidence of", + "defaults": { + "kind": "incidence", + "event": { + "eventType": "Diagnosis" + } + }, + "defaultsBareStudy": { + "kind": "incidence", + "event": { + "eventType": "" + } + } + }, + "drawsOwnWaiting": false, + "sub": "Diagnosis from day 0, death competing", + "help": { + "title": "What is this?", + "text": "Shows what share of patients have experienced a particular event — such as the cancer spreading to the brain — by each point in time. It counts patients who died before the event as never having it, rather than pretending they might still, which is why its numbers can be noticeably lower, and more honest, than a simple survival-style curve would suggest." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Cumulative incidence — A vs B" + }, + "labs": { + "menu": { + "label": "Lab course and response (e.g. PSA50)", + "why": "this study records no lab-test timeline events", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no lab-test timeline events", + "loading": false + }, + "menuAggregateOnly": { + "why": "this study records no lab-test timeline events" + }, + "added": { + "kind": "labs", + "test": "PSA", + "anchor": { + "eventType": "Treatment" + }, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "labs", + "test": "PSA", + "anchor": { + "eventType": "Treatment" + }, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "labs", + "test": "PSA", + "anchor": { + "eventType": "Treatment" + }, + "cohorts": [] + }, + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Lab course and response (e.g. PSA50)", + "headTitle": "Lab course", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Lab course and response (e.g. PSA50)", + "title": "Lab course", + "maxCohorts": 4, + "why": null, + "whyBareStudy": "this study records no lab-test timeline events", + "defaults": { + "kind": "labs", + "test": "PSA", + "anchor": { + "eventType": "Treatment" + } + }, + "defaultsBareStudy": { + "kind": "labs", + "test": "PSA", + "anchor": { + "eventType": "Treatment" + } + } + }, + "drawsOwnWaiting": false, + "sub": "PSA from first Treatment · responders fell ≥50%", + "help": { + "title": "What is this?", + "text": "Follows a blood test — such as PSA in prostate cancer — from the moment a treatment started, as the change from each patient's own starting level. The line shows the typical patient, the shaded band the middle half of patients, and the bars on the right each patient's best result: how far their level fell, or rose, after treatment began." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Lab course — A vs B" + }, + "riskmodel": { + "menu": { + "label": "Risk model (elastic-net Cox, OncoCast-style)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "riskmodel", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "riskmodel", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "riskmodel", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Risk model (elastic-net Cox, OncoCast-style)", + "headTitle": "Risk model", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Risk model (elastic-net Cox, OncoCast-style)", + "title": "Risk model", + "maxCohorts": 1, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "riskmodel" + }, + "defaultsBareStudy": { + "kind": "riskmodel" + } + }, + "drawsOwnWaiting": false, + "sub": "OS on mutated genes · 10 held-out splits", + "help": { + "title": "What is this?", + "text": "A model that predicts survival from which genes are mutated in each patient, in the style of the OncoCast package. It is trained many times, each time on two thirds of the patients and tested on the other third, so every patient's risk score comes from a model that never saw them. Patients are then split into low, intermediate and high risk, and their survival curves are drawn. The list shows the genes the model relied on most, with a hazard ratio above 1 meaning higher risk. Tick \"validate\" to train on one half of the patients and grade the model on the other half, which it never saw, or name another group of patients under \"validate on\" to grade it there — for example patients from a different study — with the same risk-group boundaries." + }, + "need": "patients", + "preview": null, + "isNew": true, + "figureTitle": "Risk model — A vs B" + }, + "cancertypes": { + "menu": { + "label": "Cancer types summary", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "cancertypes", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "cancertypes", + "genes": [ + "TP53", + "KRAS" + ], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "cancertypes", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "cohorts": [] + }, + "maxCohorts": 2, + "emptyHint": "It draws up to 2 cohorts, side by side.", + "title": "Cancer types summary", + "headTitle": "Cancer types summary", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Cancer types summary", + "title": "Cancer types summary", + "genes": true, + "maxCohorts": 2, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "cancertypes", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ] + }, + "defaultsBareStudy": { + "kind": "cancertypes", + "genes": [ + "TP53", + "KRAS" + ] + } + }, + "drawsOwnWaiting": false, + "sub": "by CANCER_TYPE, any alteration, % of profiled samples", + "help": { + "title": "What is this?", + "text": "For a list of genes, shows what share of samples in each cancer type carry a change in at least one of them — mutated, gained in many copies, deleted, or rearranged — with each bar split by which kind of change it was. It answers which cancers these genes matter most in, the way cBioPortal's Cancer Types Summary does." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Cancer types summary — A vs B" + }, + "overlap": { + "menu": { + "label": "Cohort overlap (UpSet)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "overlap", + "cohorts": [] + }, + "addedBareStudy": { + "kind": "overlap", + "cohorts": [] + }, + "addedWithTwoCohorts": { + "kind": "overlap", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It compares 2 to 6 cohorts.", + "title": "Cohort overlap (UpSet)", + "headTitle": "Cohort overlap", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Cohort overlap (UpSet)", + "title": "Cohort overlap", + "maxCohorts": 6, + "minCohorts": 2, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "overlap" + }, + "defaultsBareStudy": { + "kind": "overlap" + } + }, + "drawsOwnWaiting": false, + "sub": "patients in exactly each combination of cohorts", + "help": { + "title": "What is this?", + "text": "Shows how the groups of patients you defined overlap: for every combination of groups, how many patients belong to exactly those groups and none of the others. Each column is one combination — the dots below it say which groups — and its bar is how many patients fall there, so you can see at a glance whether two groups are mostly the same people or nearly separate." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Cohort overlap — A vs B" + }, + "enrichment": { + "menu": { + "label": "Alteration enrichment (all genes)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "enrichment", + "cohorts": [] + }, + "addedBareStudy": { + "kind": "enrichment", + "cohorts": [] + }, + "addedWithTwoCohorts": { + "kind": "enrichment", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It compares 2 to 6 cohorts.", + "title": "Alteration enrichment (all genes)", + "headTitle": "Alteration enrichment", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Alteration enrichment (all genes)", + "title": "Alteration enrichment", + "maxCohorts": 6, + "minCohorts": 2, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "enrichment" + }, + "defaultsBareStudy": { + "kind": "enrichment" + } + }, + "drawsOwnWaiting": false, + "sub": "mutations in every gene, per sample", + "help": { + "title": "What is this?", + "text": "Checks every gene at once for whether it is changed more often in one group of patients than another — mutated, gained, deleted or rearranged, whichever you choose. The genes at the top of the list, and furthest out on the chart, differ most clearly between the groups; the share in each group is beside every gene, so a difference that is clear but small is visible as small." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Alteration enrichment — A vs B" + }, + "assays": { + "menu": { + "label": "Generic assays (signatures, arm-level CNA, …)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "assays", + "profileType": "mutational_signatures_contribution_v2", + "cohorts": [] + }, + "addedBareStudy": { + "kind": "assays", + "profileType": "", + "cohorts": [] + }, + "addedWithTwoCohorts": { + "kind": "assays", + "profileType": "mutational_signatures_contribution_v2", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It compares 2 to 6 cohorts.", + "title": "Generic assays (signatures, arm-level CNA, …)", + "headTitle": "Generic assays", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Generic assays (signatures, arm-level CNA, …)", + "title": "Generic assays", + "maxCohorts": 6, + "minCohorts": 2, + "why": null, + "whyBareStudy": "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)", + "defaults": { + "kind": "assays", + "profileType": "mutational_signatures_contribution_v2" + }, + "defaultsBareStudy": { + "kind": "assays", + "profileType": "" + } + }, + "drawsOwnWaiting": false, + "sub": "mutational_signatures_contribution_v2, per sample", + "help": { + "title": "What is this?", + "text": "Compares two or more groups on every measurement in one kind of extra lab data the study recorded — such as which mutational signatures (like sun damage or smoking) shaped each tumour, or which chromosome arms were gained or lost. The measurements at the top differ most clearly between the groups; each one shows its average, or its mix of results, in every group, so a difference that is clear but small reads as small." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Generic assays — A vs B" + }, + "composition": { + "menu": { + "label": "Composition (cell types, states, signatures as parts of a whole)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no generic-assay profile and no numeric attribute to draw as parts", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "composition", + "profileType": "mutational_signatures_contribution_v2", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "composition", + "attributes": [], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "composition", + "profileType": "mutational_signatures_contribution_v2", + "cohorts": [] + }, + "maxCohorts": 8, + "emptyHint": "It compares 1 to 8 cohorts.", + "title": "Composition (cell types, states, signatures as parts of a whole)", + "headTitle": "Composition", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Composition (cell types, states, signatures as parts of a whole)", + "title": "Composition", + "maxCohorts": 8, + "minCohorts": 1, + "why": null, + "whyBareStudy": "this study has no generic-assay profile and no numeric attribute to draw as parts", + "defaults": { + "kind": "composition", + "profileType": "mutational_signatures_contribution_v2" + }, + "defaultsBareStudy": { + "kind": "composition", + "attributes": [] + } + }, + "drawsOwnWaiting": false, + "sub": "mutational_signatures_contribution_v2, as shares", + "help": { + "title": "What is this?", + "text": "Shows what each patient's (or sample's) tumour is made of — the share of each cell type, cell state or signature — as one stacked bar per case, sorted by the part you choose, with each group's average beside it. Under the bars, every group is set against the others part by part: the colour says whether a part is higher (red) or lower (blue) there, its size how sure that is. Parts of a whole move together, so one part rising means others fall." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Composition — A vs B" + }, + "correlates": { + "menu": { + "label": "Co-expression with one gene (genome-wide)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no continuous molecular profile (mRNA, protein, …) to correlate on", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "correlates", + "gene": "TP53", + "profileType": "rna_seq_v2_mrna", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "correlates", + "gene": "TP53", + "profileType": "", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "correlates", + "gene": "TP53", + "profileType": "rna_seq_v2_mrna", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Co-expression with one gene (genome-wide)", + "headTitle": "Co-expression", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Co-expression with one gene (genome-wide)", + "title": "Co-expression", + "maxCohorts": 1, + "why": null, + "whyBareStudy": "this study has no continuous molecular profile (mRNA, protein, …) to correlate on", + "defaults": { + "kind": "correlates", + "gene": "TP53", + "profileType": "rna_seq_v2_mrna" + }, + "defaultsBareStudy": { + "kind": "correlates", + "gene": "TP53", + "profileType": "" + } + }, + "drawsOwnWaiting": false, + "sub": "TP53 on rna_seq_v2_mrna · Spearman ρ, every gene", + "help": { + "title": "What is this?", + "text": "Takes one gene and checks every other gene for whether its activity rises and falls along with it across these patients. The genes at the top go up when it goes up; those at the bottom go down when it goes up. The chart on the left shows how all genes fall: most barely move with it, and the ones far out on either side are the ones listed. Moving together is a clue, not proof that one gene controls the other." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Co-expression — A vs B" + }, + "table": { + "menu": { + "label": "Clinical data table (download)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "table", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "table", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "table", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Clinical data table (download)", + "headTitle": "Clinical data", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Clinical data table (download)", + "title": "Clinical data", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "table" + }, + "defaultsBareStudy": { + "kind": "table" + } + }, + "drawsOwnWaiting": true, + "sub": "one row per sample", + "help": { + "title": "What is this?", + "text": "Lists every sample (or every patient) in your groups with everything the study recorded about them — cancer type, age, stage, survival and the rest — one row each. Click a column's name to sort by it; the download button saves every row as a file a spreadsheet can open. Studies that only share counts, never individual patients, are named rather than listed." + }, + "need": "patients", + "preview": null, + "isNew": false, + "figureTitle": "table — A vs B" + }, + "mutations": { + "menu": { + "label": "Mutation table (one gene)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "mutations", + "gene": "TP53", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "mutations", + "gene": "TP53", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "mutations", + "gene": "TP53", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Mutation table (one gene)", + "headTitle": "Mutations", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Mutation table (one gene)", + "title": "Mutations", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "mutations", + "gene": "TP53" + }, + "defaultsBareStudy": { + "kind": "mutations", + "gene": "TP53" + } + }, + "drawsOwnWaiting": true, + "sub": "TP53 · every mutation call", + "help": { + "title": "What is this?", + "text": "Lists every mutation found in one gene in your groups, one row per mutation: which sample it was in, the exact protein change, whether OncoKB considers it cancer-causing, what kind of change it is, and how much of the tumour carries it. Click a column to sort by it; the download saves every row. It sits under the lollipop chart, which shows the same mutations as positions along the protein." + }, + "need": "patients", + "preview": null, + "isNew": false, + "figureTitle": "mutations — A vs B" + }, + "alterations": { + "menu": { + "label": "Patient alterations (mutations, copy number, SVs; one patient at a time)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "alterations", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "alterations", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "alterations", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Patient alterations (mutations, copy number, SVs; one patient at a time)", + "headTitle": "Patient alterations", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Patient alterations (mutations, copy number, SVs; one patient at a time)", + "title": "Patient alterations", + "maxCohorts": 1, + "cohortInHeader": true, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "alterations" + }, + "defaultsBareStudy": { + "kind": "alterations" + } + }, + "drawsOwnWaiting": true, + "sub": "(element)", + "help": { + "title": "What is this?", + "text": "Lists one patient's genetic changes as cBioPortal's patient page does, in three tables: mutations (the gene, the protein change, whether OncoKB considers it cancer-causing, and how much of the tumour carries it), copy-number changes (genes amplified or deleted outright) and structural variants (fusions and rearrangements). A change found in several of the patient's samples is one row, with a numbered marker for each sample. Cohort % says how common the same kind of change in that gene is across the study. Each table can be searched, filtered, sorted and downloaded on its own. Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one." + }, + "need": "alterations", + "preview": null, + "isNew": false, + "figureTitle": "Patient alterations — A vs B" + }, + "timeline": { + "menu": { + "label": "Patient timeline (one patient at a time)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study records no timeline events", + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "timeline", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "timeline", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "timeline", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Patient timeline (one patient at a time)", + "headTitle": "Patient timeline", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Patient timeline (one patient at a time)", + "title": "Patient timeline", + "maxCohorts": 1, + "cohortInHeader": true, + "why": null, + "whyBareStudy": "this study records no timeline events", + "defaults": { + "kind": "timeline" + }, + "defaultsBareStudy": { + "kind": "timeline" + } + }, + "drawsOwnWaiting": true, + "sub": "(element)", + "help": { + "title": "What is this?", + "text": "Shows one patient's medical history on a single line of time, as cBioPortal's patient page does: when samples were taken, operations, each treatment and how long it lasted, diagnoses, and lab results drawn as small charts. Time is counted from the day of diagnosis, and long stretches with nothing recorded are cut short (the dashed line). Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one. Studies that only share counts, never individual patients, are named rather than listed." + }, + "need": "timeline", + "preview": null, + "isNew": false, + "figureTitle": "Patient timeline — A vs B" + }, + "acquired": { + "menu": { + "label": "Acquired under therapy (samples before vs after a treatment start)", + "why": "this study records no treatment timeline events to anchor on", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no treatment timeline events to anchor on", + "loading": false + }, + "menuAggregateOnly": { + "why": "this study records no treatment timeline events to anchor on" + }, + "added": { + "kind": "acquired", + "anchor": { + "eventType": "Treatment" + }, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "acquired", + "anchor": { + "eventType": "Treatment" + }, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "acquired", + "anchor": { + "eventType": "Treatment" + }, + "cohorts": [] + }, + "maxCohorts": 3, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "title": "Acquired under therapy (samples before vs after a treatment start)", + "headTitle": "Acquired under therapy", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Acquired under therapy (samples before vs after a treatment start)", + "title": "Acquired under therapy", + "genes": true, + "maxCohorts": 3, + "why": null, + "whyBareStudy": "this study records no treatment timeline events to anchor on", + "defaults": { + "kind": "acquired", + "anchor": { + "eventType": "Treatment" + } + }, + "defaultsBareStudy": { + "kind": "acquired", + "anchor": { + "eventType": "Treatment" + } + } + }, + "drawsOwnWaiting": false, + "sub": "last sample before the first Treatment vs the first ≥30 days after, any alteration, every gene scanned", + "help": { + "title": "What is this?", + "text": "For patients who had a tumour sample tested before a treatment started and another one afterwards, shows gene by gene how many had a change only in the later sample (acquired, to the right) or only in the earlier one (lost, to the left). Genes that keep turning up only after treatment can point to how tumours resist it. It cannot say the treatment caused the change: second biopsies are usually taken when the cancer grows again, and a change can simply have been missed the first time." + }, + "need": "local", + "preview": "no method record or golden test of the before/after pairing and McNemar counts yet", + "isNew": true, + "figureTitle": "Acquired under therapy — A vs B" + }, + "ctdna": { + "menu": { + "label": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "ctdna", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "ctdna", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "ctdna", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ], + "cohorts": [] + }, + "maxCohorts": 3, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "title": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", + "headTitle": "ctDNA vs tissue", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", + "title": "ctDNA vs tissue", + "genes": true, + "maxCohorts": 3, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "ctdna", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ] + }, + "defaultsBareStudy": { + "kind": "ctdna" + } + }, + "drawsOwnWaiting": false, + "sub": "plasma SAMPLE_CLASS = cfDNA/ctDNA vs tissue Tumor, any alteration", + "help": { + "title": "What is this?", + "text": "Tumours shed DNA into the blood, and a blood test (a \"liquid biopsy\") can read it. For patients who had both a blood sample and a tumour tissue sample tested, this shows gene by gene how often a change was found in both, only in the tissue, or only in the blood — and how often the blood test caught what the tissue showed. Below, it shows how many patients had tumour DNA detected in their blood at all, and whether those patients lived shorter or longer than those without it. With the clonal-haematopoiesis filter on, changes that probably come from ageing blood cells rather than the tumour — in genes such as DNMT3A and TET2, or also found in the patient's white blood cells — are left out first, and the figure says how many were removed and what the numbers were without the filter." + }, + "need": "local", + "preview": "no method record or golden test of the agreement counts, Wilson intervals and survival split, or of the clonal-haematopoiesis filter (chFilter) yet", + "isNew": true, + "figureTitle": "ctDNA vs tissue — A vs B" + }, + "exclusivity": { + "menu": { + "label": "Mutual exclusivity (DISCOVER, burden-aware)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "exclusivity", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "exclusivity", + "genes": [ + "KRAS", + "EGFR", + "BRAF", + "STK11", + "KEAP1", + "TP53" + ], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "exclusivity", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Mutual exclusivity (DISCOVER, burden-aware)", + "headTitle": "Mutual exclusivity", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Mutual exclusivity (DISCOVER, burden-aware)", + "title": "Mutual exclusivity", + "genes": true, + "maxCohorts": 1, + "cohortInHeader": true, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "exclusivity", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ] + }, + "defaultsBareStudy": { + "kind": "exclusivity", + "genes": [ + "KRAS", + "EGFR", + "BRAF", + "STK11", + "KEAP1", + "TP53" + ] + } + }, + "drawsOwnWaiting": false, + "sub": "(element)", + "help": { + "title": "What is this?", + "text": "Asks, for each pair of genes, whether tumours carry changes in both more or less often than chance — \"mutually exclusive\" genes are rarely changed together, which can mean either change alone is enough to drive the cancer. Its sense of \"chance\" accounts for how many changes each tumour has overall: a tumour with hundreds of changes is expected to share genes with many others, so it does not make two genes look linked just because they both turn up in heavily altered tumours. Beside it is the simpler test that ignores this, to show the difference." + }, + "need": "local", + "preview": "no method record or golden test of the DISCOVER background fit against the R package yet", + "isNew": true, + "figureTitle": "Mutual exclusivity (DISCOVER) — A vs B" + }, + "txendpoints": { + "menu": { + "label": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", + "why": "this study records no treatment timeline events", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no treatment timeline events", + "loading": false + }, + "menuAggregateOnly": { + "why": "this study records no treatment timeline events" + }, + "added": { + "kind": "txendpoints", + "endpoint": "ttnt", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "txendpoints", + "endpoint": "ttnt", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "txendpoints", + "endpoint": "ttnt", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", + "headTitle": "Treatment endpoints", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", + "title": "Treatment endpoints", + "maxCohorts": 6, + "why": null, + "whyBareStudy": "this study records no treatment timeline events", + "defaults": { + "kind": "txendpoints", + "endpoint": "ttnt", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + } + }, + "defaultsBareStudy": { + "kind": "txendpoints", + "endpoint": "ttnt", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + } + } + }, + "drawsOwnWaiting": false, + "sub": "time to next treatment from first Treatment [SUBTYPE=Immuno], death an event", + "help": { + "title": "What is this?", + "text": "Starts each patient's clock on the day a treatment began — for example their first immunotherapy — and follows them to an outcome that real-world studies use in place of a trial's: when they moved on to their next treatment (time to next treatment), when they stopped this one (time on treatment), or when their cancer was recorded as growing (real-world progression-free survival). Death counts as the outcome too, and patients still followed without it are counted up to their last visit. Each group's curve shows the share not yet there at each month, with the number still followed below. The groups were not randomised to the treatment, so a gap between curves is a clue, not proof that a gene or a group changes how well the treatment works." + }, + "need": "local", + "preview": "no method record or golden test of the endpoint derivation (lines, stops, progression) or the median intervals yet", + "isNew": true, + "figureTitle": "Treatment endpoints — A vs B" + }, + "multistate": { + "menu": { + "label": "Multistate model (progression, then death)", + "why": "this study records no diagnosis or progression timeline events", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no diagnosis or progression timeline events", + "loading": false + }, + "menuAggregateOnly": { + "why": "this study records no diagnosis or progression timeline events" + }, + "added": { + "kind": "multistate", + "progression": { + "eventType": "Diagnosis", + "key": "PROGRESSION", + "values": [ + "Y", + "Yes" + ] + }, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "multistate", + "progression": { + "eventType": "Diagnosis", + "key": "PROGRESSION", + "values": [ + "Y", + "Yes" + ] + }, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "multistate", + "progression": { + "eventType": "Diagnosis", + "key": "PROGRESSION", + "values": [ + "Y", + "Yes" + ] + }, + "cohorts": [] + }, + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Multistate model (progression, then death)", + "headTitle": "Multistate model", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Multistate model (progression, then death)", + "title": "Multistate model", + "maxCohorts": 4, + "why": null, + "whyBareStudy": "this study records no diagnosis or progression timeline events", + "defaults": { + "kind": "multistate", + "progression": { + "eventType": "Diagnosis", + "key": "PROGRESSION", + "values": [ + "Y", + "Yes" + ] + } + }, + "defaultsBareStudy": { + "kind": "multistate", + "progression": { + "eventType": "Diagnosis", + "key": "PROGRESSION", + "values": [ + "Y", + "Yes" + ] + } + } + }, + "drawsOwnWaiting": false, + "sub": "start → progression → death, months from day 0 · progression = Diagnosis PROGRESSION", + "help": { + "title": "What is this?", + "text": "Follows each patient through three states — alive without progression, alive after their cancer was first recorded as growing, and dead — from the start of their record (in MSK-CHORD, their first tumour sequencing). The coloured bands show, month by month, what share of each group is in each state; the lines below show how fast patients move along each of the three paths (progressing, dying without progressing, dying after progressing), and the table gives each path's rate and how much faster or slower each group moves along it than the first. Splitting death into 'after progression' and 'without it' shows where a group's worse survival comes from. Progression is read from radiology reports by a model, so it is only seen in patients who were scanned; the groups were not randomised, so a difference is a clue, not proof." + }, + "need": "local", + "preview": "no method record or golden test of the Aalen–Johansen occupation, Nelson–Aalen hazards or the delayed-entry Cox fits yet (no interval on the occupation)", + "isNew": true, + "figureTitle": "Multistate model — progression and death — A vs B" + }, + "rmst": { + "menu": { + "label": "Restricted mean OS time (RMST, landmarks)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no _MONTHS outcome column", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "rmst", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "rmst", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "rmst", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Restricted mean OS time (RMST, landmarks)", + "headTitle": "Restricted mean OS time", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Restricted mean OS time (RMST, landmarks)", + "title": "Restricted mean OS time", + "maxCohorts": 6, + "why": null, + "whyBareStudy": "this study has no _MONTHS outcome column", + "defaults": { + "kind": "rmst" + }, + "defaultsBareStudy": { + "kind": "rmst" + } + }, + "drawsOwnWaiting": false, + "sub": "area under the KM curve to τ where every cohort has 10 at risk · OS", + "help": { + "title": "What is this?", + "text": "Answers “how many months, on average, did patients in each group live within the first τ months?” — the shaded area under each survival curve up to τ. The difference between two groups is the months gained or lost in that window, which reads plainly even when the curves cross or only separate late (as with immunotherapy), where a single hazard ratio can mislead. τ is where every group still has at least ten patients followed, unless it is set. Landmark panels ask the same question again among the patients still alive and followed at 6 or 12 months, restarting the clock there. The groups were not randomised, so a difference is a clue, not proof." + }, + "need": "local", + "preview": "no method record or golden test of the restricted means, their variances and the landmark sets against R survRM2 yet", + "isNew": true, + "figureTitle": "Restricted mean survival time — A vs B" + }, + "predictive": { + "menu": { + "label": "Predictive biomarkers (gene × treatment interaction)", + "why": "this study records no treatment timeline events to define an exposure", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no treatment timeline events to define an exposure", + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "predictive", + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "predictive", + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "STK11", + "KEAP1", + "KRAS", + "TP53", + "EGFR" + ], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "predictive", + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [] + }, + "maxCohorts": 2, + "emptyHint": "It draws up to 2 cohorts, side by side.", + "title": "Predictive biomarkers (gene × treatment interaction)", + "headTitle": "Predictive biomarkers", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Predictive biomarkers (gene × treatment interaction)", + "title": "Predictive biomarkers", + "genes": true, + "maxCohorts": 2, + "why": null, + "whyBareStudy": "this study records no treatment timeline events to define an exposure", + "defaults": { + "kind": "predictive", + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ] + }, + "defaultsBareStudy": { + "kind": "predictive", + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "STK11", + "KEAP1", + "KRAS", + "TP53", + "EGFR" + ] + } + }, + "drawsOwnWaiting": false, + "sub": "gene × Treatment [SUBTYPE=Immuno] on OS · Cox per gene", + "help": { + "title": "What is this?", + "text": "Asks, gene by gene, whether a mutation changes how patients do on a particular treatment — not just whether it marks a better or worse outlook in general. For each gene it compares patients with and without the mutation twice: among those who received the treatment (upper marker) and among those who did not (lower marker). A gene whose markers sit far apart — say, much worse with the mutation only among the treated — may predict who benefits from that treatment; the right-hand column tests that difference. Treatment here was not assigned at random, and patients had to live long enough to receive it, so a finding is a lead to test properly, not proof." + }, + "need": "patients", + "preview": "no method record or golden test of the per-gene interaction fits yet", + "isNew": true, + "figureTitle": "Predictive biomarkers — A vs B" + }, + "cutpoint": { + "menu": { + "label": "Biomarker cut-point (best split of a number for OS)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no numeric clinical attribute to find a cut-point of", + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "cutpoint", + "attribute": "TMB_NONSYNONYMOUS", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "cutpoint", + "attribute": "TMB_NONSYNONYMOUS", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "cutpoint", + "attribute": "TMB_NONSYNONYMOUS", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Biomarker cut-point (best split of a number for OS)", + "headTitle": "Cut-point", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Biomarker cut-point (best split of a number for OS)", + "title": "Cut-point", + "maxCohorts": 1, + "why": null, + "whyBareStudy": "this study has no numeric clinical attribute to find a cut-point of", + "defaults": { + "kind": "cutpoint", + "attribute": "TMB_NONSYNONYMOUS" + }, + "defaultsBareStudy": { + "kind": "cutpoint", + "attribute": "TMB_NONSYNONYMOUS" + } + }, + "drawsOwnWaiting": false, + "sub": "TMB_NONSYNONYMOUS on OS · best log-rank cut, 1000 permutations", + "help": { + "title": "What is this?", + "text": "Looks for the value of a measurement — such as tumour mutational burden — that best splits patients into a group that did better and a group that did worse. It tries every reasonable dividing line (from the lowest tenth of patients to the highest) and draws how strongly each one separates survival; the dot marks the best. Because trying many lines and keeping the best always finds something, the view also reshuffles the measurement among the patients a thousand times to see how good a 'best line' chance alone produces, and reports how surprising the real best line is once that search is accounted for. A line found this way needs checking in other patients before anyone relies on it." + }, + "need": "patients", + "preview": "no method record or golden test of the maximally selected log-rank scan against R maxstat yet", + "isNew": true, + "figureTitle": "Cut-point — A vs B" + }, + "hla": { + "menu": { + "label": "HLA class I genotype (homozygosity, HLA LOH and OS)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study records no HLA class I alleles", + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "hla", + "covariates": [ + "TMB_NONSYNONYMOUS" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "hla", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "hla", + "covariates": [ + "TMB_NONSYNONYMOUS" + ], + "cohorts": [] + }, + "maxCohorts": 3, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "title": "HLA class I genotype (homozygosity, HLA LOH and OS)", + "headTitle": "HLA class I genotype", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "HLA class I genotype (homozygosity, HLA LOH and OS)", + "title": "HLA class I genotype", + "maxCohorts": 3, + "why": null, + "whyBareStudy": "this study records no HLA class I alleles", + "defaults": { + "kind": "hla", + "covariates": [ + "TMB_NONSYNONYMOUS" + ] + }, + "defaultsBareStudy": { + "kind": "hla" + } + }, + "drawsOwnWaiting": false, + "sub": "alleles at 2 fields · OS by homozygosity and by HLA LOH · adjusted for TMB_NONSYNONYMOUS", + "help": { + "title": "What is this?", + "text": "HLA class I molecules (HLA-A, -B and -C) show pieces of a tumour's proteins to the immune system. Everyone inherits two copies of each; a person who inherited two identical copies at a gene (homozygous) shows a narrower range of pieces, and a tumour can also lose one copy (HLA loss of heterozygosity) to hide. This shows how common each is in a cohort, and whether patients homozygous at any of the three genes, or whose tumour lost a copy, lived shorter or longer. In patients treated with immunotherapy, homozygosity has been linked to worse survival; in a cohort not limited to them the comparison describes prognosis, not response to treatment." + }, + "need": "patients", + "preview": "no method record or golden test of the homozygosity and LOH counts or the survival splits yet", + "isNew": true, + "figureTitle": "HLA class I genotype and OS — A vs B" + }, + "forest": { + "menu": { + "label": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "forest", + "exposure": { + "source": "alteration", + "gene": "TP53" + }, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "forest", + "exposure": { + "source": "alteration", + "gene": "TP53" + }, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "forest", + "exposure": { + "source": "alteration", + "gene": "TP53" + }, + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", + "headTitle": "Forest plot", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", + "title": "Forest plot", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "forest", + "exposure": { + "source": "alteration", + "gene": "TP53" + } + }, + "defaultsBareStudy": { + "kind": "forest", + "exposure": { + "source": "alteration", + "gene": "TP53" + } + } + }, + "drawsOwnWaiting": false, + "sub": "TP53 mutated · OS hazard ratio per CANCER_TYPE, random-effects pooled", + "help": { + "title": "What is this?", + "text": "Asks whether one thing — a gene being mutated, a high tumour mutation burden — goes with shorter or longer survival, separately in each cancer type, and then combines those answers. Each row is one cancer type: its square is the hazard ratio (right of the dashed line at 1 means patients with the change died sooner; left means they lived longer) and its line how sure we can be. The diamonds at the bottom combine the rows into one overall estimate. When the rows disagree a lot (a high I²), the overall estimate is an average of different effects, so read the rows. This shows an association, not a cause." + }, + "need": "patients", + "preview": "no method record or golden test of the per-stratum Cox fits and the DerSimonian–Laird pooling yet", + "isNew": true, + "figureTitle": "Forest plot — A vs B" + }, + "adjusted": { + "menu": { + "label": "Adjusted enrichment (every gene, logistic on covariates)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "adjusted", + "cohorts": [] + }, + "addedBareStudy": { + "kind": "adjusted", + "cohorts": [] + }, + "addedWithTwoCohorts": { + "kind": "adjusted", + "cohorts": [] + }, + "maxCohorts": 2, + "emptyHint": "It compares exactly 2 cohorts.", + "title": "Adjusted enrichment (every gene, logistic on covariates)", + "headTitle": "Adjusted enrichment", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Adjusted enrichment (every gene, logistic on covariates)", + "title": "Adjusted enrichment", + "maxCohorts": 2, + "minCohorts": 2, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "adjusted" + }, + "defaultsBareStudy": { + "kind": "adjusted" + } + }, + "drawsOwnWaiting": false, + "sub": "mutations in every gene mutated in ≥5% · adjusted for nothing", + "help": { + "title": "What is this?", + "text": "Compares how often each commonly mutated gene is mutated in two groups of patients — say, colorectal cancers diagnosed before and after age 50 — after taking into account other things that differ between the groups and also change mutation rates, such as how many mutations a tumour carries overall. Each dot is a gene: right of centre means more often mutated in the first group, left in the second, and higher means more certain. The table shows each gene's raw difference beside the adjusted one, so you can see which differences were explained away. A difference here is a clue, not proof of a cause." + }, + "need": "patients", + "preview": "no method record or golden test of the per-gene logistic fits yet", + "isNew": true, + "figureTitle": "Adjusted enrichment — A vs B" + }, + "instability": { + "menu": { + "label": "Genomic instability (aneuploidy score, FGA, arm-level calls)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "instability", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "instability", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "instability", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Genomic instability (aneuploidy score, FGA, arm-level calls)", + "headTitle": "Genomic instability", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Genomic instability (aneuploidy score, FGA, arm-level calls)", + "title": "Genomic instability", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "instability" + }, + "defaultsBareStudy": { + "kind": "instability" + } + }, + "drawsOwnWaiting": false, + "sub": "from hg19 segments, ±0.2, arm called past 50% of its length", + "help": { + "title": "What is this?", + "text": "Cancer cells often gain or lose whole chromosome arms, and how much of a tumour's genome is out of balance is a measure of how unstable it is. For each group of tumours, this shows how many chromosome arms are typically gained or lost (the \"aneuploidy score\") and what share of the genome is altered, then which arms are gained (red) or lost (blue) most often — patterns that differ by cancer type, such as chromosome 20q gained and 18q lost in colorectal cancer." + }, + "need": "local", + "preview": "no method record, and no check of the arm calls and aneuploidy scores against Taylor et al.'s published values yet", + "isNew": true, + "figureTitle": "Genomic instability — aneuploidy score and FGA — A vs B" + }, + "actionability": { + "menu": { + "label": "Clinical actionability (OncoKB levels, primary vs metastasis)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "actionability", + "groupBy": "CANCER_TYPE", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "actionability", + "groupBy": "CANCER_TYPE", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "actionability", + "groupBy": "CANCER_TYPE", + "cohorts": [] + }, + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Clinical actionability (OncoKB levels, primary vs metastasis)", + "headTitle": "Clinical actionability", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Clinical actionability (OncoKB levels, primary vs metastasis)", + "title": "Clinical actionability", + "maxCohorts": 4, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "actionability", + "groupBy": "CANCER_TYPE" + }, + "defaultsBareStudy": { + "kind": "actionability", + "groupBy": "CANCER_TYPE" + } + }, + "drawsOwnWaiting": false, + "sub": "highest OncoKB level per patient, any cancer type · by CANCER_TYPE · SAMPLE_TYPE concordance", + "help": { + "title": "What is this?", + "text": "For each patient, finds the strongest evidence OncoKB lists for a drug that targets one of their tumour's genetic changes — Level 1 means an FDA-approved drug for that change, down to Level 4 for early biological evidence — and shows what share of patients reach each level, overall or per cancer type. The levels here are the best OncoKB gives the change in ANY cancer, so a change that is only actionable in another cancer still counts: read the bars as an upper bound. The table below compares, for patients sequenced twice, the level found in their primary tumour against their metastasis." + }, + "need": "local", + "preview": "no method record of the per-patient highest-level counts yet", + "isNew": true, + "figureTitle": "Clinical actionability — A vs B" + }, + "drivernegative": { + "menu": { + "label": "Driver-negative analysis (genes enriched without a canonical driver)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "drivernegative", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "drivernegative", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "drivernegative", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Driver-negative analysis (genes enriched without a canonical driver)", + "headTitle": "Driver-negative analysis", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Driver-negative analysis (genes enriched without a canonical driver)", + "title": "Driver-negative analysis", + "maxCohorts": 1, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "drivernegative" + }, + "defaultsBareStudy": { + "kind": "drivernegative" + } + }, + "drawsOwnWaiting": false, + "sub": "mutations without vs with a driver in the NSCLC set, per patient", + "help": { + "title": "What is this?", + "text": "Splits one group of patients — say, lung cancers — into those whose tumour carries one of the well-known, usually treatable cancer-driving changes (EGFR, KRAS, ALK and the rest of the list) and those whose tumour carries none, and then asks which other genes are changed more often in the second group. Those genes are candidates for what drives the tumours without a known driver. Patients whose test did not cover every gene on the list are kept apart, since we cannot tell whether they have a driver. The bar shows how big each group is; in the chart, genes to the right are more common without a known driver." + }, + "need": "local", + "preview": "no method record or golden test of the group split and the per-gene Fisher tests yet", + "isNew": true, + "figureTitle": "Driver-negative analysis — A vs B" + }, + "organotropism": { + "menu": { + "label": "Metastatic organotropism (genes × metastatic sites, within cancer types)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "organotropism", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "organotropism", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "organotropism", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Metastatic organotropism (genes × metastatic sites, within cancer types)", + "headTitle": "Metastatic organotropism", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Metastatic organotropism (genes × metastatic sites, within cancer types)", + "title": "Metastatic organotropism", + "maxCohorts": 1, + "cohortInHeader": true, + "why": null, + "whyBareStudy": "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)", + "defaults": { + "kind": "organotropism" + }, + "defaultsBareStudy": { + "kind": "organotropism" + } + }, + "drawsOwnWaiting": false, + "sub": "driver mutations in the 12 most altered genes × metastatic sites from the study's site source, within CANCER_TYPE", + "help": { + "title": "What is this?", + "text": "Asks, for each organ a cancer spread to — brain, liver, bone and the rest — whether patients whose tumour carries a change in a given gene spread there more or less often than patients whose tumour does not, comparing patients within the same cancer type. Red squares mean the gene's change goes with spread to that organ, blue that it goes with spreading there less; outlined squares are the ones unlikely to be chance after testing many pairs. Where the study dates each site, the curve below shows how quickly patients with and without one gene's change develop spread to one organ, counting deaths as a separate outcome. Sites are only known where scans or biopsies found them." + }, + "need": "local", + "preview": "no method record or golden test of the per-cell Cochran–Mantel–Haenszel tests or the site incidence yet", + "isNew": true, + "figureTitle": "Organotropism — A vs B" + }, + "molsubtype": { + "menu": { + "label": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "molsubtype", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "molsubtype", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "molsubtype", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", + "headTitle": "Molecular subtypes", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", + "title": "Molecular subtypes", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "molsubtype" + }, + "defaultsBareStudy": { + "kind": "molsubtype" + } + }, + "drawsOwnWaiting": false, + "sub": "endometrial molecular classification per patient (POLE, MMRd, p53abn, NSMP) · OS by subtype", + "help": { + "title": "What is this?", + "text": "Sorts each patient's endometrial cancer into the four molecular groups doctors now use alongside the microscope: POLE-ultramutated (a tumour with a broken DNA-copying enzyme, which does very well), mismatch-repair deficient (a tumour that cannot fix certain DNA errors, read here from its MSI test), p53-abnormal (the group that does worst), and no specific molecular profile. The groups are assigned in that order, as the WHO guideline does. Patients whose test could not tell — no MSI result, or a gene panel that missed POLE or TP53 — are counted as not classifiable rather than guessed. The bars show each group's share, overall and by the attribute you pick; the curves show survival in each group." + }, + "need": "local", + "preview": "no method record or golden test of the per-patient classification, the chi-squared tests or the survival split yet", + "isNew": true, + "figureTitle": "Molecular subtypes — A vs B" + }, + "germline": { + "menu": { + "label": "Germline carriers (and their somatic second hits)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "germline", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "germline", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "germline", + "cohorts": [] + }, + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Germline carriers (and their somatic second hits)", + "headTitle": "Germline carriers", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Germline carriers (and their somatic second hits)", + "title": "Germline carriers", + "maxCohorts": 4, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "germline" + }, + "defaultsBareStudy": { + "kind": "germline" + } + }, + "drawsOwnWaiting": false, + "sub": "germline carriers per patient sequenced, every gene with ≥3 carriers · somatic second hit vs non-carriers", + "help": { + "title": "What is this?", + "text": "Some people are born with a faulty copy of a cancer gene such as BRCA2 in every cell. For each such gene this counts those patients (carriers) among everyone whose tumour was sequenced for it, and asks whether their tumour knocked out the remaining good copy — a second hit — by a further mutation or by deleting the gene, compared with how often the same gene is hit in patients born without a faulty copy. The commonest second hit, losing the good copy outright (loss of heterozygosity), needs data this view does not read, so the second-hit share is a minimum. Optionally the curves compare survival of carriers and non-carriers for one gene." + }, + "need": "local", + "preview": "no method record or golden test of the carrier and second-hit counts or the Fisher tests yet", + "isNew": true, + "figureTitle": "Germline carriers and second hits — A vs B" + } +} diff --git a/test/snapshots/labs.html b/test/snapshots/labs.html new file mode 100644 index 0000000..d15ca20 --- /dev/null +++ b/test/snapshots/labs.html @@ -0,0 +1,4 @@ + +

Lab course — PSA from first Treatment [SUBTYPE=Hormone] · responders fell ≥50%

patient-level

ShowingProstate, TP53 mutantProstate, TP53 wild-type

test
clock
diff --git a/test/snapshots/logistic.html b/test/snapshots/logistic.html new file mode 100644 index 0000000..84dba73 --- /dev/null +++ b/test/snapshots/logistic.html @@ -0,0 +1,4 @@ + +

Logistic regression — Multivariate logistic, odds ratios

aggregate-only

Showingall patients

outcomeoutcome
covariate 1
diff --git a/test/snapshots/molsubtype.html b/test/snapshots/molsubtype.html new file mode 100644 index 0000000..1753f30 --- /dev/null +++ b/test/snapshots/molsubtype.html @@ -0,0 +1,4 @@ + +

Molecular subtypes — endometrial molecular classification per patient (POLE, MMRd, p53abn, NSMP) · by SUBTYPE · OS by subtype

Previewaggregate-only

ShowingUCEC

data
diff --git a/test/snapshots/multistate.html b/test/snapshots/multistate.html new file mode 100644 index 0000000..8dbe223 --- /dev/null +++ b/test/snapshots/multistate.html @@ -0,0 +1,4 @@ + +

Multistate model — start → progression → death, months from day 0 · progression = Diagnosis PROGRESSION

Previewaggregate-only

ShowingColorectal, stage 1-3Colorectal, stage 4

progression
day 0
diff --git a/test/snapshots/mutations.html b/test/snapshots/mutations.html new file mode 100644 index 0000000..1084d0c --- /dev/null +++ b/test/snapshots/mutations.html @@ -0,0 +1,4 @@ + +

Mutations — KRAS · every mutation call · 1,378 rows

patient-level

ShowingColorectal

data
1,378 of 2,426 KRAS mutations
P-0044990-T01-IM6G12DOncogenic · Level 10.939
P-0074217-T02-IM7G12COncogenic · Level 10.923
P-0066632-T01-IM7G12DOncogenic · Level 10.907
P-0011108-T01-IM5G12DOncogenic · Level 10.899
P-0046395-T01-IM6G13DOncogenic · Level 10.8110
P-0018448-T01-IM6G12DOncogenic · Level 10.8016
P-0055331-T01-IM6G12AOncogenic · Level 10.809
P-0057020-T01-IM6G12COncogenic · Level 10.797
P-0012877-T01-IM5G12DOncogenic · Level 10.7910
P-0005029-T01-IM5G13DOncogenic · Level 10.793
P-0015210-T01-IM6G12DOncogenic · Level 10.785
P-0000739-T01-IM3G12COncogenic · Level 10.777
P-0078660-T01-IM7G12COncogenic · Level 10.7613
P-0007990-T01-IM5G12SOncogenic · Level 10.767
P-0050517-T01-IM6G12VOncogenic · Level 10.7510
P-0039168-T01-IM6G12SOncogenic · Level 10.744
P-0041600-T01-IM6G12VOncogenic · Level 10.746
P-0046742-T01-IM6G13DOncogenic · Level 10.727
P-0008735-T01-IM5G12COncogenic · Level 10.7210
P-0089247-T01-IM7G12VOncogenic · Level 10.728
P-0057012-T01-IM6G12VOncogenic · Level 10.729
P-0012653-T01-IM5G13DOncogenic · Level 10.724
P-0042211-T01-IM6G12DOncogenic · Level 10.728
P-0087879-T01-IM7G13DOncogenic · Level 10.728
P-0077659-T01-IM7G12ROncogenic · Level 10.7238
…Showing 1–25 of 1,378 KRAS mutations

Every KRAS mutation call in the cohort's samples, one row per call; uncalled mutations are left out, as the portal leaves them out. Annotation is OncoKB v7.5 (oncogenicity and its highest sensitivity level for the gene). Allele frequency is variant reads over variant + reference reads; # Mut in Sample is the study's own MUTATION_COUNT, blank where it records none. Showing 1,378 of 2,426 mutations: rows Protein Change matches /^G1[23]/; Allele Freq (T) ≥ 0.2. Sorted by Allele Freq (T) (highest first), missing values last, across the cohort; every page is a slice of that one order, and the download holds every row kept, with every column.

diff --git a/test/snapshots/organotropism.html b/test/snapshots/organotropism.html new file mode 100644 index 0000000..ba561a5 --- /dev/null +++ b/test/snapshots/organotropism.html @@ -0,0 +1,4 @@ + +

Metastatic organotropism — driver mutations in 6 genes × metastatic sites from timeline, within CANCER_TYPE

Previewaggregate-only
data
diff --git a/test/snapshots/overlap.html b/test/snapshots/overlap.html new file mode 100644 index 0000000..c75b7a5 --- /dev/null +++ b/test/snapshots/overlap.html @@ -0,0 +1,4 @@ + +

Cohort overlap — patients in exactly each combination of cohorts

aggregate-only

ShowingTP53 mutantKRAS mutantImmunotherapyNSCLC

diff --git a/test/snapshots/paired.html b/test/snapshots/paired.html new file mode 100644 index 0000000..e0904ca --- /dev/null +++ b/test/snapshots/paired.html @@ -0,0 +1,4 @@ + +

Paired samples — Primary vs Metastasis, drivers

aggregate-only

Showingall patients

data
diff --git a/test/snapshots/pathways.html b/test/snapshots/pathways.html new file mode 100644 index 0000000..8cbe28f --- /dev/null +++ b/test/snapshots/pathways.html @@ -0,0 +1,4 @@ + +

Oncogenic signalling pathways — OncoKB drivers, Sanchez-Vega et al. 2018

aggregate-only

ShowingColorectalPancreatic

data
diff --git a/test/snapshots/plot.html b/test/snapshots/plot.html new file mode 100644 index 0000000..638b233 --- /dev/null +++ b/test/snapshots/plot.html @@ -0,0 +1,8 @@ + +

Plot — box · 1,068 samples

aggregate-only

Showingall samples

Showing 1,068 of 1,084 samples with data on both axes

colour samples byfill
ring
+ +

Plot — box · 1,068 samples

aggregate-only

Showingall samples

Showing 1,068 of 1,084 samples with data on both axes

colour samples byfill
ring
diff --git a/test/snapshots/pooled-paired.html b/test/snapshots/pooled-paired.html new file mode 100644 index 0000000..fd2100c --- /dev/null +++ b/test/snapshots/pooled-paired.html @@ -0,0 +1,12 @@ + +

Paired samples — before vs after, drivers, every gene scanned

aggregate-only

Showingbefore and after treatment

datasides set per study in the document (5 studies)
diff --git a/test/snapshots/predictive.html b/test/snapshots/predictive.html new file mode 100644 index 0000000..e74fefd --- /dev/null +++ b/test/snapshots/predictive.html @@ -0,0 +1,4 @@ + +

Predictive biomarkers — gene × Treatment [SUBTYPE=Immuno] on OS · Cox per gene

Previewpatient-level

ShowingNSCLC

exposure
diff --git a/test/snapshots/riskmodel-validateon.html b/test/snapshots/riskmodel-validateon.html new file mode 100644 index 0000000..4f75c0b --- /dev/null +++ b/test/snapshots/riskmodel-validateon.html @@ -0,0 +1,4 @@ + +

Risk model — OS on mutated genes · 10 held-out splits, scored on “NSCLC (MSK-CHORD)”

patient-level

ShowingNSCLC (MSK-IMPACT 2017)

data
model
view
diff --git a/test/snapshots/riskmodel.html b/test/snapshots/riskmodel.html new file mode 100644 index 0000000..9ff50a0 --- /dev/null +++ b/test/snapshots/riskmodel.html @@ -0,0 +1,4 @@ + +

Risk model — OS on mutated genes · 5 held-out splits

patient-level

ShowingNSCLC

data
model· in the document
view
diff --git a/test/snapshots/rmst.html b/test/snapshots/rmst.html new file mode 100644 index 0000000..5dc8bf1 --- /dev/null +++ b/test/snapshots/rmst.html @@ -0,0 +1,4 @@ + +

Restricted mean OS time — area under the KM curve to τ = 24 months · OS · landmarks 0, 6, 12 mo

Previewaggregate-only

ShowingTMB < 10TMB ≥ 10

restrict
diff --git a/test/snapshots/sequence.html b/test/snapshots/sequence.html new file mode 100644 index 0000000..cc173fb --- /dev/null +++ b/test/snapshots/sequence.html @@ -0,0 +1,4 @@ + +

Treatment sequence — lines of therapy by SUBTYPE · 90-day line gap

aggregate-only

ShowingTP53 mutTP53 wt

diff --git a/test/snapshots/survival.html b/test/snapshots/survival.html new file mode 100644 index 0000000..807f4a4 --- /dev/null +++ b/test/snapshots/survival.html @@ -0,0 +1,4 @@ + +

Survival — Kaplan–Meier, PFS_1L

aggregate-only

ShowingMSI-HMSS

endpointmeasured fromdisplaytickscutoffmo
diff --git a/test/snapshots/table.html b/test/snapshots/table.html new file mode 100644 index 0000000..108cb35 --- /dev/null +++ b/test/snapshots/table.html @@ -0,0 +1,4 @@ + +

Clinical data — one row per sample · 3,134 rows, 52 attributes

patient-level

ShowingPancreatic

data
3,134 samples
P-0006018P-0006018-T01-IM5Pancreatic Cancer2ARegional,Extension and NodesPancreasBlastoma, Nos2BPancreatic Neuroendocrine Tumor0:LIVINGMetastasisWhiteFemaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT41089UnknownMatchedTumorUnknown220.741611.9574393950.6LiverPANET0.032876676Pancreas56490YesNoNoYesYesNoYesYesNoNo
P-0006930P-0006930-T01-IM5Pancreatic Cancer1ARegional,Direct ExtensionPancreasAdenocarcinoma, Nos2APancreatic Adenocarcinoma0:LIVINGPrimaryWhiteFemaleStage 1-3Unknown whether Spanish or notStableIMPACT41089NeverMatchedTumorUnknown150.112416.8510378830PAAD11.99998685Pancreas103010NoNoNoNoNoYesNoYesNoNo
P-0007791P-0007791-T01-IM5Pancreatic Cancer2ARegional,Extension and NodesPancreasTubular Adenocarcinoma2BPancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteMaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT41089Former/Current SmokerMatchedTumorNo prior medications110.016910.978719698MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD80.90950037Pancreas71730NoNoNoNoYesNoYesYesNoNo
P-0009404P-0009404-T01-IM5Pancreatic Cancer1ARegional,Direct ExtensionPancreasAdenocarcinoma, Nos2APancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteFemaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT41089NeverMatchedTumorUnknown150.069214.893598488MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD8.021909017Pancreas56610NoYesNoYesYesNoYesYesNoYes
P-0010195P-0010195-T01-IM5Pancreatic Cancer3Regional,Direct ExtensionPancreasAdenocarcinoma, Nos0Pancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteFemaleStage 1-3Spanish NOS; Hispanic NOS, Latino NOSStableIMPACT41089Former/Current SmokerMatchedTumorNo prior medications130.13112.936159093MICROSATELLITE STABLE (MSS). See MSI note below.0.44PAAD3.978077832Pancreas23630NoNoNoNoNoNoNoYesNoNo
P-0011111P-0011111-T01-IM5Pancreatic Cancer1BRegional Both 2 and 3PancreasInfiltrating Duct Carcinoma2BPancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteMaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT41089NeverMatchedTumorNo prior medications240.013.91487879MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD26.33421772Pancreas37920NoNoNoNoNoYesYesYesNoNo
P-0012579P-0012579-T01-IM5Pancreatic Cancer1BRegional by Direct ExtensionPancreasInfiltrating Duct Carcinoma2APancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteFemaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT41089Former/Current SmokerMatchedTumorNo prior medications120.061811.9574393950.07PAAD28.30681829Pancreas78520NoNoNoNoNoNoNoYesNoNo
P-0013009P-0013009-T01-IM5Pancreatic Cancer4Distant Metastases/Systemic DiseasePancreasAdenocarcinoma, NosPancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteFemaleStage 4Non-Spanish; Non-HispanicStableIMPACT41089NeverMatchedTumorNo prior medications250.170514.8935984880.14PAAD8.580812514Pancreas90910NoNoNoYesYesYesYesYesNoNo
P-0018639P-0018639-T01-IM6Pancreatic Cancer1ARegional by Direct ExtensionPancreasInfiltrating Duct Carcinoma2APancreatic Adenocarcinoma0:LIVINGPrimaryWhiteFemaleStage 1-3Unknown whether Spanish or notDo not reportIMPACT46889Former/Current SmokerMatchedTumorNo prior medications350.014.3234904750PAAD76.50402575Pancreas87320NoNoNoNoYesYesNoYesNoNo
P-0023044P-0023044-T01-IM6Pancreatic Cancer2ARegional by Direct ExtensionPancreasAdenocarcinoma, NosPancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteFemaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT46889NeverMatchedTumorPrior medications to MSK180.00416.91758476MICROSATELLITE STABLE (MSS). See MSI note below.0.88PAAD6.246568497Pancreas17430NoYesNoNoYesYesYesNoNoNo
P-0024552P-0024552-T01-IM6Pancreatic Cancer4Distant Metastases/Systemic DiseasePancreasAdenocarcinoma, Nos4Pancreatic Adenocarcinoma1:DECEASEDMetastasisWhiteMaleStage 4Non-Spanish; Non-HispanicStableIMPACT46889Former/Current SmokerMatchedTumorNo prior medications6330.217512.5940942856MICROSATELLITE STABLE (MSS). See MSI note below.0.29LiverPAAD6.082185115Pancreas82940YesNoNoYesYesNoNoYesNoNo
P-0028084P-0028084-T01-IM6Pancreatic Cancer1BRegional Both 2 and 3PancreasInfiltrating Duct Carcinoma2BPancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteFemaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT46889NeverMatchedTumorNo prior medications230.012112.594094285MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD52.27391532Pancreas69610NoNoNoNoNoNoNoYesNoNo
P-0028158P-0028158-T01-IM6Pancreatic Cancer2ARegional by Direct ExtensionPancreasCarcinoma, Undifferentiated Type, NosUndifferentiated Carcinoma of the Pancreas1:DECEASEDMetastasisWhiteFemaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT46889NeverMatchedTumorNo prior medicationsYes290.297617.782282855MICROSATELLITE STABLE (MSS). See MSI note below.0.61UCP16.53696818PancreasYes65650NoNoNoNoNoYesNoYesNoNo
P-0028249P-0028249-T01-IM6Pancreatic Cancer1ARegional Both 2 and 3PancreasInfiltrating Duct Carcinoma3Pancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteMaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT46889NeverMatchedTumorNo prior medications160.015.18818857MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD31.69311595Pancreas75810NoNoNoNoNoNoYesYesNoNo
P-0029109P-0029109-T01-IM6Pancreatic CancerLocalizedPancreasAdenocarcinoma, Nos1APancreatic Adenocarcinoma0:LIVINGPrimaryWhiteFemaleStage 1-3Spanish NOS; Hispanic NOS, Latino NOSStableIMPACT46889Former/Current SmokerMatchedTumorNo prior medications150.016.052886665MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD59.60541413Pancreas53320NoNoNoNoNoNoYesYesNoNo
P-0030506P-0030506-T01-IM6Pancreatic Cancer1BRegional,Extension and NodesPancreasInfiltrating Duct Carcinoma2BPancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteMaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT46889NeverMatchedTumorUnknown7330.054912.5940942857MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD6.016431763Pancreas52540NoNoNoYesYesNoNoYesNoYes
P-0031431P-0031431-T01-IM6Pancreatic Cancer4Distant Metastases/Systemic DiseasePancreasAdenocarcinoma, Nos4Pancreatic Adenocarcinoma0:LIVINGPrimaryWhiteMaleStage 4Non-Spanish; Non-HispanicStableIMPACT46889Former/Current SmokerMatchedTumorNo prior medications1100.225818.64698095MICROSATELLITE STABLE (MSS). See MSI note below.0.19PAAD47.93419404Pancreas53030NoNoNoNoNoNoNoNoNoNo
P-0032403P-0032403-T01-IM6Pancreatic Cancer4Distant Metastases/Systemic DiseasePancreasAdenocarcinoma, Nos4Pancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteFemaleStage 4Non-Spanish; Non-HispanicStableIMPACT46889NeverMatchedTumorNo prior medications160.23515.18818857MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD43.59447277Pancreas64530NoNoNoNoYesNoNoYesNoNo
P-0037989P-0037989-T01-IM6Pancreatic Cancer1BRegional To Lymph NodesPancreasInfiltrating Duct Carcinoma2BPancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteMaleStage 1-3Unknown whether Spanish or notStableIMPACT46889NeverMatchedTumorNo prior medications270.034916.052886665MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD19.0027189Pancreas65240NoNoNoYesNoYesNoYesNoNo
P-0038511P-0038511-T01-IM6Pancreatic Cancer1BLocalizedPancreasAdenocarcinoma, NosPancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteMaleStage 1-3Unknown whether Spanish or notStableIMPACT46889Former/Current SmokerMatchedTumorNo prior medications7260.309715.188188577MICROSATELLITE STABLE (MSS). See MSI note below.0.26PAAD12.95341046Pancreas58740NoNoNoNoYesNoNoYesNoNo
P-0039568P-0039568-T01-IM6Pancreatic Cancer2ARegional Both 2 and 3PancreasAdenocarcinoma, Nos3Pancreatic Adenocarcinoma0:LIVINGMetastasisWhiteFemaleStage 1-3Unknown whether Spanish or notStableIMPACT46889NeverMatchedTumorNo prior medications350.221514.323490475MICROSATELLITE STABLE (MSS). See MSI note below.0.06DuodenumPAAD35.60544043Pancreas59230NoNoNoYesNoNoYesYesNoNo
P-0040808P-0040808-T01-IM6Pancreatic Cancer2ALocalizedPancreasAdenocarcinoma, NosPancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteMaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT46889Former/Current SmokerMatchedTumorNo prior medications280.01616.91758476MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD8.284922427Pancreas76320YesNoNoNoYesNoYesYesNoNo
P-0044512P-0044512-T01-IM6Pancreatic Cancer1BRegional Both 2 and 3PancreasInfiltrating Duct Carcinoma2BPancreatic Adenocarcinoma1:DECEASEDPrimaryWhiteFemaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT46889NeverMatchedTumorNo prior medications130.04412.594094285MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD45.00816985Pancreas63210NoNoNoYesYesYesNoYesNoNo
P-0046434P-0046434-T01-IM6Pancreatic Cancer3Regional,Direct ExtensionPancreasAdenocarcinoma, NosPancreatic Adenocarcinoma1:DECEASEDMetastasisWhiteMaleStage 1-3Unknown whether Spanish or notStableIMPACT46889NeverMatchedTumorUnknown150.046114.323490475MICROSATELLITE STABLE (MSS). See MSI note below.0.06LiverPAAD7.956155664Pancreas47410NoNoNoNoYesYesNoYesNoNo
P-0048987P-0048987-T01-IM6Pancreatic CancerLocalizedPancreasInfiltrating Duct Carcinoma1APancreatic Adenocarcinoma0:LIVINGPrimaryWhiteMaleStage 1-3Non-Spanish; Non-HispanicStableIMPACT46889Former/Current SmokerMatchedTumorUnknown6240.00213.458792386MICROSATELLITE STABLE (MSS). See MSI note below.0PAAD46.8821404Pancreas62620NoNoNoNoYesNoNoYesNoNo
…Showing 1–25 of 3,134 samples

One row per sample of each cohort's patients, carrying its patient's attributes too, with 50 attributes as each study recorded them — vocabularies are not harmonized across studies. 3,134 samples. Sorted by Current Age (highest first), missing values last, across the cohort; every page is a slice of that one order, and the download holds every row kept, with every column.

diff --git a/test/snapshots/timeline.html b/test/snapshots/timeline.html new file mode 100644 index 0000000..50116b1 --- /dev/null +++ b/test/snapshots/timeline.html @@ -0,0 +1,8 @@ + +

Patient timeline — Breast · 5,368 patients · clinical events from diagnosis

patient-level
patient 1,252 of 5,368
P-0000012msk_chord_2024172 events · 2 samplesP-0000012-T02-IM3P-0000012-T03-IM3

P-0000012 (msk_chord_2024): 172 events in 6 event types, from -26 years, 5 months, 1 day to 9 years, 3 months, 2 days relative to diagnosis (day 0). Samples, numbered in collection order: 1 P-0000012-T02-IM3 (Primary), 2 P-0000012-T03-IM3 (Metastasis). 9 years with no events are cut from the axis (dashed line); expandGaps draws every year. Events of one type nest by attribute as on cBioPortal (treatment by subtype and agent, lab tests by test); vocabularies are the study's own. Click a track with ▾ to fold its sub-tracks into one row.

The list is one row per patient of each cohort; event counts and days come from the study's timeline, in days from diagnosis as the study records them. 5,368 patients. Sorted by Events (highest first), missing values last, across the cohort; every page is a slice of that one order, and the download holds every row kept, with every column.

+ +

Patient alterations — Breast · 5,368 patients · mutations, copy-number alterations and structural variants

patient-level
patient 1 of 5,368
P-0000012msk_chord_2024P-0000012-T02-IM3P-0000012-T03-IM3Each row is one alteration in one gene across these samples: a sample's marker where it carries it, -- where it does not. A gene's rows sit together, shaded alike, in sample order.
31 Mutations
ATRXK983*Likely OncogenicNonsense_Mutation3.3%0.20
BLMA363fsLikely OncogenicFrame_Shift_Del1.4%0.13
FBXW7G61*Likely Oncogenic · Level 3ANonsense_Mutation4.5%0.23
X413_spliceLikely Oncogenic · Level 3ASplice_Site4.5%0.26
MAP3K1E302*Likely OncogenicNonsense_Mutation3.1%0.17
X696_spliceLikely OncogenicSplice_Site3.1%0.20
PTPRDE915*Likely OncogenicNonsense_Mutation4.3%0.18
P1338TUnknownMissense_Mutation4.3%0.30
PTPRTG969VUnknownMissense_Mutation4.8%0.14
X1053_spliceLikely OncogenicSplice_Site4.8%0.05
SMARCA4E1211*Likely Oncogenic · Level 3ANonsense_Mutation4.3%0.21
TP53T256PLikely OncogenicMissense_Mutation52.4%0.50
P67Qfs*56Likely OncogenicFrame_Shift_Del52.4%0.40
ATRR2431MUnknownMissense_Mutation2.7%0.17
AXIN2I375MUnknownMissense_Mutation2.1%0.14
BRIP1D393VUnknownMissense_Mutation1.4%0.15
CCNE1A47VUnknownMissense_Mutation0.5%0.15
CREBBPM2161IUnknownMissense_Mutation3.4%0.25
DOT1LW305CUnknownMissense_Mutation2.3%0.16
FLT4S293RUnknownMissense_Mutation2.8%0.18
HGFW547Cfs*35UnknownFrame_Shift_Del2.2%0.14
KDM5AN212IUnknownMissense_Mutation1.8%0.22
KMT2CK3105NUnknownMissense_Mutation6.5%0.18
NSD1V1780FUnknownMissense_Mutation2.1%0.19
PDGFRAL465MUnknownMissense_Mutation2.1%0.20
Showing 1–25 of 31 Mutations
3 Copy Number Alterations
CDK4AMPOncogenic · Level 412q14.11.6%
MCL1AMPOncogenic1q21.21.5%
RIT1AMPUnknown1q220.7%
Showing 1–3 of 3 Copy Number Alterations
0 Structural Variants
no rows
Showing 0–0 of 0 Structural Variants

P-0000012 — Mutations: 31 rows; Copy Number Alterations: 3 rows; Structural Variants: 0 rows. Each table lists a variant, a gene's call or an event once however many of the patient's samples carry it (Samples names them); allele frequency and read counts are the highest among those samples. Copy number is the deep calls only (AMP, HOMDEL), as the portal's patient view lists them; Copy # on a mutation is the gene's deep calls anywhere in the patient. Cohort % is the share of the study's samples profiled for the gene by their panel with a mutation in it, or with the same copy-number call. Annotation is OncoKB v7.5 (oncogenicity and its highest sensitivity level); structural variants carry the study's own annotation, not OncoKB's. The patient list: 5,368 patients. Sorted with patients who have any alteration first, then by patient id across the cohort; every page is a slice of that one order, and the download holds every row kept, with every column.

diff --git a/test/snapshots/two-studies-four-views.html b/test/snapshots/two-studies-four-views.html new file mode 100644 index 0000000..81062dc --- /dev/null +++ b/test/snapshots/two-studies-four-views.html @@ -0,0 +1,20 @@ + +

Compare — alteration frequencies · OncoKB v7.5 (pinned)

aggregate-only

ShowingTP53 mutTP53 wt

cross-study comparison (msk_chord_2024, msk_met_2021): denominators are per-study panel-aware; patient overlap across studies is NOT deduplicated; counts are POOLED across studies, so the p-value is unstratified — site composition differences can invert a pooled association (Simpson's paradox), and a stratified test (Cochran–Mantel–Haenszel) would be the principled statistic

gene TP53 mut
driver / any
TP53 wt
driver / any
p (any)q (any)
APC21.6% / 23.8%8.3% / 10.1%0.0e+00.0e+0
KRAS29.8% / 30%21.5% / 21.7%3.7e-1019.2e-101
CDKN2A14% / 15.6%9% / 9.7%6.3e-861.1e-85
PIK3CA11.8% / 13.1%16% / 17.1%1.7e-362.1e-36
EGFR8.4% / 10.2%5.9% / 7.3%3.5e-293.5e-29
+ +

Survival — Kaplan–Meier, overall survival

aggregate-only

ShowingTP53 mutTP53 wt

endpointmeasured fromdisplaytickscutoffmo
+ +

Clinical comparison — attribute distributions across cohorts

aggregate-only

ShowingTP53 mutTP53 wt

Categorical attributes are compared with a Pearson chi-squared over the full value vocabulary; numeric ones with Welch's unequal-variance t-test (2 cohorts) or Welch's ANOVA (3+) computed from (n, Σx, Σx²) per cohort — those totals are ADDITIVE across sites, so the pooled mean and variance are exact from per-site aggregates and no individual value is ever transmitted. Counting unit follows the attribute: patients for patient-level, samples for sample-level. q-values are Benjamini–Hochberg across the 2 tested attributes. Counts are pooled across 2 studies without harmonizing vocabularies; an attribute the studies type differently is refused rather than merged.

attribute TP53 mut TP53 wttestpq
FRACTION_GENOME_ALTERED
sample-level · numeric
0.224 ± 0.198 (n=25,537)0.147 ± 0.168 (n=24,565)Welch t<1e-300<1e-300
TMB_NONSYNONYMOUS
sample-level · numeric
8.693 ± 20.068 (n=25,622)6.555 ± 15.678 (n=24,649)Welch t1.5e-401.5e-40
CURRENT_AGE_DEID
patient-level · 0 values
not compared — not declared by msk_met_2021
+ +

Co-occurrence — pairwise, among patients profiled for both genes

aggregate-only

ShowingTP53 mutTP53 wt

Pairwise co-occurrence within “TP53 mut” (the first cohort in this view; co-occurrence describes one cohort, not a contrast). Denominators are the patients profiled for BOTH genes, computed per study against that study's own panels — a pair is never scored on patients whose panel could not have detected it. With 2 studies the reported test is STRATIFIED by study (Cochran–Mantel–Haenszel): each study contributes a 2×2 and the coordinator combines them, so a pair cannot be manufactured by differing case mix between sites. The pooled Fisher p is shown alongside. q-values are Benjamini–Hochberg across the 6 assessable pairs. Every site returns four counts per pair and nothing else.

pairbothA onlyB onlyneitherlog2 ORpqtendency
KRAS / APC2,2535,4253,83114,0460.578e-374.8e-36co-occurrence
KRAS / PIK3CA7246,9542,62315,254-0.726.3e-301.9e-29mutual exclusivity
PIK3CA / APC9292,4185,15517,0530.357e-91.4e-8co-occurrence
TP53 / KRAS7,67817,87700-1.221.001.00mutual exclusivity
TP53 / PIK3CA3,34722,20800-2.731.001.00mutual exclusivity
TP53 / APC6,08419,47100-1.681.001.00mutual exclusivity
diff --git a/test/snapshots/two-studies-plot.html b/test/snapshots/two-studies-plot.html new file mode 100644 index 0000000..7af59e7 --- /dev/null +++ b/test/snapshots/two-studies-plot.html @@ -0,0 +1,10 @@ + +

Plot — density · 968 patients

aggregate-only

ShowingLung (TCGA)

Showing 968 of 1,053 patients with data on both axes

show
+ +

Plot — box · 996 patients

aggregate-only

ShowingLung (TCGA)

Showing 996 of 1,053 patients with data on both axes

colour samples byfill
ring
show
diff --git a/test/snapshots/two-studies-survival.html b/test/snapshots/two-studies-survival.html new file mode 100644 index 0000000..b47c95e --- /dev/null +++ b/test/snapshots/two-studies-survival.html @@ -0,0 +1,5 @@ + +

Survival — Kaplan–Meier, overall survival

aggregate-only

ShowingTP53 mutTP53 wt

endpointmeasured fromdisplaytickscutoffmo
diff --git a/test/snapshots/txendpoints.html b/test/snapshots/txendpoints.html new file mode 100644 index 0000000..4b369c4 --- /dev/null +++ b/test/snapshots/txendpoints.html @@ -0,0 +1,4 @@ + +

Treatment endpoints — real-world PFS from first Treatment [SUBTYPE=Immuno], death an event

Previewaggregate-only

ShowingNSCLC, KRAS mutantNSCLC, KRAS wild-type

endpoint
clock
diff --git a/test/support/render-views.ts b/test/support/render-views.ts new file mode 100644 index 0000000..6c0d64c --- /dev/null +++ b/test/support/render-views.ts @@ -0,0 +1,42 @@ +// Renders an analysis result's view cards to HTML, as the lab page draws them (app/lab/view-cards.tsx), +// so the view snapshots pin each card's controls, subtitle and body as well as its numbers. The page +// context is a fixed stand-in: no study catalog, no edits, nothing loading, every cohort on offer. +import { register } from "node:module"; +import type { AnalysisDoc, AnalysisResult } from "../../lib/types.ts"; + +register("./tsx-loader.mjs", import.meta.url); +const { renderToStaticMarkup } = await import("react-dom/server"); +const { createElement } = await import("react"); +const { ViewCard } = await import("../../app/lab/view-cards.tsx"); + +const noop = () => {}; +const none = () => []; + +/** + * Each view's card, as HTML: the card's own markup, not what other snapshots pin. Drawn without + * the view's timings (they change on every run) and queries (.sql has them), and with each + * figure's SVG reduced to a mark (.svg has it). + */ +export function renderViewCards(doc: AnalysisDoc, resultAsRun: AnalysisResult): string { + const result = { ...resultAsRun, views: resultAsRun.views.map(({ ms: _ms, renderMs: _r, queries: _q, ...v }) => v) } as AnalysisResult; + const views = doc.views || []; + const ctx = { + doc, study: doc.study, result, viewIds: views.map((_, i) => i), docHistory: { undo: noop, canUndo: false }, + pendingKeys: new Set(), busy: false, explore: null, cat: null, catLoading: false, + trackAttrs: { key: "", attrs: [], profiles: [], gaProfiles: [] }, trackAttrsLoading: false, + contracts: {}, setContracts: noop, menuOpenRef: { current: new Map() }, VIEW_KINDS: [], + catalogAttrs: none, attrsLoading: () => false, profileOptions: none, profileCatalog: none, assayOptions: none, viewEvents: none, + drawsOwnHead: () => true, maxCohortsOf: () => undefined, + docView: (vi: number) => views[vi] || {}, ocView: (vi: number) => views[vi], + cohortChoice: (_vi: number, v: { cohorts?: string[] }) => { + const all = Object.keys(result.cohorts); + return { chosen: v.cohorts || [], all, nodes: all.map((n) => ({ name: n, label: n, kind: "cohort", depth: 0, count: result.cohorts[n].count })), + drawn: v.cohorts || [], busy: false, viewKey: 0, onChange: noop, onAdd: noop }; + }, + patchView: noop, updateView: noop, updateViewLocal: noop, setViewAttrs: noop, setViewCohorts: noop, + moveView: noop, removeView: noop, noteEdit: () => undefined, openGenes: noop, openCohortChooser: noop, + cohortFromSelection: async () => {}, featureFromSelection: async () => {}, + }; + return result.views.map((v, vi) => "\n" + + renderToStaticMarkup(createElement(ViewCard, { ctx: ctx as never, v, vi })).replace(/]*viewBox[\s\S]*?<\/svg>/g, "")).join("\n") + "\n"; +} diff --git a/test/support/tsx-loader.mjs b/test/support/tsx-loader.mjs new file mode 100644 index 0000000..78c2e87 --- /dev/null +++ b/test/support/tsx-loader.mjs @@ -0,0 +1,40 @@ +// A module loader that lets a node test import the app's React components: app/ files (.tsx, and +// .ts that Next compiles rather than node) go through esbuild, extension-less and "@/" imports +// resolve as Next resolves them, and stylesheets load as nothing. Registered by +// test/support/render-views.ts; lib/ keeps node's own type stripping. +import { readFile } from "node:fs/promises"; +import { existsSync, statSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, join } from "node:path"; +import { transform } from "esbuild"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const app = join(root, "app") + "/"; +const EXTENSIONS = [".tsx", ".ts", "/index.tsx", "/index.ts"]; + +function withExtension(path) { + if (existsSync(path) && statSync(path).isFile()) return path; + return EXTENSIONS.map((e) => path + e).find((p) => existsSync(p) && statSync(p).isFile()); +} + +export async function resolve(specifier, context, next) { + if (specifier.endsWith(".css")) return { url: "data:text/javascript,", shortCircuit: true }; + const local = specifier.startsWith("@/") ? join(root, specifier.slice(2)) + : specifier.startsWith(".") && context.parentURL?.startsWith("file:") ? join(dirname(fileURLToPath(context.parentURL)), specifier) : null; + const file = local && withExtension(local); + if (file) return { url: pathToFileURL(file).href, shortCircuit: true }; + return next(specifier, context); +} + +export async function load(url, context, next) { + if (url.startsWith("file:")) { + const path = fileURLToPath(url); + if (path.startsWith(app) && /\.tsx?$/.test(path)) { + const { code } = await transform(await readFile(path, "utf8"), { + loader: path.endsWith(".tsx") ? "tsx" : "ts", format: "esm", jsx: "automatic", sourcefile: path, target: "node22", + }); + return { format: "module", source: code, shortCircuit: true }; + } + } + return next(url, context); +} diff --git a/test/support/view-snapshots.ts b/test/support/view-snapshots.ts index 1f50ca5..92a32a5 100644 --- a/test/support/view-snapshots.ts +++ b/test/support/view-snapshots.ts @@ -1,12 +1,14 @@ // Every view kind's output, pinned: one gallery example per kind (and a few that combine studies) // runs through the whole engine (cohort compiler, runner, statistics, figure, results text) with -// the warehouse replaced by answers recorded from it. Four files per case in test/snapshots/ are +// the warehouse replaced by answers recorded from it. Five files per case in test/snapshots/ are // compared byte for byte: // .sql each cohort's compiled SQL and each view's queries, by label and study // .json what each view computed (its result, without timings or the queries above; a long // array, or an object of more than 50 keys, as its first 20 items, then the rest's // count and digest) // .svg each view's figure; .txt the results text MCP clients and the AI tools read +// .html each view's card as the lab page draws it: header, controls, body +// (test/support/render-views.ts) // A change to shared code that moves another view's numbers, figure, text or queries fails here, // named by the case. A changed query has no recorded answer, so it fails as "not recorded". // Each case runs twice, its answers delivered in the order its queries were sent and then in the @@ -47,6 +49,7 @@ const { runAnalysis } = await import("../../lib/analysis.ts"); const { withRequestCustomData } = await import("../../lib/custom-data-server.ts"); const { viewToSvg } = await import("../../lib/view-svg.ts"); const { analysisText } = await import("../../lib/result-text.ts"); +const { renderViewCards } = await import("./render-views.ts"); import type { AnalysisDoc, AnalysisResult } from "../../lib/types.ts"; /** @@ -167,6 +170,7 @@ function render(doc: AnalysisDoc, r: AnalysisResult): Record { json: json({ study: r.study, cohorts, splits: r.splits, views }), svg, txt: analysisText(doc, r).join("\n\n").replace(/ — \d+ms\./, " — (time)ms.") + "\n", + html: renderViewCards(doc, r), }; } diff --git a/test/view-kinds.test.ts b/test/view-kinds.test.ts new file mode 100644 index 0000000..e16610b --- /dev/null +++ b/test/view-kinds.test.ts @@ -0,0 +1,101 @@ +// What every view kind tells the page and the reader before any data arrives — its entry in the add +// menu, the spec a fresh view starts from, its card's title, limits and subtitle, its help, its +// federation needs and marks — pinned in test/snapshots/kinds.json, for a study that has everything +// a kind might look for and for one that has nothing. Read through the same functions the page calls +// (app/lab/use-view-options.ts, use-view-edits.ts, kind-cards.tsx), with a fixed stand-in page. +// UPDATE_SNAPSHOTS=1 rewrites the file after an intended change. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { register } from "node:module"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +register("./support/tsx-loader.mjs", import.meta.url); +const { useViewOptions } = await import("../app/lab/use-view-options.ts"); +const { useViewEdits } = await import("../app/lab/use-view-edits.ts"); +const kindCards = await import("../app/lab/kind-cards.tsx"); +const { viewHeadTitle } = await import("../app/lab/view-cards.tsx"); +const { VIEW_HELP } = await import("../lib/view-help.ts"); +const { viewNeed } = await import("../lib/view-coverage.ts"); +const { previewPending } = await import("../lib/preview-kinds.ts"); +const { isNewKind } = await import("../lib/new-in-lab.ts"); +const { viewTitle } = await import("../lib/view-svg.ts"); + +const KINDS = ["compare", "survival", "cox", "logistic", "oncoprint", "hotspots", "plot", "cooccurrence", "clinical", "coexpression", + "course", "sequence", "attrition", "genome", "pathways", "diffexpr", "paired", "clonality", "incidence", "labs", "riskmodel", + "cancertypes", "overlap", "enrichment", "assays", "composition", "correlates", "table", "mutations", "alterations", "timeline", + "acquired", "ctdna", "exclusivity", "txendpoints", "multistate", "rmst", "predictive", "cutpoint", "hla", "forest", "adjusted", + "instability", "actionability", "drivernegative", "organotropism", "molsubtype", "germline"]; + +const RICH = { + genes: ["TP53", "KRAS", "EGFR", "STK11", "KEAP1", "PIK3CA", "APC", "BRAF", "ERBB2"], + attrs: [{ attribute: "TMB_NONSYNONYMOUS", kind: "num" }, { attribute: "AGE", kind: "num" }, { attribute: "OS_MONTHS", kind: "num" }, + { attribute: "SEX", kind: "cat" }, { attribute: "CANCER_TYPE", kind: "cat" }, { attribute: "HLA_A", kind: "cat" }, { attribute: "DMETS_DX_BRAIN", kind: "cat" }], + profiles: ["mutations", "cna", "rna_seq_v2_mrna", "rna_seq_v2_mrna_median_Zscores"], + assays: ["mutational_signatures_contribution_v2"], + events: [{ canonical: "treatment", label: "Treatment", n: 900, perPatient: 3.2, hasDurations: true }, + { canonical: "diagnosis", label: "Diagnosis", n: 1000, perPatient: 2.1, hasDurations: false }, + { canonical: "lab_test", label: "Lab_Test", n: 400, perPatient: 9, hasDurations: false }, + { canonical: "sequencing", label: "Sequencing", n: 1000, perPatient: 1, hasDurations: false }], +}; +const EMPTY = { genes: [], attrs: [], profiles: [], assays: [], events: [] }; + +const PATIENT_LEVEL = { instance: "primary", patientLevel: true, remote: false, shares: [] }; +const AGGREGATE_ONLY = { instance: "primary", patientLevel: false, remote: false, shares: [] }; + +function page(study: typeof RICH, cohorts: string[], access: typeof PATIENT_LEVEL = PATIENT_LEVEL) { + const doc = { study: "s1", cohorts: Object.fromEntries(cohorts.map((n) => [n, { all: [] }])), views: [] }; + const opts = useViewOptions({ + cat: study.events.length ? { endpoints: [{ months: "OS_MONTHS", status: "OS_STATUS" }], types: study.events } : null, catLoading: false, + doc: doc as never, explore: null, exploreBusy: false, result: null, + trackAttrs: { key: "s1", attrs: study.attrs.map((a) => ({ ...a, patients: 10 })), profiles: study.profiles, gaProfiles: study.assays } as never, + trackAttrsLoading: false, studies: [{ id: "s1", access }] as never, + }); + let added: unknown = null; + const edits = useViewEdits({ + assayOptions: () => study.assays, busy: false, cantDrawCohort: () => null, catalogAttrs: () => study.attrs, + contProfile: () => study.profiles.find((p) => /mrna/.test(p)) ?? null, doc: doc as never, + docs: { current: { doc, ids: [] }, set: (d: { views: unknown[] }) => { added = d.views[0]; } } as never, + numericAttrs: () => study.attrs.filter((a) => a.kind === "num").map((a) => a.attribute), profileOptions: () => study.profiles, + result: null, viewAttrs: () => study.attrs.map((a) => a.attribute).slice(0, 3), viewEvents: () => study.events, viewGenes: () => study.genes, + viewIds: [], VIEW_KINDS: opts.VIEW_KINDS, setChooser: () => {}, setToast: () => {}, study: "s1", + }); + const addView = (kind: string) => { added = null; edits.addView(kind); return added; }; + return { opts, edits, addView }; +} + +function facts() { + const rich = page(RICH, ["A"]), richTwo = page(RICH, ["A", "B"]), empty = page(EMPTY, ["A"]), aggregate = page(RICH, ["A"], AGGREGATE_ONLY); + const known = (s: typeof RICH) => ({ ...s, cohorts: [] }); + const out: Record = {}; + for (const kind of KINDS) { + const menu = rich.opts.VIEW_KINDS.find((k) => k.kind === kind); + const emptyMenu = empty.opts.VIEW_KINDS.find((k) => k.kind === kind); + const aggregateMenu = aggregate.opts.VIEW_KINDS.find((k) => k.kind === kind); + const card = kindCards.hasKindCard(kind) ? kindCards.KIND_CARDS[kind] : null; + const spec = rich.addView(kind) as Record | null; + out[kind] = { + menu: menu ? { label: menu.label, why: menu.why?.() ?? null, loading: menu.loading?.() ?? false } : null, + menuBareStudy: emptyMenu ? { why: emptyMenu.why?.() ?? null, loading: emptyMenu.loading?.() ?? false } : null, + menuAggregateOnly: aggregateMenu ? { why: aggregateMenu.why?.() ?? null } : null, + added: spec, addedBareStudy: empty.addView(kind), addedWithTwoCohorts: richTwo.addView(kind), + maxCohorts: rich.edits.maxCohortsOf(kind) ?? null, emptyHint: rich.edits.emptyViewHint(kind), title: rich.edits.viewTitle(kind), + headTitle: viewHeadTitle(kind), + drawsOwnHead: { error: rich.opts.drawsOwnHead({ kind, error: "x" }), withData: rich.opts.drawsOwnHead({ kind, rows: [], hotspots: [], coexpression: [], clinical: [], pairs: [], curves: [], svg: "x", spec: {} }) }, + card: card ? { ...card, why: card.why?.(known(RICH)) ?? null, whyBareStudy: card.why?.(known(EMPTY)) ?? null, + defaults: kindCards.defaultKindView(kind, known(RICH)), defaultsBareStudy: kindCards.defaultKindView(kind, known(EMPTY)) } : null, + drawsOwnWaiting: kindCards.drawsOwnWaiting(kind), + sub: spec && card ? (() => { const s = kindCards.kindSub(kind, spec, {}); return typeof s === "string" || s == null ? s : "(element)"; })() : null, + help: VIEW_HELP[kind] ?? null, need: viewNeed(kind), preview: previewPending(kind), isNew: isNewKind(kind), + figureTitle: viewTitle({ kind, cohorts: ["A", "B"] } as never), + }; + } + return out; +} + +test("what every view kind tells the page before data arrives", () => { + const file = join(import.meta.dirname, "snapshots", "kinds.json"); + const text = JSON.stringify(facts(), null, 2) + "\n"; + if (process.env.UPDATE_SNAPSHOTS || !existsSync(file)) writeFileSync(file, text); + assert.equal(text, readFileSync(file, "utf8"), "kinds.json changed — review the diff; if intended, UPDATE_SNAPSHOTS=1 npm test"); +}); From ea2e741bd8c3eeca26c9a245f8ee45d3086889f6 Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Fri, 23 Oct 2026 21:42:13 +0000 Subject: [PATCH 2/7] Each view kind's code moves into its own folder, lib/views// MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every view kind's runner, figure, types, SQL and method record lived in shared files (types.ts, federation-views.ts, method.ts, figures/) beside every other kind's, so each new view edited the same hubs. Each kind now has a folder: run.ts its runner (from lib/views/.ts; riskmodel's table1.ts and validate.ts too) figure.ts its figure (from lib/figures/.ts) types.ts its spec and result types, and its entries in lib/types.ts's maps sql.ts the SQL only it uses (from lib/federation-views.ts) method.ts its method record (from lib/method.ts) A declaration moved only if nothing outside the kind's folder (tests aside) used it, together with the helpers only it used; what two kinds share stays in the shared file (the lab-test, genome coverage and panel-gene queries, the assay profile and entity queries, CoxTrace, …). lib/types.ts no longer names a kind. `View` is ViewSpecs[keyof ViewSpecs], and ViewSpecs, ViewResultFields and ViewResultMain start empty: each kind's types.ts adds its own entries with `declare module "../../types.ts"`, so the type guarantees of #55/#57 hold unchanged. The boundary suite now imports every lib/views//sql.ts it finds and sweeps those builders with lib/federation.ts's, so a kind's own SQL is held to "aggregates only" without being registered. orgSiteMapSql is renamed orgSiteMapExpr: it builds an expression, not a query. Tests that found the kinds by parsing `export type View =` read each kind folder's ViewSpecs entry instead (test/support/view-kinds.ts). Moved mechanically (TypeScript's language service for file renames, and a checker-driven mover for declarations: each name a moved declaration uses imported exactly, every importer rewritten). The only output change: method records cite where the code now is ("file": "lib/views/attrition/run.ts" for "lib/views/attrition.ts"), 9 lines in 8 snapshots. Verified: every view snapshot (SQL, result, figure, text, card HTML) and test/snapshots/kinds.json otherwise byte-identical; npm test 1611/1611; tsc, eslint, knip clean; every lib module loads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CuBjiBEjYispDSEYDZDWM1 --- CLAUDE.md | 8 +- app/_lollipop/HotspotsBody.tsx | 3 +- app/_oncoprint/OncoprintFigure.tsx | 3 +- app/api/oncoprint/detail/route.ts | 3 +- app/api/oncoprint/route.ts | 3 +- app/lab/alterations-body.tsx | 3 +- app/lab/kind-cards.tsx | 4 +- app/lab/page.tsx | 2 +- app/lab/portal-bodies.tsx | 6 +- app/lab/risk-groups-dialog.tsx | 5 +- app/lab/risk-table1-dialog.tsx | 7 +- app/lab/use-view-edits.ts | 5 +- app/lab/view-cards.tsx | 3 +- docs/plot-case-level.md | 4 +- lib/api-schemas.ts | 3 +- lib/federation-views.ts | 2007 +-------------- lib/federation.ts | 6 +- lib/figures/bars.ts | 6 +- lib/figures/forest.ts | 3 +- lib/figures/index.ts | 62 +- lib/hypothesis.ts | 3 +- lib/lollipop-svg.ts | 3 +- lib/lollipop.ts | 2 +- lib/method.ts | 2162 +--------------- lib/next-cache.ts | 5 +- lib/oncoprint-assemble.ts | 4 +- lib/oncoprint-detail.ts | 2 +- lib/oncoprint.ts | 3 +- lib/result-text.ts | 12 +- lib/types.ts | 2237 +---------------- lib/view-svg.ts | 11 +- .../acquired.ts => views/acquired/figure.ts} | 5 +- lib/views/{acquired.ts => acquired/run.ts} | 14 +- lib/views/acquired/sql.ts | 89 + lib/views/acquired/types.ts | 64 + .../actionability/figure.ts} | 5 +- .../run.ts} | 9 +- lib/views/actionability/sql.ts | 107 + lib/views/actionability/types.ts | 49 + .../adjusted.ts => views/adjusted/figure.ts} | 6 +- lib/views/{adjusted.ts => adjusted/run.ts} | 18 +- lib/views/adjusted/sql.ts | 51 + lib/views/adjusted/types.ts | 56 + lib/views/alterations/method.ts | 24 + .../{alterations.ts => alterations/run.ts} | 23 +- lib/views/alterations/types.ts | 48 + .../assays.ts => views/assays/figure.ts} | 8 +- lib/views/assays/method.ts | 98 + lib/views/{assays.ts => assays/run.ts} | 14 +- lib/views/assays/sql.ts | 34 + lib/views/assays/types.ts | 59 + .../attrition/figure.ts} | 5 +- lib/views/attrition/method.ts | 16 + lib/views/{attrition.ts => attrition/run.ts} | 11 +- lib/views/attrition/types.ts | 34 + .../cancertypes/figure.ts} | 7 +- lib/views/cancertypes/method.ts | 32 + .../{cancertypes.ts => cancertypes/run.ts} | 13 +- lib/views/cancertypes/sql.ts | 109 + lib/views/cancertypes/types.ts | 58 + lib/views/clinical/method.ts | 158 ++ lib/views/{clinical.ts => clinical/run.ts} | 15 +- lib/views/clinical/types.ts | 53 + .../clonality/figure.ts} | 5 +- lib/views/clonality/method.ts | 75 + lib/views/{clonality.ts => clonality/run.ts} | 18 +- lib/views/clonality/sql.ts | 107 + lib/views/clonality/types.ts | 54 + lib/views/coexpression/method.ts | 94 + .../{coexpression.ts => coexpression/run.ts} | 15 +- lib/views/coexpression/types.ts | 29 + lib/views/compare/method.ts | 107 + lib/views/{compare.ts => compare/run.ts} | 17 +- lib/views/compare/types.ts | 37 + .../composition/figure.ts} | 6 +- lib/views/composition/method.ts | 90 + .../{composition.ts => composition/run.ts} | 18 +- lib/views/composition/sql.ts | 29 + lib/views/composition/types.ts | 72 + lib/views/cooccurrence/method.ts | 99 + .../{cooccurrence.ts => cooccurrence/run.ts} | 15 +- lib/views/cooccurrence/types.ts | 29 + .../correlates/figure.ts} | 4 +- lib/views/correlates/method.ts | 91 + .../{correlates.ts => correlates/run.ts} | 15 +- lib/views/correlates/sql.ts | 41 + lib/views/correlates/types.ts | 52 + .../course.ts => views/course/figure.ts} | 11 +- lib/views/course/method.ts | 71 + lib/views/{course.ts => course/run.ts} | Bin 11118 -> 11165 bytes lib/views/course/types.ts | 65 + lib/views/cox/method.ts | 107 + lib/views/{cox.ts => cox/run.ts} | 22 +- lib/views/cox/types.ts | 75 + .../ctdna.ts => views/ctdna/figure.ts} | 7 +- lib/views/{ctdna.ts => ctdna/run.ts} | 15 +- lib/views/ctdna/sql.ts | 189 ++ lib/views/ctdna/types.ts | 89 + .../cutpoint.ts => views/cutpoint/figure.ts} | 6 +- lib/views/{cutpoint.ts => cutpoint/run.ts} | 13 +- lib/views/cutpoint/sql.ts | 44 + lib/views/cutpoint/types.ts | 55 + .../diffexpr.ts => views/diffexpr/figure.ts} | 8 +- lib/views/diffexpr/method.ts | 105 + lib/views/{diffexpr.ts => diffexpr/run.ts} | 13 +- lib/views/diffexpr/sql.ts | 43 + lib/views/diffexpr/types.ts | 51 + .../drivernegative/figure.ts} | 6 +- .../run.ts} | 12 +- lib/views/drivernegative/sql.ts | 75 + lib/views/drivernegative/types.ts | 49 + .../enrichment/figure.ts} | 6 +- lib/views/enrichment/method.ts | 62 + .../{enrichment.ts => enrichment/run.ts} | 14 +- lib/views/enrichment/sql.ts | 53 + lib/views/enrichment/types.ts | 52 + .../exclusivity/figure.ts} | 5 +- .../{exclusivity.ts => exclusivity/run.ts} | 13 +- lib/views/exclusivity/sql.ts | 81 + lib/views/exclusivity/types.ts | 51 + lib/views/{forest.ts => forest/run.ts} | 17 +- lib/views/forest/types.ts | 64 + .../genome.ts => views/genome/figure.ts} | 6 +- lib/views/genome/method.ts | 64 + lib/views/{genome.ts => genome/run.ts} | 14 +- lib/views/genome/sql.ts | 51 + lib/views/genome/types.ts | 45 + .../germline.ts => views/germline/figure.ts} | 7 +- lib/views/{germline.ts => germline/run.ts} | 14 +- lib/views/germline/sql.ts | 88 + lib/views/germline/types.ts | 47 + lib/{figures/hla.ts => views/hla/figure.ts} | 7 +- lib/views/{hla.ts => hla/run.ts} | 14 +- lib/views/hla/sql.ts | 98 + lib/views/hla/types.ts | 63 + lib/views/{hotspots.ts => hotspots/run.ts} | 11 +- lib/views/hotspots/types.ts | 65 + .../incidence/figure.ts} | 18 +- lib/views/incidence/method.ts | 124 + lib/views/{incidence.ts => incidence/run.ts} | 14 +- lib/views/incidence/types.ts | 36 + lib/views/index.ts | 96 +- .../instability/figure.ts} | 7 +- .../{instability.ts => instability/run.ts} | 12 +- lib/views/instability/sql.ts | 92 + lib/views/instability/types.ts | 47 + lib/{figures/labs.ts => views/labs/figure.ts} | 10 +- lib/views/labs/method.ts | 82 + lib/views/{labs.ts => labs/run.ts} | 19 +- lib/views/labs/sql.ts | 120 + lib/views/labs/types.ts | 53 + lib/views/logistic/method.ts | 71 + lib/views/{logistic.ts => logistic/run.ts} | 20 +- lib/views/logistic/types.ts | 61 + .../molsubtype/figure.ts} | 7 +- .../{molsubtype.ts => molsubtype/run.ts} | 14 +- lib/views/molsubtype/sql.ts | 128 + lib/views/molsubtype/types.ts | 57 + .../multistate/figure.ts} | 11 +- .../{multistate.ts => multistate/run.ts} | 14 +- lib/views/multistate/sql.ts | 60 + lib/views/multistate/types.ts | 72 + lib/views/mutations/method.ts | 13 + lib/views/{mutations.ts => mutations/run.ts} | 17 +- lib/views/mutations/types.ts | 31 + lib/views/{oncoprint.ts => oncoprint/run.ts} | 17 +- lib/views/oncoprint/types.ts | 71 + .../organotropism/figure.ts} | 9 +- .../run.ts} | 16 +- lib/views/organotropism/sql.ts | 236 ++ lib/views/organotropism/types.ts | 78 + .../overlap.ts => views/overlap/figure.ts} | 5 +- lib/views/overlap/method.ts | 15 + lib/views/{overlap.ts => overlap/run.ts} | 11 +- lib/views/overlap/sql.ts | 21 + lib/views/overlap/types.ts | 30 + .../paired.ts => views/paired/figure.ts} | 4 +- lib/views/paired/method.ts | 71 + lib/views/{paired.ts => paired/run.ts} | 17 +- lib/views/paired/sql.ts | 55 + lib/views/paired/types.ts | 70 + .../pathways.ts => views/pathways/figure.ts} | 7 +- lib/views/pathways/method.ts | 65 + lib/views/{pathways.ts => pathways/run.ts} | 15 +- lib/views/pathways/sql.ts | 56 + lib/views/pathways/types.ts | 49 + lib/{figures/plot.ts => views/plot/figure.ts} | 10 +- lib/views/{plot.ts => plot/run.ts} | 15 +- lib/views/plot/types.ts | 49 + .../predictive/figure.ts} | 6 +- .../{predictive.ts => predictive/run.ts} | 17 +- lib/views/predictive/types.ts | 51 + .../riskmodel/figure.ts} | 23 +- lib/views/riskmodel/method.ts | 170 ++ lib/views/{riskmodel.ts => riskmodel/run.ts} | 27 +- .../table1.ts} | 6 +- lib/views/riskmodel/types.ts | 177 ++ .../validate.ts} | 9 +- lib/{figures/rmst.ts => views/rmst/figure.ts} | 11 +- lib/views/{rmst.ts => rmst/run.ts} | 19 +- lib/views/rmst/types.ts | 54 + lib/views/run.ts | 2 +- .../sequence.ts => views/sequence/figure.ts} | 5 +- lib/views/sequence/method.ts | 98 + lib/views/{sequence.ts => sequence/run.ts} | 17 +- lib/views/sequence/types.ts | 70 + .../survival.ts => views/survival/figure.ts} | 11 +- lib/views/survival/method.ts | 136 + lib/views/{survival.ts => survival/run.ts} | 13 +- lib/views/survival/types.ts | 57 + lib/views/table/method.ts | 14 + lib/views/{table.ts => table/run.ts} | 15 +- lib/views/table/types.ts | 33 + lib/views/timeline/method.ts | 19 + lib/views/{timeline.ts => timeline/run.ts} | 21 +- lib/views/timeline/types.ts | 32 + .../txendpoints/figure.ts} | 7 +- .../{txendpoints.ts => txendpoints/run.ts} | 15 +- lib/views/txendpoints/types.ts | 51 + mcp/tools.ts | 5 +- scripts/check-mutants.ts | 4 +- scripts/check-pooled.ts | 3 +- test/actionability.test.ts | 2 +- test/adjusted.test.ts | 2 +- test/boundary.test.ts | 182 +- test/compare-alteration-types.test.ts | 2 +- test/composition.test.ts | 2 +- test/ctdna.test.ts | 2 +- test/cutpoint.test.ts | 2 +- test/drivernegative.test.ts | 2 +- test/exclusivity.test.ts | 2 +- test/figures-bars.test.ts | 5 +- test/figures-flows.test.ts | 8 +- test/figures-portal.test.ts | 5 +- test/figures-timecourse.test.ts | 8 +- test/figures-volcano.test.ts | 2 +- test/figures.test.ts | 5 +- test/forest.test.ts | 2 +- test/germline.test.ts | 2 +- test/gsea-golden.test.ts | 2 +- test/hla.test.ts | 2 +- test/incidence-golden.test.ts | 3 +- test/instability.test.ts | 3 +- test/mcp-views.test.ts | 10 +- test/method.test.ts | 21 +- test/molsubtype.test.ts | 2 +- test/organotropism.test.ts | 2 +- test/plot-layers.test.ts | 2 +- test/plot-points.test.ts | 2 +- test/predictive.test.ts | 2 +- test/riskmodel-groups.test.ts | 9 +- test/riskmodel-table1.test.ts | 8 +- test/riskmodel-validate.test.ts | 4 +- test/rmst.test.ts | 2 +- test/snapshots/alterations.json | 2 +- test/snapshots/attrition.json | 2 +- test/snapshots/cancertypes.json | 2 +- test/snapshots/course.json | 2 +- test/snapshots/overlap.json | 2 +- test/snapshots/riskmodel.json | 2 +- test/snapshots/sequence.json | 2 +- test/snapshots/timeline.json | 4 +- test/support/view-kinds.ts | 15 + test/support/view-snapshots.ts | 7 +- test/view-help.test.ts | 10 +- test/view-svg.test.ts | 2 +- 266 files changed, 7887 insertions(+), 7114 deletions(-) rename lib/{figures/acquired.ts => views/acquired/figure.ts} (97%) rename lib/views/{acquired.ts => acquired/run.ts} (95%) create mode 100644 lib/views/acquired/sql.ts create mode 100644 lib/views/acquired/types.ts rename lib/{figures/actionability.ts => views/actionability/figure.ts} (97%) rename lib/views/{actionability.ts => actionability/run.ts} (97%) create mode 100644 lib/views/actionability/sql.ts create mode 100644 lib/views/actionability/types.ts rename lib/{figures/adjusted.ts => views/adjusted/figure.ts} (96%) rename lib/views/{adjusted.ts => adjusted/run.ts} (95%) create mode 100644 lib/views/adjusted/sql.ts create mode 100644 lib/views/adjusted/types.ts create mode 100644 lib/views/alterations/method.ts rename lib/views/{alterations.ts => alterations/run.ts} (95%) create mode 100644 lib/views/alterations/types.ts rename lib/{figures/assays.ts => views/assays/figure.ts} (98%) create mode 100644 lib/views/assays/method.ts rename lib/views/{assays.ts => assays/run.ts} (96%) create mode 100644 lib/views/assays/sql.ts create mode 100644 lib/views/assays/types.ts rename lib/{figures/attrition.ts => views/attrition/figure.ts} (97%) create mode 100644 lib/views/attrition/method.ts rename lib/views/{attrition.ts => attrition/run.ts} (91%) create mode 100644 lib/views/attrition/types.ts rename lib/{figures/cancertypes.ts => views/cancertypes/figure.ts} (97%) create mode 100644 lib/views/cancertypes/method.ts rename lib/views/{cancertypes.ts => cancertypes/run.ts} (96%) create mode 100644 lib/views/cancertypes/sql.ts create mode 100644 lib/views/cancertypes/types.ts create mode 100644 lib/views/clinical/method.ts rename lib/views/{clinical.ts => clinical/run.ts} (96%) create mode 100644 lib/views/clinical/types.ts rename lib/{figures/clonality.ts => views/clonality/figure.ts} (98%) create mode 100644 lib/views/clonality/method.ts rename lib/views/{clonality.ts => clonality/run.ts} (94%) create mode 100644 lib/views/clonality/sql.ts create mode 100644 lib/views/clonality/types.ts create mode 100644 lib/views/coexpression/method.ts rename lib/views/{coexpression.ts => coexpression/run.ts} (92%) create mode 100644 lib/views/coexpression/types.ts create mode 100644 lib/views/compare/method.ts rename lib/views/{compare.ts => compare/run.ts} (95%) create mode 100644 lib/views/compare/types.ts rename lib/{figures/composition.ts => views/composition/figure.ts} (98%) create mode 100644 lib/views/composition/method.ts rename lib/views/{composition.ts => composition/run.ts} (95%) create mode 100644 lib/views/composition/sql.ts create mode 100644 lib/views/composition/types.ts create mode 100644 lib/views/cooccurrence/method.ts rename lib/views/{cooccurrence.ts => cooccurrence/run.ts} (93%) create mode 100644 lib/views/cooccurrence/types.ts rename lib/{figures/correlates.ts => views/correlates/figure.ts} (98%) create mode 100644 lib/views/correlates/method.ts rename lib/views/{correlates.ts => correlates/run.ts} (94%) create mode 100644 lib/views/correlates/sql.ts create mode 100644 lib/views/correlates/types.ts rename lib/{figures/course.ts => views/course/figure.ts} (95%) create mode 100644 lib/views/course/method.ts rename lib/views/{course.ts => course/run.ts} (96%) create mode 100644 lib/views/course/types.ts create mode 100644 lib/views/cox/method.ts rename lib/views/{cox.ts => cox/run.ts} (95%) create mode 100644 lib/views/cox/types.ts rename lib/{figures/ctdna.ts => views/ctdna/figure.ts} (97%) rename lib/views/{ctdna.ts => ctdna/run.ts} (97%) create mode 100644 lib/views/ctdna/sql.ts create mode 100644 lib/views/ctdna/types.ts rename lib/{figures/cutpoint.ts => views/cutpoint/figure.ts} (97%) rename lib/views/{cutpoint.ts => cutpoint/run.ts} (97%) create mode 100644 lib/views/cutpoint/sql.ts create mode 100644 lib/views/cutpoint/types.ts rename lib/{figures/diffexpr.ts => views/diffexpr/figure.ts} (97%) create mode 100644 lib/views/diffexpr/method.ts rename lib/views/{diffexpr.ts => diffexpr/run.ts} (97%) create mode 100644 lib/views/diffexpr/sql.ts create mode 100644 lib/views/diffexpr/types.ts rename lib/{figures/drivernegative.ts => views/drivernegative/figure.ts} (96%) rename lib/views/{drivernegative.ts => drivernegative/run.ts} (95%) create mode 100644 lib/views/drivernegative/sql.ts create mode 100644 lib/views/drivernegative/types.ts rename lib/{figures/enrichment.ts => views/enrichment/figure.ts} (98%) create mode 100644 lib/views/enrichment/method.ts rename lib/views/{enrichment.ts => enrichment/run.ts} (95%) create mode 100644 lib/views/enrichment/sql.ts create mode 100644 lib/views/enrichment/types.ts rename lib/{figures/exclusivity.ts => views/exclusivity/figure.ts} (97%) rename lib/views/{exclusivity.ts => exclusivity/run.ts} (97%) create mode 100644 lib/views/exclusivity/sql.ts create mode 100644 lib/views/exclusivity/types.ts rename lib/views/{forest.ts => forest/run.ts} (97%) create mode 100644 lib/views/forest/types.ts rename lib/{figures/genome.ts => views/genome/figure.ts} (97%) create mode 100644 lib/views/genome/method.ts rename lib/views/{genome.ts => genome/run.ts} (94%) create mode 100644 lib/views/genome/sql.ts create mode 100644 lib/views/genome/types.ts rename lib/{figures/germline.ts => views/germline/figure.ts} (95%) rename lib/views/{germline.ts => germline/run.ts} (95%) create mode 100644 lib/views/germline/sql.ts create mode 100644 lib/views/germline/types.ts rename lib/{figures/hla.ts => views/hla/figure.ts} (97%) rename lib/views/{hla.ts => hla/run.ts} (97%) create mode 100644 lib/views/hla/sql.ts create mode 100644 lib/views/hla/types.ts rename lib/views/{hotspots.ts => hotspots/run.ts} (94%) create mode 100644 lib/views/hotspots/types.ts rename lib/{figures/incidence.ts => views/incidence/figure.ts} (95%) create mode 100644 lib/views/incidence/method.ts rename lib/views/{incidence.ts => incidence/run.ts} (95%) create mode 100644 lib/views/incidence/types.ts rename lib/{figures/instability.ts => views/instability/figure.ts} (97%) rename lib/views/{instability.ts => instability/run.ts} (94%) create mode 100644 lib/views/instability/sql.ts create mode 100644 lib/views/instability/types.ts rename lib/{figures/labs.ts => views/labs/figure.ts} (97%) create mode 100644 lib/views/labs/method.ts rename lib/views/{labs.ts => labs/run.ts} (94%) create mode 100644 lib/views/labs/sql.ts create mode 100644 lib/views/labs/types.ts create mode 100644 lib/views/logistic/method.ts rename lib/views/{logistic.ts => logistic/run.ts} (94%) create mode 100644 lib/views/logistic/types.ts rename lib/{figures/molsubtype.ts => views/molsubtype/figure.ts} (96%) rename lib/views/{molsubtype.ts => molsubtype/run.ts} (97%) create mode 100644 lib/views/molsubtype/sql.ts create mode 100644 lib/views/molsubtype/types.ts rename lib/{figures/multistate.ts => views/multistate/figure.ts} (97%) rename lib/views/{multistate.ts => multistate/run.ts} (96%) create mode 100644 lib/views/multistate/sql.ts create mode 100644 lib/views/multistate/types.ts create mode 100644 lib/views/mutations/method.ts rename lib/views/{mutations.ts => mutations/run.ts} (90%) create mode 100644 lib/views/mutations/types.ts rename lib/views/{oncoprint.ts => oncoprint/run.ts} (93%) create mode 100644 lib/views/oncoprint/types.ts rename lib/{figures/organotropism.ts => views/organotropism/figure.ts} (97%) rename lib/views/{organotropism.ts => organotropism/run.ts} (97%) create mode 100644 lib/views/organotropism/sql.ts create mode 100644 lib/views/organotropism/types.ts rename lib/{figures/overlap.ts => views/overlap/figure.ts} (99%) create mode 100644 lib/views/overlap/method.ts rename lib/views/{overlap.ts => overlap/run.ts} (92%) create mode 100644 lib/views/overlap/sql.ts create mode 100644 lib/views/overlap/types.ts rename lib/{figures/paired.ts => views/paired/figure.ts} (98%) create mode 100644 lib/views/paired/method.ts rename lib/views/{paired.ts => paired/run.ts} (95%) create mode 100644 lib/views/paired/sql.ts create mode 100644 lib/views/paired/types.ts rename lib/{figures/pathways.ts => views/pathways/figure.ts} (97%) create mode 100644 lib/views/pathways/method.ts rename lib/views/{pathways.ts => pathways/run.ts} (96%) create mode 100644 lib/views/pathways/sql.ts create mode 100644 lib/views/pathways/types.ts rename lib/{figures/plot.ts => views/plot/figure.ts} (99%) rename lib/views/{plot.ts => plot/run.ts} (99%) create mode 100644 lib/views/plot/types.ts rename lib/{figures/predictive.ts => views/predictive/figure.ts} (90%) rename lib/views/{predictive.ts => predictive/run.ts} (96%) create mode 100644 lib/views/predictive/types.ts rename lib/{figures/riskmodel.ts => views/riskmodel/figure.ts} (97%) create mode 100644 lib/views/riskmodel/method.ts rename lib/views/{riskmodel.ts => riskmodel/run.ts} (98%) rename lib/views/{riskmodel-table1.ts => riskmodel/table1.ts} (98%) create mode 100644 lib/views/riskmodel/types.ts rename lib/views/{riskmodel-validate.ts => riskmodel/validate.ts} (97%) rename lib/{figures/rmst.ts => views/rmst/figure.ts} (93%) rename lib/views/{rmst.ts => rmst/run.ts} (95%) create mode 100644 lib/views/rmst/types.ts rename lib/{figures/sequence.ts => views/sequence/figure.ts} (98%) create mode 100644 lib/views/sequence/method.ts rename lib/views/{sequence.ts => sequence/run.ts} (95%) create mode 100644 lib/views/sequence/types.ts rename lib/{figures/survival.ts => views/survival/figure.ts} (96%) create mode 100644 lib/views/survival/method.ts rename lib/views/{survival.ts => survival/run.ts} (98%) create mode 100644 lib/views/survival/types.ts create mode 100644 lib/views/table/method.ts rename lib/views/{table.ts => table/run.ts} (93%) create mode 100644 lib/views/table/types.ts create mode 100644 lib/views/timeline/method.ts rename lib/views/{timeline.ts => timeline/run.ts} (93%) create mode 100644 lib/views/timeline/types.ts rename lib/{figures/txendpoints.ts => views/txendpoints/figure.ts} (91%) rename lib/views/{txendpoints.ts => txendpoints/run.ts} (96%) create mode 100644 lib/views/txendpoints/types.ts create mode 100644 test/support/view-kinds.ts diff --git a/CLAUDE.md b/CLAUDE.md index ef120fa..5995ab3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a `POST /api/view-page` (a page, or a column's values) and `POST /api/table` (the CSV) serve every table view. A view holding several tables (`alterations`) names one with `table` in those requests (`RunOpts.table`), and `pagedTable()` in `lib/views/rows.ts` picks it from the result. -- **The `alterations` view** (cBioPortal's patient-view genomic tables, runner `lib/views/alterations.ts`) — one +- **The `alterations` view** (cBioPortal's patient-view genomic tables, runner `lib/views/alterations/run.ts`) — one patient's Mutations, Copy Number Alterations and Structural Variants as three table views in one card, each with its own query under its own spec key (`mutations`, `cna`, `sv`); the top-level query is the patient list's. Row sources in **`lib/patient-alterations.ts`** (one row per variant / call / event however many of the @@ -97,7 +97,7 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a Its navigator is the timeline's: **`app/lab/patient-nav.tsx`** (‹ prev / next ›, the List/Search dialog, ← / →) serves every view that shows one patient of a cohort at a time, stepping by the result's `position` (`listPosition()` in `lib/views/rows.ts`). -- **The `timeline` view** (cBioPortal's patient-view Timeline, runner `lib/views/timeline.ts`) — one +- **The `timeline` view** (cBioPortal's patient-view Timeline, runner `lib/views/timeline/run.ts`) — one patient's clinical events as tracks, with its cohort's patients (a table view) to pick from. **`lib/timeline-tracks.ts`** is the track model, ported from cbioportal-frontend's `cbioportal-clinical-timeline` package and `patientView/timeline/timeline_helpers.tsx`: the portal's track order and `trackStructures` @@ -156,8 +156,8 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a what it does not know. A change to the document's shape, or to what an unchanged document means, bumps DOC_VERSION and adds an upgrade step there; never re-interpret an old shape anywhere else. - **`lib/explore.ts`** — faceted study exploration, single + federated (`buildExploreFederated`). -- **The `composition` view** (runner `lib/views/composition.ts`, SQL `lib/composition.ts`, figure - `lib/figures/composition.ts`) — parts of a whole per case (cell types, cell states, signatures): +- **The `composition` view** (runner `lib/views/composition/run.ts`, SQL `lib/composition.ts`, figure + `lib/views/composition/figure.ts`) — parts of a whole per case (cell types, cell states, signatures): a generic-assay profile's entities or 2–16 numeric clinical attributes (`custom:` included). Each case's vector is built in the database (`casesCte`); every participant returns only sums (`compositionMomentsSql`), and the per-case stacked bars come from `PatientLevelResolver.viewRows` diff --git a/app/_lollipop/HotspotsBody.tsx b/app/_lollipop/HotspotsBody.tsx index fd57913..f740372 100644 --- a/app/_lollipop/HotspotsBody.tsx +++ b/app/_lollipop/HotspotsBody.tsx @@ -6,7 +6,7 @@ // The figure itself (lib/view-svg.ts) is the plot alone; a figure that travels without the app // carries the summary and the selection in its caption instead. import React, { useMemo } from "react"; -import type { HotspotGene, ViewResultOf } from "../../lib/types.ts"; +import type { ViewResultOf } from "../../lib/types.ts"; import { IMPACT_COLORS, IMPACT_TYPES, filterHotspotGene, cellOf, type AlterationCell, type ImpactType } from "../../lib/lollipop.ts"; import { lollipopPanelsSvg } from "../../lib/lollipop-svg.ts"; import { DrawAtWidth } from "../lab/figure.tsx"; @@ -14,6 +14,7 @@ import { fmtShare } from "../../lib/chart/format.ts"; import { X } from "lucide-react"; import { Button } from "../_ui/button"; import { TB } from "../_ui/toolbar.ts"; +import type { HotspotGene } from "../../lib/views/hotspots/types.ts"; type Col = "driver" | "vus"; const COLS: Col[] = ["driver", "vus"]; diff --git a/app/_oncoprint/OncoprintFigure.tsx b/app/_oncoprint/OncoprintFigure.tsx index ddeba29..be4e991 100644 --- a/app/_oncoprint/OncoprintFigure.tsx +++ b/app/_oncoprint/OncoprintFigure.tsx @@ -18,7 +18,7 @@ import { setPngDpi } from "../../lib/png-dpi.ts"; import { api } from "../lab/api.ts"; import type { UploadBinding } from "../lab/custom-data-client.ts"; import type { OncoprintBody } from "../../lib/api-schemas.ts"; -import type { OncoprintSpec } from "../../lib/types.ts"; + import { Check, Download, Ellipsis, ImageDown, ListOrdered, LoaderCircle, Minus, Plus, Sheet, SlidersHorizontal, Tag, X } from "lucide-react"; import { Button } from "../_ui/button"; import { ErrorAlert } from "../_ui/error-alert.tsx"; @@ -30,6 +30,7 @@ import { OptionSelect } from "../_ui/choice.tsx"; import { Slider } from "../_ui/slider"; import { FIELD, TB } from "../_ui/toolbar.ts"; import { cn } from "../../lib/cn"; +import type { OncoprintSpec } from "../../lib/views/oncoprint/types.ts"; /** one of the zoom group's buttons: the group draws the border */ const ZOOM = "h-full rounded-none text-muted-foreground hover:bg-muted hover:text-foreground"; diff --git a/app/api/oncoprint/detail/route.ts b/app/api/oncoprint/detail/route.ts index f0eeb8f..df7a862 100644 --- a/app/api/oncoprint/detail/route.ts +++ b/app/api/oncoprint/detail/route.ts @@ -1,6 +1,7 @@ -import type { OncoprintSpec } from "../../../../lib/types.ts"; + import { parseBody, OncoprintDetailRequest } from "../../../../lib/api-schemas.ts"; import { columnDetail } from "../../../../lib/oncoprint-detail"; +import type { OncoprintSpec } from "../../../../lib/views/oncoprint/types.ts"; export const maxDuration = 30; diff --git a/app/api/oncoprint/route.ts b/app/api/oncoprint/route.ts index 5fcb35c..d52a7b9 100644 --- a/app/api/oncoprint/route.ts +++ b/app/api/oncoprint/route.ts @@ -1,4 +1,4 @@ -import type { OncoprintSpec } from "../../../lib/types.ts"; + import { parseBody, OncoprintRequest } from "../../../lib/api-schemas.ts"; import { cachedContract } from "../../../lib/next-cache.ts"; import { withRequestCustomData } from "../../../lib/custom-data-server.ts"; @@ -6,6 +6,7 @@ import { packContract } from "../../../lib/oncoprint-binary"; import { gzipSync } from "node:zlib"; import { contractToSvg } from "../../../lib/oncoprint-svg"; import { contractToTsv } from "../../../lib/oncoprint-tabular"; +import type { OncoprintSpec } from "../../../lib/views/oncoprint/types.ts"; export const maxDuration = 120; diff --git a/app/lab/alterations-body.tsx b/app/lab/alterations-body.tsx index d8a940b..ced59f3 100644 --- a/app/lab/alterations-body.tsx +++ b/app/lab/alterations-body.tsx @@ -7,9 +7,10 @@ import React, { useMemo, useState } from "react"; import { DataTable, specQuery } from "../_table/DataTable.tsx"; import { sampleMarks } from "../../lib/timeline-tracks.ts"; -import type { AlterationsResult, TableQuery } from "../../lib/types"; +import type { TableQuery } from "../../lib/types"; import { AnnotationBadge } from "./portal-bodies"; import { PatientNav, PatientsDialog, patientKeys, type PatientKey } from "./patient-nav.tsx"; +import type { AlterationsResult } from "../../lib/views/alterations/types"; type Spec = { patient?: PatientKey } & Record; diff --git a/app/lab/kind-cards.tsx b/app/lab/kind-cards.tsx index 2e5ff75..f803e25 100644 --- a/app/lab/kind-cards.tsx +++ b/app/lab/kind-cards.tsx @@ -13,7 +13,7 @@ import { Checkbox } from "../_ui/checkbox"; import { Input } from "../_ui/input"; import { Choice, OptionSelect } from "../_ui/choice.tsx"; import { FIELD } from "../_ui/toolbar.ts"; -import { RISK_LABEL_ABOVE } from "../../lib/figures/riskmodel.ts"; +import { RISK_LABEL_ABOVE } from "../../lib/views/riskmodel/figure.ts"; import { RiskTable1Button } from "./risk-table1-dialog.tsx"; import { RiskGroupsButton } from "./risk-groups-dialog.tsx"; import { GearMenu } from "../_ui/gear-menu"; @@ -1304,7 +1304,7 @@ export function KindCardBody({ c }: { c: KindCardCtx }): React.ReactNode | null /** The clinical data table's body, on the shared table (app/_table/DataTable.tsx). */ function ClinicalTableBody({ c }: { c: KindCardCtx }) { - const t = c.v.table as import("../../lib/types").TableResult; + const t = c.v.table as import("../../lib/views/table/types.ts").TableResult; return c.patch({ ...q, attributes: undefined })} cell={(id, x) => (id === "patient" || id === "sample") ? {x} : undefined} />; diff --git a/app/lab/page.tsx b/app/lab/page.tsx index 4a3ccd0..3a1ea8d 100644 --- a/app/lab/page.tsx +++ b/app/lab/page.tsx @@ -28,7 +28,7 @@ import { useSavedArtifacts } from "./use-saved.ts"; import { SavedTab } from "./saved-tab.tsx"; import { ExamplesTab } from "./examples-tab.tsx"; import { DrawAtWidth } from "./figure.tsx"; -import { survivalSvg } from "../../lib/figures/survival.ts"; +import { survivalSvg } from "../../lib/views/survival/figure.ts"; import { GenesDialog, CustomDataWarning, CohortColumn, DocDialog, ViewColumn, type AnalysisCtx } from "./analysis-sections.tsx"; import { StudyPicker, ExploreBar, AddChartDialog, DerivedFacets, ClinicalFacets, GeneFacets, StudyBreakdown, DataTypes, AlteredGenes, EventsAndCaseLists, type ExploreCtx } from "./explore-sections.tsx"; import { CohortEditDialog, ViewNoteDialog, WorkspaceDialog } from "./dialogs.tsx"; diff --git a/app/lab/portal-bodies.tsx b/app/lab/portal-bodies.tsx index 694134d..181879f 100644 --- a/app/lab/portal-bodies.tsx +++ b/app/lab/portal-bodies.tsx @@ -8,9 +8,9 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import type { ViewResult, ViewResultOf, DrawnResultOf, TableQuery, CohortDef, View } from "../../lib/types"; import { DataTable, specQuery } from "../_table/DataTable.tsx"; export { usePages, Pager } from "../_table/DataTable.tsx"; -import { cancertypesPanelSvg } from "../../lib/figures/cancertypes"; -import { enrichmentChartsSvg } from "../../lib/figures/enrichment"; -import { assayEntitySvg } from "../../lib/figures/assays"; +import { cancertypesPanelSvg } from "../../lib/views/cancertypes/figure.ts"; +import { enrichmentChartsSvg } from "../../lib/views/enrichment/figure.ts"; +import { assayEntitySvg } from "../../lib/views/assays/figure.ts"; import { pDigits, fmtP, fmtPct, fmtNum } from "../../lib/chart/format"; import { textOn, seriesStyle } from "../../lib/chart/style"; import { frame, palette, SERIES } from "../../lib/svg-kit"; diff --git a/app/lab/risk-groups-dialog.tsx b/app/lab/risk-groups-dialog.tsx index 94b3532..496ee61 100644 --- a/app/lab/risk-groups-dialog.tsx +++ b/app/lab/risk-groups-dialog.tsx @@ -6,8 +6,8 @@ // distance between its cuts — nothing is asked of the server), and nothing reaches the document // until Apply. The dialog owns its draft, so dragging re-renders it alone, not the page. import { useLayoutEffect, useMemo, useState } from "react"; -import type { RiskmodelResult } from "../../lib/types.ts"; -import { riskHistogramFigure, riskGroupNames, riskScaled, cutLandsAt } from "../../lib/figures/riskmodel.ts"; + +import { riskHistogramFigure, riskGroupNames, riskScaled, cutLandsAt } from "../../lib/views/riskmodel/figure.ts"; import { fmtInt } from "../../lib/svg-kit.ts"; import { Button } from "../_ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../_ui/dialog"; @@ -17,6 +17,7 @@ import { Slider } from "../_ui/slider"; import { TB, FIELD } from "../_ui/toolbar.ts"; import { cn } from "../../lib/cn"; import { SvgAtWidth } from "./figure.tsx"; +import type { RiskmodelResult } from "../../lib/views/riskmodel/types.ts"; /** what the view's groups are: n equal quantiles, or cuts at whole percentiles */ type Groups = { groups: number } | { cuts: number[] }; diff --git a/app/lab/risk-table1-dialog.tsx b/app/lab/risk-table1-dialog.tsx index 2caf7fc..223bbe0 100644 --- a/app/lab/risk-table1-dialog.tsx +++ b/app/lab/risk-table1-dialog.tsx @@ -1,15 +1,15 @@ "use client"; // The risk model's Table 1: each risk group's clinical and genomic makeup, side by side, opened from -// the card's toolbar. Computed on the server with the model (lib/views/riskmodel-table1.ts) — the +// the card's toolbar. Computed on the server with the model (lib/views/riskmodel/table1.ts) — the // dialog sends the view with its draft `describe` and shows the counts that come back; no patient // reaches the browser. The draft is the dialog's own (attributes and genes added or dropped re-run // only this request, never the page), and is ephemeral until "Add to document" writes it into the // view, so a table someone keeps is reproducible from the link. import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import type { AnalysisDoc, RiskTable1, RiskTable1Row, RiskmodelResult } from "../../lib/types.ts"; +import type { AnalysisDoc } from "../../lib/types.ts"; import { fmtInt, fmtNum, fmtPct, pDigits } from "../../lib/svg-kit.ts"; -import { TABLE1_MAX_ATTRIBUTES, TABLE1_MAX_GENES } from "../../lib/views/riskmodel-table1.ts"; +import { TABLE1_MAX_ATTRIBUTES, TABLE1_MAX_GENES } from "../../lib/views/riskmodel/table1.ts"; import { api } from "./api.ts"; import { viewRequest } from "./use-analysis.ts"; import { PickMenu } from "./view-chrome.tsx"; @@ -20,6 +20,7 @@ import { RemoveBadge } from "../_ui/remove-badge"; import { ErrorAlert } from "../_ui/error-alert"; import { TB, FIELD } from "../_ui/toolbar.ts"; import { cn } from "../../lib/cn"; +import type { RiskTable1, RiskTable1Row, RiskmodelResult } from "../../lib/views/riskmodel/types.ts"; type Describe = { attributes?: string[]; genes?: string[] }; /** a categorical row shows this many values; the rest are pooled into one disclosed "other" line */ diff --git a/app/lab/use-view-edits.ts b/app/lab/use-view-edits.ts index dcd1f3a..1c094e9 100644 --- a/app/lab/use-view-edits.ts +++ b/app/lab/use-view-edits.ts @@ -7,13 +7,14 @@ import { hasKindCard, defaultKindView, KIND_CARDS } from "./kind-cards"; import { pruneSort } from "../_oncoprint/OncoprintCard.tsx"; import type { GeneTrackSpec } from "../../lib/gene-tracks.ts"; -import type { AnalysisDoc, AnalysisResult, CohortDef, OncoprintView } from "../../lib/types.ts"; +import type { AnalysisDoc, AnalysisResult, CohortDef } from "../../lib/types.ts"; import type { AddCohortTarget } from "./add-cohort-dialog.tsx"; import { expandSplits, type PartitionShape } from "../../lib/partition-arms.ts"; import { treatmentEventOf } from "../../lib/event-names.ts"; -import { CohortNode } from "./view-chrome.tsx"; +import type { CohortNode } from "./view-chrome.tsx"; import type { DocStore } from "./doc-store.ts"; +import type { OncoprintView } from "../../lib/views/oncoprint/types.ts"; /** What useViewEdits needs from the analysis page. */ export interface ViewEditsPage { diff --git a/app/lab/view-cards.tsx b/app/lab/view-cards.tsx index 851b84e..1180439 100644 --- a/app/lab/view-cards.tsx +++ b/app/lab/view-cards.tsx @@ -8,7 +8,7 @@ import { KIND_CARDS, hasKindCard, kindSub, KindCardControls, KindCardBody, draws import type { ColumnSelection } from "../_oncoprint/OncoprintFigure.tsx"; import { OncoprintCard, Waiting } from "../_oncoprint/OncoprintCard.tsx"; import type { UnpackedContract } from "../../lib/oncoprint-binary.ts"; -import { isDrawn, type AnalysisDoc, type AnalysisResult, type OncoprintView, type ExploreResult } from "../../lib/types.ts"; +import { isDrawn, type AnalysisDoc, type AnalysisResult, type ExploreResult } from "../../lib/types.ts"; import { pDigits } from "../../lib/chart/format.ts"; import { MethodPanel } from "./method-panel.tsx"; import { viewRequest } from "./use-analysis.ts"; @@ -25,6 +25,7 @@ import { cn } from "../../lib/cn"; import { Choice } from "../_ui/choice.tsx"; import { GearMenu, MenuSeparator } from "../_ui/gear-menu.tsx"; import { CHIP, TB } from "../_ui/toolbar.ts"; +import type { OncoprintView } from "../../lib/views/oncoprint/types.ts"; type ResultView = AnalysisResult["views"][number]; type ViewChoice = NonNullable["cohorts"]>; diff --git a/docs/plot-case-level.md b/docs/plot-case-level.md index e33db78..3bff1c3 100644 --- a/docs/plot-case-level.md +++ b/docs/plot-case-level.md @@ -22,8 +22,8 @@ interactive. Its rules are in `src/shared/components/plots/PlotsTabUtils.tsx` only patient-aware option is "connect samples from the same patient" on a box plot. - Everything is Victory SVG, one DOM node per point, with no cap and no downsampling. -Our plot view (`lib/plot.ts`, `lib/views/plot.ts`, `lib/federation.ts`, the figure in -`lib/figures/plot.ts`, the controls in `app/lab/plot-controls.tsx`) is aggregate-first: a scatter is +Our plot view (`lib/plot.ts`, `lib/views/plot/run.ts`, `lib/federation.ts`, the figure in +`lib/views/plot/figure.ts`, the controls in `app/lab/plot-controls.tsx`) is aggregate-first: a scatter is a density grid, a box is quartiles plus a violin, a table is counts. ## The model: every point when it is a drawing, the cases that matter when it is not diff --git a/lib/api-schemas.ts b/lib/api-schemas.ts index b6c3231..0cfbaa5 100644 --- a/lib/api-schemas.ts +++ b/lib/api-schemas.ts @@ -10,7 +10,8 @@ // Unknown fields are kept (parseBody), so an addition to the document needs no change here. // Browser-safe: the client imports it. import { z } from "zod"; -import type { CohortDef, OncoprintSpec, View } from "./types.ts"; +import type { CohortDef, View } from "./types.ts"; +import type { OncoprintSpec } from "./views/oncoprint/types.ts"; const isObject = (v: unknown): v is object => typeof v === "object" && v !== null && !Array.isArray(v); const object = (what: string) => z.custom(isObject, what + " must be an object"); diff --git a/lib/federation-views.ts b/lib/federation-views.ts index 9172677..bcf3c14 100644 --- a/lib/federation-views.ts +++ b/lib/federation-views.ts @@ -8,13 +8,12 @@ import { ALTERED_COND, driverJoin } from "./federation.ts"; import { clinicalValueFoldSql, nameFoldSql, foldedEqualsSql, foldedInSql } from "./event-names.ts"; import { clinicalTable } from "./custom-data.ts"; import { splitBucketSql } from "./cohort2.ts"; -import { casesCte, type CompositionSource } from "./composition.ts"; import { finiteSql } from "./sql-number.ts"; import { linesCtes, type LineSpec } from "./treatment-lines.ts"; import { VIEW_ATTR_RE } from "./custom-data-names.ts"; /** bare patient id for a *_unique_id column of this study */ -const pidOf = (col: string, study: string): string => "replaceOne(" + col + ", " + sq(study + "_") + ", '')"; +export const pidOf = (col: string, study: string): string => "replaceOne(" + col + ", " + sq(study + "_") + ", '')"; // --------------------------------------------------------------------------- // genome: copy-number segments, binned along the genome @@ -42,54 +41,6 @@ export function studyGenomeSql(study: string, db: string): string { "WHERE cs.cancer_study_identifier = " + sq(study); } -export interface GenomeBinOpts { - /** bin width in base pairs */ - binBp: number; - /** |segment mean| at or past this is a gain / loss (log2 ratio) */ - threshold: number; - includeX: boolean; -} - -/** - * The cohort's segmented SAMPLES, one row per (chromosome, bin): how many have a segment - * covering that bin's midpoint (the bin's own denominator — a sample whose segmentation does - * not reach a bin was not measured there, which is not the same as copy-neutral), and how many - * of those are gained or lost there. - * - * Each bin takes the value of the segment covering its MIDPOINT, so a focal event narrower than - * a bin can fall between midpoints and be missed; the caller says so and a smaller bin finds it. - * A sample with overlapping segments at one midpoint (it happens in a few studies' files) takes - * their mean, so it is still counted once. - */ -export function genomeBinsSql(cohortSql: string, study: string, db: string, o: GenomeBinOpts): string { - const B = Math.max(1, Math.floor(o.binBp)), half = Math.floor(B / 2), T = Number(o.threshold); - const chroms = GENOME_CHROMS.filter((c) => o.includeX || c !== "X"); - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "samp AS (\n" + - " SELECT internal_id AS sid FROM " + db + ".sample_derived\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - "seg AS (\n" + - " SELECT s.sample_id AS sid, multiIf(s.chr IN ('23', 'X', 'chrX'), 'X', replaceOne(s.chr, 'chr', '')) AS chrom,\n" + - " s.start AS st, s.end AS en, s.segment_mean AS m\n" + - " FROM " + db + ".copy_number_seg s\n" + - " WHERE s.cancer_study_id = (SELECT cancer_study_id FROM " + db + ".cancer_study WHERE cancer_study_identifier = " + sq(study) + ")\n" + - " AND s.sample_id IN (SELECT sid FROM samp) AND isFinite(s.segment_mean)\n" + - "),\n" + - "binned AS (\n" + - " SELECT sid, chrom, bin, avg(m) AS v FROM (\n" + - " SELECT sid, chrom, m,\n" + - " arrayJoin(range(toUInt64(greatest(0, ceil((st - " + half + ") / " + B + "))),\n" + - " toUInt64(greatest(0, floor((en - " + half + ") / " + B + ") + 1)))) AS bin\n" + - " FROM seg WHERE chrom IN (" + chroms.map(sq).join(",") + ") AND en >= st\n" + - " ) GROUP BY sid, chrom, bin\n" + - ")\n" + - "SELECT chrom, bin, count() AS profiled, countIf(v >= " + T + ") AS gain, countIf(v <= " + (-T) + ") AS loss\n" + - "FROM binned GROUP BY chrom, bin ORDER BY chrom, bin" - ); -} - /** How many of the cohort's samples exist, and how many have any segment at all. */ export function genomeCoverageSql(cohortSql: string, study: string, db: string): string { return ( @@ -106,149 +57,12 @@ export function genomeCoverageSql(cohortSql: string, study: string, db: string): ); } -// --------------------------------------------------------------------------- -// pathways: is ANY gene of a set altered? - -/** - * Per gene SET, for one cohort in one study: patients profiled for at least one member (any - * alteration type), altered in at least one member, and carrying a driver in at least one - * member — the oncoprint's merged-row rule, so a set's percentage is over the UNION of its - * members' panels. Built from exactly the CTEs geneCountsSql uses (same event table, the same - * high-level-CNA rule, the same driver join, the same panel coverage), so a one-gene set gives - * that gene's own 'any' counts. - */ -export function geneSetCountsSql(cohortSql: string, sets: { name: string; genes: string[] }[], study: string, - pin: string | null, db: string): string { - const genes = [...new Set(sets.flatMap((x) => x.genes))]; - const geneList = genes.map(sq).join(","); - const pairs = sets.flatMap((x) => x.genes.map((g) => "(" + sq(x.name) + "," + sq(g) + ")")).join(","); - const pid = pidOf("g.patient_unique_id", study), covPid = pidOf("sd.patient_unique_id", study); - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "members AS (SELECT tupleElement(m, 1) AS set_name, tupleElement(m, 2) AS gene FROM (SELECT arrayJoin([" + pairs + "]) AS m)),\n" + - "altered AS (\n" + - " SELECT g.hugo_gene_symbol AS gene, " + pid + " AS pid,\n" + - (pin ? " max(ann.is_driver) AS is_driver\n" : " 0 AS is_driver\n") + - " FROM " + db + ".genomic_event_derived g\n" + - (pin ? driverJoin(pin) : "") + - " WHERE g.cancer_study_identifier = " + sq(study) + "\n" + - " AND g.hugo_gene_symbol IN (" + geneList + ") AND " + ALTERED_COND + "\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY gene, pid\n" + - "),\n" + - "covered AS (\n" + - " SELECT DISTINCT pg.gene AS gene, " + covPid + " AS pid\n" + - " FROM " + db + ".sample_to_gene_panel_derived sp\n" + - " JOIN " + db + ".gene_panel_to_gene_derived pg ON pg.gene_panel_id = sp.gene_panel_id AND pg.gene IN (" + geneList + ")\n" + - " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + - " WHERE sp.cancer_study_identifier = " + sq(study) + "\n" + - " AND sp.alteration_type IN ('MUTATION_EXTENDED','COPY_NUMBER_ALTERATION','STRUCTURAL_VARIANT')\n" + - " AND " + covPid + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - "per AS (\n" + - " SELECT members.set_name AS set_name, x.pid AS pid, max(x.is_alt) AS is_alt, max(x.is_drv) AS is_drv\n" + - " FROM (\n" + - " SELECT gene, pid, 1 AS is_alt, is_driver > 0 AS is_drv FROM altered\n" + - " UNION ALL\n" + - " SELECT gene, pid, 0 AS is_alt, 0 AS is_drv FROM covered\n" + - " ) x\n" + - " INNER JOIN members ON members.gene = x.gene\n" + - " GROUP BY set_name, pid\n" + - ")\n" + - "SELECT set_name, uniqExact(pid) AS profiled, uniqExactIf(pid, is_alt = 1) AS altered, uniqExactIf(pid, is_drv = 1) AS driver\n" + - "FROM per GROUP BY set_name" - ); -} - -// --------------------------------------------------------------------------- -// diffexpr: every gene's moments on one molecular profile - -/** - * Per GENE, over the cohort's patients: n, Σv and Σv² of the patient's value (the mean of their - * samples', as profileMomentsSql takes it), on the profile as stored or as log2(v + 1). Three - * additive totals per gene are all Welch's t needs, so a site sends no value at all, and every - * gene in the profile comes back — a ranking for enrichment needs the whole list, not a - * shortlist. A negative stored value under log2 has no meaning and is left out, never clamped. - */ -export function profileMomentsAllSql(cohortSql: string, study: string, db: string, profileType: string, log2: boolean): string { - const pid = pidOf("sd.patient_unique_id", study); - const raw = finiteSql("ga.alteration_value"); - const val = log2 ? "log2(" + raw + " + 1)" : raw; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "vals AS (\n" + - " SELECT " + pid + " AS pid, ga.hugo_gene_symbol AS gene, avg(" + val + ") AS v\n" + - " FROM " + db + ".genetic_alteration_derived ga\n" + - " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = ga.sample_unique_id\n" + - " WHERE ga.cancer_study_identifier = " + sq(study) + " AND ga.profile_type = " + sq(profileType) + "\n" + - " AND " + raw + " IS NOT NULL AND isFinite(" + raw + ")" + (log2 ? " AND " + raw + " >= 0" : "") + "\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid, gene\n" + - ")\n" + - "SELECT gene, count() AS n, sum(v) AS sx, sum(v * v) AS sxx FROM vals GROUP BY gene" - ); -} - -/** Named MSigDB gene sets and their members' symbols (`prefix` a set-name prefix, e.g. HALLMARK_). */ -export function genesetMembersSql(names: string[], prefix: string | null, db: string): string { - const cond = names.length - ? "gs.external_id IN (" + names.map(sq).join(",") + ")" - : "startsWith(gs.external_id, " + sq(prefix || "HALLMARK_") + ")"; - return "SELECT gs.external_id AS set_name, groupUniqArray(g.hugo_gene_symbol) AS genes\n" + - "FROM " + db + ".geneset gs\n" + - "JOIN " + db + ".geneset_gene gg ON gg.geneset_id = gs.id\n" + - "JOIN " + db + ".gene g ON g.entrez_gene_id = gg.entrez_gene_id\n" + - "WHERE " + cond + " GROUP BY set_name ORDER BY set_name"; -} - -// --------------------------------------------------------------------------- -// paired: one patient's two kinds of sample, gene by gene - -export interface PairBy { attribute: string; a: string[]; b: string[] } - -/** - * The cohort's samples split into side 'a' and side 'b' by a SAMPLE-level attribute (folded on - * case and whitespace in both the data and the values, the one fold every clinical predicate - * uses), restricted to patients with at least one sample on each side. - */ -function pairedSidesCtes(cohortSql: string, study: string, db: string, by: PairBy): string { - const fold = (x: string) => clinicalValueFoldSql(x); - const lit = (vs: string[]) => vs.map((v) => fold(sq(v))).join(","); - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "sides AS (\n" + - " SELECT sample_unique_id AS suid, " + pidOf("patient_unique_id", study) + " AS pid,\n" + - " multiIf(" + fold("attribute_value") + " IN (" + lit(by.a) + "), 'a', " + fold("attribute_value") + " IN (" + lit(by.b) + "), 'b', '') AS side\n" + - " FROM " + clinicalTable(db, [by.attribute]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(by.attribute) + "\n" + - " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - "paired AS (SELECT pid FROM sides GROUP BY pid HAVING countIf(side = 'a') > 0 AND countIf(side = 'b') > 0),\n" + - "ps AS (SELECT suid, pid, side FROM sides WHERE side != '' AND pid IN (SELECT pid FROM paired))" - ); -} - -/** - * Per gene, among patients with a sample on each side AND profiled for the gene on each side - * (by that side's own panels — a gene one side never assayed says nothing about concordance): - * altered on both sides, on side a only, on side b only. A patient with several samples on a - * side is altered on that side if any of them is. Counts only. - * - * `genes` null scans: every gene with a counted alteration in a paired patient's sample, and only - * the genes with at least one patient altered on some side come back (a gene altered nowhere - * carries no information about concordance or direction). - */ -export function pairedCountsSql(cohortSql: string, study: string, db: string, genes: string[] | null, by: PairBy, - pin: string | null, measure: "driver" | "any"): string { - return pairedSidesCtes(cohortSql, study, db, by) + ",\n" + sideCountsTail(study, db, genes, pin, measure); -} - /** * The per-gene counting after a `ps` CTE (suid, pid, side 'a' | 'b') — shared by every view that * puts each patient's samples on two sides: the paired view's sample attribute, the acquired * view's samples before and after a treatment start. */ -function sideCountsTail(study: string, db: string, genes: string[] | null, pin: string | null, measure: "driver" | "any", exclude?: string): string { +export function sideCountsTail(study: string, db: string, genes: string[] | null, pin: string | null, measure: "driver" | "any", exclude?: string): string { const scan = genes == null; const geneList = scan ? "SELECT DISTINCT gene FROM alt" : genes.map(sq).join(","); const hit = measure === "driver" ? "max(ann.is_driver)" : "1"; @@ -283,14 +97,6 @@ function sideCountsTail(study: string, db: string, genes: string[] | null, pin: ); } -/** How many of the cohort's patients have a sample on each side, and how many samples each side holds. */ -export function pairedPatientsSql(cohortSql: string, study: string, db: string, by: PairBy): string { - return ( - pairedSidesCtes(cohortSql, study, db, by) + "\n" + - "SELECT uniqExact(pid) AS patients, countIf(side = 'a') AS a_samples, countIf(side = 'b') AS b_samples FROM ps" - ); -} - /** Every value the attribute takes among the cohort's samples, for a refusal that names them. */ export function sampleAttributeValuesSql(cohortSql: string, study: string, db: string, attribute: string): string { return ( @@ -302,538 +108,6 @@ export function sampleAttributeValuesSql(cohortSql: string, study: string, db: s ); } -// --------------------------------------------------------------------------- -// acquired: each patient's last sample before a treatment start and first sample after it - -export interface AcquiredSides { - /** the treatment start: its FIRST event of this type (with key = value, when given) */ - anchor: { eventType: string; key?: string; value?: string }; - /** the timeline event types that date a sample, and the attribute naming the sample */ - sampleEvents: string[]; sampleKey: string; - /** pre: the last dated sample on or before the anchor day, at most this many days before it (null: any) */ - preWindowDays: number | null; - /** post: the first dated sample at least this many days after the anchor */ - minGapDays: number; -} - -/** - * The cohort's patients with a dated sample on each side of a treatment start, as a `ps` CTE (one - * sample per side: 'a' the last on or before the anchor, 'b' the first at least minGapDays after; - * ties on a day broken by sample id, so the pick is deterministic). A sample's day is the first - * timeline event of the given types naming it, matched to the study's samples by stable id. - */ -function acquiredSidesCtes(cohortSql: string, study: string, db: string, o: AcquiredSides): string { - const a = o.anchor; - const preCond = "rel <= 0" + (o.preWindowDays != null ? " AND rel >= " + (-Math.floor(o.preWindowDays)) : ""); - const postCond = "rel >= " + Math.floor(o.minGapDays); - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "anch AS (\n" + - " SELECT p.stable_id AS pid, min(ce.start_date) AS day\n" + - " FROM " + db + ".clinical_event ce\n" + - " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + - " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + - " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", a.eventType) + "\n" + - (a.key ? " AND ce.clinical_event_id IN (SELECT clinical_event_id FROM " + db + ".clinical_event_data WHERE " + - foldedEqualsSql("key", a.key) + " AND " + foldedEqualsSql("value", String(a.value ?? "")) + ")\n" : "") + - " AND p.stable_id IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - "),\n" + - "tl AS (\n" + - " SELECT p.stable_id AS pid, d.value AS sample, min(ce.start_date) AS day\n" + - " FROM " + db + ".clinical_event ce\n" + - " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + - " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + - " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("d.key", o.sampleKey) + "\n" + - " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedInSql("ce.event_type", o.sampleEvents) + "\n" + - " AND p.stable_id IN (SELECT pid FROM anch)\n" + - " GROUP BY pid, sample\n" + - "),\n" + - "dated AS (\n" + - " SELECT tl.pid AS pid, sd.sample_unique_id AS suid, tl.day - anch.day AS rel\n" + - " FROM tl INNER JOIN anch ON anch.pid = tl.pid\n" + - " INNER JOIN " + db + ".sample_derived sd ON sd.cancer_study_identifier = " + sq(study) + " AND sd.sample_stable_id = tl.sample AND sd.patient_stable_id = tl.pid\n" + - "),\n" + - "pick AS (\n" + - " SELECT pid, countIf(" + preCond + ") AS n_pre, countIf(" + postCond + ") AS n_post,\n" + - " argMaxIf(suid, (rel, suid), " + preCond + ") AS pre, argMinIf(suid, (rel, suid), " + postCond + ") AS post,\n" + - " maxIf(rel, " + preCond + ") AS pre_rel, minIf(rel, " + postCond + ") AS post_rel\n" + - " FROM dated GROUP BY pid\n" + - "),\n" + - "ps AS (\n" + - " SELECT pre AS suid, pid, 'a' AS side FROM pick WHERE n_pre > 0 AND n_post > 0\n" + - " UNION ALL SELECT post AS suid, pid, 'b' AS side FROM pick WHERE n_pre > 0 AND n_post > 0\n" + - ")" - ); -} - -/** Per gene, among patients with a sample before and after the anchor, both profiled for it: as pairedCountsSql (a = before, b = after). */ -export function acquiredCountsSql(cohortSql: string, study: string, db: string, genes: string[] | null, o: AcquiredSides, - pin: string | null, measure: "driver" | "any"): string { - return acquiredSidesCtes(cohortSql, study, db, o) + ",\n" + sideCountsTail(study, db, genes, pin, measure); -} - -/** The funnel from cohort to pairs: anchored, with a dated sample, with one before, one after, both; and the pairs' spacing in days. */ -export function acquiredFunnelSql(cohortSql: string, study: string, db: string, o: AcquiredSides): string { - const both = "n_pre > 0 AND n_post > 0"; - return ( - acquiredSidesCtes(cohortSql, study, db, o) + "\n" + - "SELECT (SELECT count() FROM anch) AS anchored, (SELECT uniqExact(pid) FROM tl) AS with_events, count() AS dated,\n" + - " countIf(n_pre > 0) AS with_pre, countIf(n_post > 0) AS with_post, countIf(" + both + ") AS paired,\n" + - " quantileExactIf(0.5)(-pre_rel, " + both + ") AS pre_median, quantileExactIf(0.5)(post_rel, " + both + ") AS post_median,\n" + - " quantileExactIf(0.25)(post_rel - pre_rel, " + both + ") AS gap_q1, quantileExactIf(0.5)(post_rel - pre_rel, " + both + ") AS gap_median,\n" + - " quantileExactIf(0.75)(post_rel - pre_rel, " + both + ") AS gap_q3\n" + - "FROM pick" - ); -} - -// --------------------------------------------------------------------------- -// ctdna: a patient's plasma (cfDNA) samples against their tissue samples - -export interface CtdnaSides { - /** the sample attribute that classes samples (SAMPLE_CLASS), its plasma values and its tissue values */ - attribute: string; plasma: string[]; tissue: string[]; - /** samples on these panels are plasma whatever the attribute says (e.g. ACCESS129) */ - plasmaPanels: string[]; - /** keep only samples with a sample of the other kind dated within this many days (timeline dates); null: no dates needed */ - withinDays: number | null; - sampleEvents: string[]; sampleKey: string; - /** - * leave out plasma mutation calls likely from clonal haematopoiesis: those in `genes`, and (with - * `normalMinAlt`) those whose matched normal (white-cell) sample carries at least that many - * alternate reads at an allele fraction at least `normalMinRatio` of the plasma's; absent, every call counts - */ - ch?: { genes: string[]; normalMinAlt: number | null; normalMinRatio: number }; -} - -/** - * The cohort's samples classed plasma ('b') or tissue ('a'), as a `ps` CTE of the patients with - * both; `sides` keeps every classed sample, for questions about plasma alone. With `withinDays`, - * a sample is kept only when the timeline dates it and a sample of the other kind within that - * many days (`dated` and `near` say how many). - */ -function ctdnaSidesCtes(cohortSql: string, study: string, db: string, o: CtdnaSides): string { - const fold = (x: string) => clinicalValueFoldSql(x); - const lit = (vs: string[]) => vs.map((v) => fold(sq(v))).join(","); - const dated = o.withinDays != null; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "cls AS (\n" + - " SELECT sample_unique_id AS suid, any(" + fold("attribute_value") + ") AS v\n" + - " FROM " + clinicalTable(db, [o.attribute]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(o.attribute) + "\n" + - " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY suid\n" + - "),\n" + - "classed AS (\n" + - " SELECT sd.sample_unique_id AS suid, sd.patient_stable_id AS pid, sd.sample_stable_id AS sample,\n" + - " multiIf(" + (o.plasmaPanels.length ? "sd.sample_unique_id IN (SELECT sample_unique_id FROM " + db + ".sample_to_gene_panel_derived WHERE cancer_study_identifier = " + sq(study) + - " AND gene_panel_id IN (" + o.plasmaPanels.map(sq).join(",") + ")), 'b', " : "") + - "cls.v IN (" + lit(o.plasma) + "), 'b', cls.v IN (" + lit(o.tissue) + "), 'a', '') AS side\n" + - " FROM " + db + ".sample_derived sd LEFT JOIN cls ON cls.suid = sd.sample_unique_id\n" + - " WHERE sd.cancer_study_identifier = " + sq(study) + " AND sd.patient_stable_id IN (SELECT pid FROM cohort)\n" + - "),\n" + - (dated - ? "tl AS (\n" + - " SELECT p.stable_id AS pid, d.value AS sample, min(ce.start_date) AS day\n" + - " FROM " + db + ".clinical_event ce\n" + - " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + - " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + - " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("d.key", o.sampleKey) + "\n" + - " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedInSql("ce.event_type", o.sampleEvents) + "\n" + - " AND p.stable_id IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid, sample\n" + - "),\n" + - "dated AS (SELECT c.suid AS suid, c.pid AS pid, c.side AS side, tl.day AS day FROM classed c INNER JOIN tl ON tl.pid = c.pid AND tl.sample = c.sample WHERE c.side != ''),\n" + - "near AS (\n" + - " SELECT DISTINCT x.suid AS suid FROM dated x INNER JOIN dated y ON y.pid = x.pid\n" + - " WHERE x.side != y.side AND abs(x.day - y.day) <= " + Math.floor(o.withinDays!) + "\n" + - "),\n" + - "sides AS (SELECT suid, pid, side FROM classed WHERE side != '' AND suid IN (SELECT suid FROM near)),\n" - : "sides AS (SELECT suid, pid, side FROM classed WHERE side != ''),\n") + - "paired AS (SELECT pid FROM sides GROUP BY pid HAVING countIf(side = 'a') > 0 AND countIf(side = 'b') > 0),\n" + - "ps AS (SELECT suid, pid, side FROM sides WHERE pid IN (SELECT pid FROM paired))" + - (o.ch ? ",\n" + ctdnaChCte(study, db, o.ch) : "") - ); -} - -/** - * `chx`: the plasma samples' mutation calls a clonal-haematopoiesis rule removes — by gene, or by - * alternate reads in the matched normal (mutation_derived's normal counts, where the study loads them). - */ -function ctdnaChCte(study: string, db: string, ch: NonNullable): string { - const byGene = ch.genes.length ? "g.hugo_gene_symbol IN (" + ch.genes.map(sq).join(",") + ")" : "0"; - const normal = ch.normalMinAlt != null; - return ( - (normal - ? "nm AS (\n" + - " SELECT concat(studyId, '_', sampleId) AS suid, `GENE.hugoGeneSymbol` AS gene, proteinChange AS mv,\n" + - " max(normalAltCount >= " + Math.floor(ch.normalMinAlt!) + " AND normalAltCount / (normalAltCount + normalRefCount) >= " + Number(ch.normalMinRatio) + - " * tumorAltCount / greatest(1, tumorAltCount + tumorRefCount)) AS hit, max(normalAltCount IS NOT NULL) AS loaded\n" + - " FROM " + db + ".mutation_derived\n" + - " WHERE studyId = " + sq(study) + " AND concat(studyId, '_', sampleId) IN (SELECT suid FROM sides WHERE side = 'b')\n" + - " GROUP BY suid, gene, mv\n" + - "),\n" - : "") + - "chx AS (\n" + - " SELECT g.sample_unique_id AS suid, g.hugo_gene_symbol AS gene, g.mutation_variant AS mv,\n" + - " max(" + byGene + ") AS by_gene, " + (normal ? "max(ifNull(nm.hit, 0) = 1)" : "toUInt8(0)") + " AS by_normal,\n" + - " " + (normal ? "max(ifNull(nm.loaded, 0) = 1)" : "toUInt8(0)") + " AS has_normal\n" + - " FROM " + db + ".genomic_event_derived g\n" + - (normal ? " LEFT JOIN nm ON nm.suid = g.sample_unique_id AND nm.gene = g.hugo_gene_symbol AND nm.mv = g.mutation_variant\n" : "") + - " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation'\n" + - " AND g.sample_unique_id IN (SELECT suid FROM sides WHERE side = 'b')\n" + - " GROUP BY suid, gene, mv\n" + - ")" - ); -} - -/** with a CH rule, the condition that keeps an event: not a plasma mutation call `chx` removes (`side` is the side column) */ -const chKeep = (o: CtdnaSides, side: string): string | undefined => o.ch - ? "NOT (" + side + " = 'b' AND g.variant_type = 'mutation' AND (g.sample_unique_id, g.hugo_gene_symbol, g.mutation_variant) IN (SELECT suid, gene, mv FROM chx WHERE by_gene OR by_normal))" - : undefined; - -/** Per gene, among patients with plasma and tissue both profiled for it: as pairedCountsSql (a = tissue, b = plasma). */ -export function ctdnaCountsSql(cohortSql: string, study: string, db: string, genes: string[] | null, o: CtdnaSides, - pin: string | null, measure: "driver" | "any"): string { - return ctdnaSidesCtes(cohortSql, study, db, o) + ",\n" + sideCountsTail(study, db, genes, pin, measure, chKeep(o, "ps.side")); -} - -/** - * The patients with a plasma sample, a tissue sample, both (and, with dates, how many classed - * samples were dated); and of those with plasma, how many had any alteration called in it - * (ctDNA detected: a mutation, an amplification or deep deletion, or a structural variant; with - * `driver`, an OncoKB oncogenic one). - */ -export function ctdnaFunnelSql(cohortSql: string, study: string, db: string, o: CtdnaSides, pin: string | null, measure: "driver" | "any"): string { - return ( - ctdnaSidesCtes(cohortSql, study, db, o) + ",\n" + - ctdnaDetectedCte(study, db, pin, measure, chKeep(o, "s.side")) + "\n" + - "SELECT (SELECT uniqExactIf(pid, side = 'b') FROM classed) AS plasma_all, (SELECT uniqExactIf(pid, side = 'a') FROM classed) AS tissue_all,\n" + - " (SELECT uniqExactIf(pid, side = 'b') FROM sides) AS plasma, (SELECT uniqExactIf(pid, side = 'a') FROM sides) AS tissue,\n" + - " (SELECT count() FROM paired) AS both, (SELECT count() FROM det) AS detected,\n" + - " (SELECT countIf(side = 'b') FROM ps) AS plasma_samples, (SELECT countIf(side = 'a') FROM ps) AS tissue_samples,\n" + - " " + (o.withinDays != null ? "(SELECT count() FROM dated)" : "toUInt64(0)") + " AS dated_samples" + - (o.ch ? ",\n (SELECT count() FROM chx) AS ch_calls, (SELECT countIf(has_normal) FROM chx) AS ch_with_normal,\n" + - " (SELECT countIf(by_gene) FROM chx) AS ch_by_gene, (SELECT countIf(by_normal) FROM chx) AS ch_by_normal, (SELECT countIf(by_gene OR by_normal) FROM chx) AS ch_removed,\n" + - " (SELECT uniqExact(s.pid) FROM chx INNER JOIN sides s ON s.suid = chx.suid WHERE by_gene OR by_normal) AS ch_patients" : "") + "\n" + - "FROM system.one" - ); -} - -/** the patients with any (driver) alteration called in a plasma sample */ -function ctdnaDetectedCte(study: string, db: string, pin: string | null, measure: "driver" | "any", exclude?: string): string { - return ( - "det AS (\n" + - " SELECT DISTINCT s.pid AS pid FROM " + db + ".genomic_event_derived g\n" + - (pin && measure === "driver" ? driverJoin(pin) : "") + - " INNER JOIN sides s ON s.suid = g.sample_unique_id\n" + - " WHERE g.cancer_study_identifier = " + sq(study) + " AND s.side = 'b' AND " + ALTERED_COND + "\n" + - " AND g.sample_unique_id IN (SELECT suid FROM sides WHERE side = 'b')" + (pin && measure === "driver" ? " AND ann.is_driver" : measure === "driver" ? " AND 0" : "") + - (exclude ? " AND " + exclude : "") + "\n" + - ")" - ); -} - -/** - * Overall survival of the patients with a plasma sample, by ctDNA detected or not: counts of - * (detected, months, dead) — OS_MONTHS / OS_STATUS as the study records them. - */ -export function ctdnaSurvivalSql(cohortSql: string, study: string, db: string, o: CtdnaSides, pin: string | null, measure: "driver" | "any"): string { - const pid = pidOf("patient_unique_id", study); - return ( - ctdnaSidesCtes(cohortSql, study, db, o) + ",\n" + - ctdnaDetectedCte(study, db, pin, measure, chKeep(o, "s.side")) + ",\n" + - "os AS (\n" + - " SELECT " + pid + " AS pid, " + finiteSql("anyIf(attribute_value, attribute_name = 'OS_MONTHS')") + " AS months,\n" + - " anyIf(attribute_value, attribute_name = 'OS_STATUS') LIKE '1%' AS dead, " + pid + " IN (SELECT pid FROM det) AS detected\n" + - " FROM " + clinicalTable(db, ["OS_MONTHS", "OS_STATUS"]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN ('OS_MONTHS', 'OS_STATUS')\n" + - " AND " + pid + " IN (SELECT pid FROM sides WHERE side = 'b')\n" + - " GROUP BY pid\n" + - ")\n" + - "SELECT detected, round(months, 2) AS t, dead, count() AS n\n" + - "FROM os WHERE months IS NOT NULL AND months >= 0 GROUP BY detected, t, dead ORDER BY t" - ); -} - -/** with a CH rule: per gene, the plasma mutation calls it removes and their patients, by which rule */ -export function ctdnaChRemovedSql(cohortSql: string, study: string, db: string, o: CtdnaSides): string { - if (!o.ch) throw new Error("ctdnaChRemovedSql needs a CH rule"); - return ( - ctdnaSidesCtes(cohortSql, study, db, o) + "\n" + - "SELECT chx.gene AS gene, countIf(by_gene OR by_normal) AS calls, uniqExactIf(s.pid, by_gene OR by_normal) AS patients,\n" + - " countIf(by_gene) AS gene_calls, countIf(by_normal) AS normal_calls\n" + - "FROM chx INNER JOIN sides s ON s.suid = chx.suid\n" + - "GROUP BY gene HAVING calls > 0 ORDER BY calls DESC, gene LIMIT 40" - ); -} - -// --------------------------------------------------------------------------- -// exclusivity: the sample-by-gene alteration matrix, reduced to what DISCOVER's background needs - -/** - * The cohort's samples with a gene panel, each with its panel signature (every panel it was - * profiled on, any alteration type, joined), its stratum (a sample attribute's folded value, or - * '' unstratified), and its altered genes among those its panels cover (any alteration, or OncoKB - * drivers): `sp`, `altc` and `strat` CTEs. - */ -function exclusivityCtes(cohortSql: string, study: string, db: string, stratifyBy: string | null, pin: string | null, measure: "driver" | "any"): string { - const driver = measure === "driver"; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "sp AS (\n" + - " SELECT sample_unique_id AS suid, arrayStringConcat(arraySort(groupUniqArray(gene_panel_id)), '+') AS sig\n" + - " FROM " + db + ".sample_to_gene_panel_derived\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND alteration_type IN ('MUTATION_EXTENDED','COPY_NUMBER_ALTERATION','STRUCTURAL_VARIANT')\n" + - " AND sample_unique_id IN (SELECT sample_unique_id FROM " + db + ".sample_derived WHERE cancer_study_identifier = " + sq(study) + - " AND patient_stable_id IN (SELECT pid FROM cohort))\n" + - " GROUP BY suid\n" + - "),\n" + - "cov AS (\n" + - " SELECT DISTINCT s.sig AS sig, pg.gene AS gene\n" + - " FROM (SELECT DISTINCT sig, arrayJoin(splitByChar('+', sig)) AS panel FROM sp) s\n" + - " JOIN " + db + ".gene_panel_to_gene_derived pg ON pg.gene_panel_id = s.panel\n" + - "),\n" + - "altc AS (\n" + - " SELECT DISTINCT g.sample_unique_id AS suid, g.hugo_gene_symbol AS gene\n" + - " FROM " + db + ".genomic_event_derived g\n" + - (driver && pin ? driverJoin(pin) : "") + - " INNER JOIN sp ON sp.suid = g.sample_unique_id\n" + - " INNER JOIN cov ON cov.sig = sp.sig AND cov.gene = g.hugo_gene_symbol\n" + - " WHERE g.cancer_study_identifier = " + sq(study) + " AND " + ALTERED_COND + (driver ? (pin ? " AND ann.is_driver" : " AND 0") : "") + "\n" + - " AND g.sample_unique_id IN (SELECT suid FROM sp)\n" + - "),\n" + - "strat AS (\n" + - (stratifyBy - ? " SELECT sample_unique_id AS suid, any(" + clinicalValueFoldSql("attribute_value") + ") AS v FROM " + clinicalTable(db, [stratifyBy]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(stratifyBy) + " AND sample_unique_id IN (SELECT suid FROM sp)\n" + - " GROUP BY suid\n" - : " SELECT suid, '' AS v FROM sp\n") + - ")" - ); -} - -/** - * The samples grouped by what their background probability depends on — panel signature, - * stratum and burden r (altered covered genes) — and by which of `genes` they have altered (a - * bitmask, bit i for genes[i]): counts only. - */ -export function exclusivityGroupsSql(cohortSql: string, study: string, db: string, genes: string[], stratifyBy: string | null, - pin: string | null, measure: "driver" | "any"): string { - const mask = genes.map((g, i) => "maxIf(" + (2 ** i) + ", altc.gene = " + sq(g) + ")").join(" + ") || "0"; - return ( - exclusivityCtes(cohortSql, study, db, stratifyBy, pin, measure) + ",\n" + - "per AS (SELECT suid, count() AS r, " + mask + " AS amask FROM altc GROUP BY suid)\n" + - "SELECT sp.sig AS sig, ifNull(strat.v, '') AS stratum, per.r AS r, per.amask AS amask, count() AS n\n" + - "FROM sp LEFT JOIN per ON per.suid = sp.suid LEFT JOIN strat ON strat.suid = sp.suid\n" + - "GROUP BY sig, stratum, r, amask" - ); -} - -/** Per stratum and gene, the samples with that gene altered (the background's column sums). */ -export function exclusivityColumnsSql(cohortSql: string, study: string, db: string, stratifyBy: string | null, pin: string | null, measure: "driver" | "any"): string { - return ( - exclusivityCtes(cohortSql, study, db, stratifyBy, pin, measure) + "\n" + - "SELECT ifNull(strat.v, '') AS stratum, altc.gene AS gene, count() AS n\n" + - "FROM altc LEFT JOIN strat ON strat.suid = altc.suid GROUP BY stratum, gene" - ); -} - -/** The genes each panel signature in the cohort covers. Metadata only. */ -export function exclusivityCoverageSql(cohortSql: string, study: string, db: string): string { - return exclusivityCtes(cohortSql, study, db, null, null, "any") + "\nSELECT sig, gene FROM cov"; -} - -// --------------------------------------------------------------------------- -// instability: genome-wide instability scores per sample, from the same segments - -/** - * hg19 centromeres: the boundary between band p11.1 (p11.11 on chr11) and q11 in UCSC's hg19 - * cytoBand table (hgdownload.soe.ucsc.edu/goldenPath/hg19/database/cytoBand.txt.gz). The p arm - * is [0, centromere), the q arm [centromere, chromosome length) (CHROM_LENGTHS.hg19). - */ -export const HG19_CENTROMERES: Record = { - "1": 125000000, "2": 93300000, "3": 91000000, "4": 50400000, "5": 48400000, "6": 61000000, "7": 59900000, - "8": 45600000, "9": 49000000, "10": 40200000, "11": 53700000, "12": 35800000, "13": 17900000, "14": 17600000, - "15": 19000000, "16": 36600000, "17": 24000000, "18": 17200000, "19": 26500000, "20": 27500000, "21": 13200000, - "22": 14700000, "X": 60600000 -}; -/** the acrocentric p arms, which carry no unique sequence to measure — left out, as Taylor et al. 2018 did */ -const ACROCENTRIC = ["13", "14", "15", "21", "22"]; -/** the arms scored: 39 autosomal arms (and Xp, Xq with includeX), in genome order */ -export function instabilityArms(includeX: boolean): string[] { - return GENOME_CHROMS.filter((c) => includeX || c !== "X").flatMap((c) => ACROCENTRIC.includes(c) ? [c + "q"] : [c + "p", c + "q"]); -} - -export interface InstabilityOpts { threshold: number; armShare: number; includeX: boolean } - -/** - * Per segmented sample (in SQL, never returned): its FGA — the share of its segmented length on - * the scored chromosomes whose |segment mean| is past the threshold — and, per arm, the share of - * the arm's length gained and lost; an arm is gained (lost) when that share passes armShare, and - * measured when segments cover at least armShare of it. Aneuploidy score = arms gained or lost. - */ -function instabilityCtes(cohortSql: string, study: string, db: string, o: InstabilityOpts): string { - const T = Number(o.threshold), S = Number(o.armShare); - const chroms = GENOME_CHROMS.filter((c) => o.includeX || c !== "X"); - const arr = (xs: (string | number)[]) => "[" + xs.map((x) => typeof x === "number" ? String(x) : sq(x)).join(",") + "]"; - const cen = "transform(chrom, " + arr([...chroms]) + ", " + arr(chroms.map((c) => HG19_CENTROMERES[c])) + ", 0)"; - const len = "transform(chrom, " + arr([...chroms]) + ", " + arr(chroms.map((c) => CHROM_LENGTHS.hg19[c])) + ", 0)"; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "samp AS (\n" + - " SELECT internal_id AS sid FROM " + db + ".sample_derived\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - "seg AS (\n" + - " SELECT s.sample_id AS sid, multiIf(s.chr IN ('23', 'X', 'chrX'), 'X', replaceOne(s.chr, 'chr', '')) AS chrom,\n" + - " toInt64(s.start) AS st, toInt64(s.end) AS en, s.segment_mean AS m\n" + - " FROM " + db + ".copy_number_seg s\n" + - " WHERE s.cancer_study_id = (SELECT cancer_study_id FROM " + db + ".cancer_study WHERE cancer_study_identifier = " + sq(study) + ")\n" + - " AND s.sample_id IN (SELECT sid FROM samp) AND isFinite(s.segment_mean)\n" + - "),\n" + - "parts AS (\n" + - " SELECT sid, chrom, m, arm, greatest(toInt64(0), least(en, hi) - greatest(st, lo)) AS bp, hi - lo AS arm_len FROM (\n" + - " SELECT sid, chrom, m, st, en, " + cen + " AS c, " + len + " AS l,\n" + - " arrayJoin([(concat(chrom, 'p'), toInt64(0), toInt64(c)), (concat(chrom, 'q'), toInt64(c), toInt64(l))]) AS a,\n" + - " a.1 AS arm, a.2 AS lo, a.3 AS hi\n" + - " FROM seg WHERE chrom IN (" + chroms.map(sq).join(",") + ") AND en > st\n" + - " ) WHERE NOT (arm IN (" + ACROCENTRIC.map((c) => sq(c + "p")).join(",") + "))\n" + - "),\n" + - "arms AS (\n" + - " SELECT sid, arm, any(arm_len) AS alen, sum(bp) AS cov, sumIf(bp, m >= " + T + ") AS gbp, sumIf(bp, m <= " + (-T) + ") AS lbp\n" + - " FROM parts GROUP BY sid, arm\n" + - "),\n" + - "calls AS (\n" + - " SELECT sid, arm, cov >= " + S + " * alen AS measured, gbp > " + S + " * alen AS gain, lbp > " + S + " * alen AS loss FROM arms\n" + - "),\n" + - "per AS (\n" + - " SELECT a.sid AS sid, sum(a.cov) AS cov, sum(a.gbp + a.lbp) AS alt,\n" + - " countIf(c.gain OR c.loss) AS score, countIf(c.measured) AS measured\n" + - " FROM arms a INNER JOIN calls c ON c.sid = a.sid AND c.arm = a.arm GROUP BY a.sid\n" + - ")" - ); -} - -/** FGA in 1-point bins × aneuploidy score, with how many arms each sample had measured: counts of samples. */ -export function instabilityScoresSql(cohortSql: string, study: string, db: string, o: InstabilityOpts): string { - return ( - instabilityCtes(cohortSql, study, db, o) + "\n" + - "SELECT toInt32(least(100, floor(100 * alt / greatest(1, cov)))) AS fga_bin, score, measured, count() AS n\n" + - "FROM per GROUP BY fga_bin, score, measured ORDER BY fga_bin, score" - ); -} - -/** Per arm: the samples that measured it, and those with it gained or lost. */ -export function instabilityArmsSql(cohortSql: string, study: string, db: string, o: InstabilityOpts): string { - return ( - instabilityCtes(cohortSql, study, db, o) + "\n" + - "SELECT arm, countIf(measured) AS n_measured, countIf(measured AND gain) AS n_gain, countIf(measured AND loss) AS n_loss FROM calls GROUP BY arm" - ); -} - -// --------------------------------------------------------------------------- -// clonality: FACETS allele-specific copy number, per mutation call - -/** The cohort's samples; with `qc`, only those whose FACETS_QC is Pass. */ -function facetsSamplesCte(cohortSql: string, study: string, db: string, qc: boolean): string { - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "samp AS (\n" + - " SELECT internal_id AS sid, sample_unique_id AS suid FROM " + db + ".sample_derived\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + - (qc - ? " AND sample_unique_id IN (SELECT sample_unique_id FROM " + clinicalTable(db, ["FACETS_QC"]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = 'FACETS_QC'\n" + - " AND " + clinicalValueFoldSql("attribute_value") + " = 'pass')\n" - : "") + - ")" - ); -} - -/** - * Per gene, over the cohort's mutation CALLS that carry a FACETS estimate (a call is one mutation - * in one sample): how many are clonal, subclonal, indeterminate or uncalled; how many sit in a - * region with no minor allele (LOH at the locus), of those where the minor copy number is - * known; and the cancer-cell fraction in ten bins. Only the study's current mutation profile is - * read — the table also holds orphaned rows from superseded loads. - */ -export function clonalityGenesSql(cohortSql: string, study: string, db: string, genes: string[], pin: string | null, - measure: "driver" | "any", qc: boolean): string { - const geneList = genes.map(sq).join(","); - const drv = measure === "driver" && pin; - return ( - facetsSamplesCte(cohortSql, study, db, qc) + ",\n" + - "prof AS (\n" + - " SELECT gp.genetic_profile_id AS id FROM " + db + ".genetic_profile gp\n" + - " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = gp.cancer_study_id\n" + - " WHERE cs.cancer_study_identifier = " + sq(study) + " AND gp.genetic_alteration_type = 'MUTATION_EXTENDED'\n" + - "),\n" + - "calls AS (\n" + - " SELECT g.hugo_gene_symbol AS gene, a.sample_id AS sid, a.clonal AS call_state, a.ccf_expected_copies AS ccf, a.minor_copy_number AS minor\n" + - " FROM " + db + ".allele_specific_copy_number a\n" + - " JOIN " + db + ".mutation_event me ON me.mutation_event_id = a.mutation_event_id\n" + - " JOIN " + db + ".gene g ON g.entrez_gene_id = me.entrez_gene_id\n" + - (drv - ? " INNER JOIN oncokb_annotation.variant_annotation ann ON ann.oncokb_version = " + sq(pin) + " AND ann.is_driver\n" + - " AND ann.hugo_gene_symbol = g.hugo_gene_symbol AND ann.alteration_type = 'MUTATION' AND ann.alteration = me.protein_change\n" - : "") + - " WHERE a.genetic_profile_id IN (SELECT id FROM prof) AND a.sample_id IN (SELECT sid FROM samp)\n" + - " AND g.hugo_gene_symbol IN (" + geneList + ")\n" + - ")\n" + - "SELECT gene, count() AS calls, uniqExact(sid) AS samples,\n" + - " countIf(call_state = 'CLONAL') AS clonal, countIf(call_state = 'SUBCLONAL') AS subclonal,\n" + - " countIf(call_state = 'INDETERMINATE') AS indeterminate,\n" + - " countIf(minor = 0) AS loh, countIf(minor IS NOT NULL) AS loh_known,\n" + - Array.from({ length: 10 }, (_, i) => - " countIf(ccf >= " + (i / 10) + (i === 9 ? "" : " AND ccf < " + ((i + 1) / 10)) + ") AS ccf" + i).join(",\n") + "\n" + - "FROM calls GROUP BY gene" - ); -} - -/** - * Per cohort: its samples, how many carry any FACETS estimate, FACETS_QC pass/fail over ALL of - * them, and whole-genome doubling among the samples considered (QC-passing ones, with `qc`). - */ -export function facetsSummarySql(cohortSql: string, study: string, db: string, qc: boolean): string { - const attr = (name: string) => "(SELECT sample_unique_id AS suid, " + clinicalValueFoldSql("attribute_value") + " AS v FROM " + clinicalTable(db, [name]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(name) + ")"; - const considered = qc ? " AND suid IN (SELECT suid FROM qcv WHERE v = 'pass')" : ""; - return ( - facetsSamplesCte(cohortSql, study, db, false) + ",\n" + - "prof AS (\n" + - " SELECT gp.genetic_profile_id AS id FROM " + db + ".genetic_profile gp\n" + - " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = gp.cancer_study_id\n" + - " WHERE cs.cancer_study_identifier = " + sq(study) + " AND gp.genetic_alteration_type = 'MUTATION_EXTENDED'\n" + - "),\n" + - "wgd AS " + attr("FACETS_WGD") + ",\n" + - "qcv AS " + attr("FACETS_QC") + "\n" + - "SELECT count() AS samples,\n" + - " countIf(sid IN (SELECT DISTINCT sample_id FROM " + db + ".allele_specific_copy_number WHERE genetic_profile_id IN (SELECT id FROM prof))) AS ascn_samples,\n" + - " countIf(suid IN (SELECT suid FROM qcv WHERE v = 'pass')) AS qc_pass,\n" + - " countIf(suid IN (SELECT suid FROM qcv WHERE v = 'fail')) AS qc_fail,\n" + - " countIf(suid IN (SELECT suid FROM wgd WHERE v = 'true')" + considered + ") AS wgd_true,\n" + - " countIf(suid IN (SELECT suid FROM wgd WHERE v = 'false')" + considered + ") AS wgd_false\n" + - "FROM samp" - ); -} - -/** FACETS purity (0.05 bins over 0-1) and ploidy (0.25 bins over 0-8) among the samples considered. */ -export function facetsHistogramSql(cohortSql: string, study: string, db: string, qc: boolean): string { - return ( - facetsSamplesCte(cohortSql, study, db, qc) + "\n" + - "SELECT attribute_name AS attr,\n" + - " toInt32(floor(least(if(attribute_name = 'FACETS_PURITY', 0.9999, 7.9999), greatest(0, " + finiteSql("attribute_value") + ")) /\n" + - " if(attribute_name = 'FACETS_PURITY', 0.05, 0.25))) AS bin,\n" + - " count() AS n\n" + - "FROM " + clinicalTable(db, ["FACETS_PURITY", "FACETS_PLOIDY"]) + "\n" + - "WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name IN ('FACETS_PURITY', 'FACETS_PLOIDY')\n" + - " AND sample_unique_id IN (SELECT suid FROM samp) AND " + finiteSql("attribute_value") + " IS NOT NULL\n" + - "GROUP BY attr, bin ORDER BY attr, bin" - ); -} - /** Which studies hold allele-specific copy number for their current mutation profile. Metadata only. */ export function ascnStudiesSql(_study: string, db: string): string { return "SELECT DISTINCT cs.cancer_study_identifier AS study FROM " + db + ".allele_specific_copy_number a\n" + @@ -933,323 +207,35 @@ export function incidenceCountsSql(cohortSql: string, study: string, db: string, ); } -// --------------------------------------------------------------------------- -// labs: a lab value's course from treatment start, as % change from baseline - -export interface LabSpec { - /** the event type holding lab results, and the key/value naming this test (e.g. TEST = PSA) */ - eventType: string; testKey: string; test: string; - /** the attribute holding the numeric result */ - resultKey: string; -} -export interface LabOpts { - /** baseline: the last value within this many days before (or on) the anchor */ - baselineDays: number; - /** window around the anchor, and bin width, in days */ - fromDays: number; toDays: number; binDays: number; -} -/** - * % change is binned in 2-point steps CENTRED on −100, −98, … +300 (so an unchanged value sits - * exactly at 0, not at a bin's edge); values above +300 share the top bin. - */ -export const LAB_PCT_BIN = 2, LAB_PCT_MIN = -100, LAB_PCT_MAX = 300; -export const LAB_PCT_BINS = (LAB_PCT_MAX - LAB_PCT_MIN) / LAB_PCT_BIN + 1; - -function labCtes(cohortSql: string, study: string, db: string, lab: LabSpec, anchor: IncidenceAnchor, o: LabOpts): string { - const pick = anchor.occurrence === "LAST" ? "max" : "min"; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "anch AS (\n" + - " SELECT p.stable_id AS pid, " + pick + "(ce.start_date) AS day\n" + - " FROM " + db + ".clinical_event ce\n" + - " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + - " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + - " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", anchor.eventType) + "\n" + - (anchor.key ? " AND ce.clinical_event_id IN (SELECT clinical_event_id FROM " + db + ".clinical_event_data WHERE " + - foldedEqualsSql("key", anchor.key) + " AND " + foldedEqualsSql("value", String(anchor.value ?? "")) + ")\n" : "") + - " AND p.stable_id IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - "),\n" + - "lab AS (\n" + - " SELECT p.stable_id AS pid, ce.start_date AS day, " + finiteSql("r.value") + " AS v\n" + - " FROM " + db + ".clinical_event ce\n" + - " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + - " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + - " JOIN " + db + ".clinical_event_data t ON t.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("t.key", lab.testKey) + " AND " + foldedEqualsSql("t.value", lab.test) + "\n" + - " JOIN " + db + ".clinical_event_data r ON r.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("r.key", lab.resultKey) + "\n" + - " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", lab.eventType) + "\n" + - " AND p.stable_id IN (SELECT pid FROM anch) AND " + finiteSql("r.value") + " IS NOT NULL\n" + - "),\n" + - "base AS (\n" + - // the baseline is the mean of the readings on the last day with any, so several readings that day - // give one answer rather than whichever row came first - " SELECT lab.pid AS pid, max(lab.day) AS bday,\n" + - " arrayAvg(arrayMap((x) -> assumeNotNull(x.1), arrayFilter((x) -> x.2 = bday, groupArray((lab.v, lab.day))))) AS b\n" + - " FROM lab INNER JOIN anch ON anch.pid = lab.pid\n" + - " WHERE lab.day >= anch.day - " + Math.floor(o.baselineDays) + " AND lab.day <= anch.day GROUP BY lab.pid\n" + - "),\n" + - "chg AS (\n" + - " SELECT lab.pid AS pid, lab.day - anch.day AS rel, 100 * (lab.v - base.b) / base.b AS pc\n" + - " FROM lab INNER JOIN anch ON anch.pid = lab.pid INNER JOIN base ON base.pid = lab.pid\n" + - " WHERE base.b > 0 AND lab.day != base.bday\n" + - " AND lab.day - anch.day >= " + Math.floor(o.fromDays) + " AND lab.day - anch.day <= " + Math.floor(o.toDays) + "\n" + - ")" - ); -} - -const pctBin = (x: string) => - "toInt32(floor((least(" + LAB_PCT_MAX + ", greatest(" + LAB_PCT_MIN + ", " + x + ")) - (" + LAB_PCT_MIN + ") + " + (LAB_PCT_BIN / 2) + ") / " + LAB_PCT_BIN + "))"; - -/** - * The course: per time bin, the histogram of % change from baseline (each patient's mean within - * the bin, so a patient tested weekly weighs no more than one tested monthly). The baseline - * reading itself — 0% by definition — is not part of the course. Counts only. - */ -export function labCourseSql(cohortSql: string, study: string, db: string, lab: LabSpec, anchor: IncidenceAnchor, o: LabOpts): string { - return ( - labCtes(cohortSql, study, db, lab, anchor, o) + "\n" + - "SELECT tbin, pbin, count() AS n FROM (\n" + - " SELECT pid, toInt32(floor(rel / " + Math.max(1, Math.floor(o.binDays)) + ")) AS tbin, " + pctBin("avg(pc)") + " AS pbin\n" + - " FROM chg GROUP BY pid, tbin\n" + - ") GROUP BY tbin, pbin ORDER BY tbin, pbin" - ); -} - -/** - * Best response: each patient's lowest % change after the anchor (within the window), binned; - * how many fell by at least `responseDrop` percent, counted exactly rather than from the bins; - * and the counts behind the denominators — patients anchored, with a baseline, with a positive - * baseline, and with any value after the anchor. - */ -export function labBestSql(cohortSql: string, study: string, db: string, lab: LabSpec, anchor: IncidenceAnchor, o: LabOpts, responseDrop: number): string { - const best = "(SELECT pid, min(pc) AS best FROM chg WHERE rel > 0 GROUP BY pid)"; - return ( - labCtes(cohortSql, study, db, lab, anchor, o) + "\n" + - "SELECT 'best' AS what, " + pctBin("best") + " AS pbin, count() AS n FROM " + best + " GROUP BY pbin\n" + - "UNION ALL SELECT 'responders' AS what, 0 AS pbin, countIf(best <= " + (-Math.abs(Number(responseDrop))) + ") AS n FROM " + best + "\n" + - "UNION ALL SELECT 'anchored' AS what, 0 AS pbin, count() AS n FROM anch\n" + - "UNION ALL SELECT 'baseline' AS what, 0 AS pbin, count() AS n FROM base\n" + - "UNION ALL SELECT 'positive_baseline' AS what, 0 AS pbin, countIf(b > 0) AS n FROM base\n" + - "UNION ALL SELECT 'after' AS what, 0 AS pbin, uniqExactIf(pid, rel > 0) AS n FROM chg" - ); -} - -/** - * Each patient's best % change, one row per patient and no identifier — the values behind a - * waterfall. Patient-level: only PatientLevelResolver.viewRows runs it, the same entitlement - * that lets a plot draw its points. - */ -export function labWaterfallSql(cohortSql: string, study: string, db: string, lab: LabSpec, anchor: IncidenceAnchor, o: LabOpts, cap: number): string { - return ( - labCtes(cohortSql, study, db, lab, anchor, o) + "\n" + - "SELECT round(best, 1) AS best FROM (SELECT pid, min(pc) AS best FROM chg WHERE rel > 0 GROUP BY pid)\n" + - "ORDER BY best LIMIT " + Math.max(1, Math.floor(cap)) - ); -} - -/** The tests a lab event type holds, with how many patients and results each has. */ -export function labTestsSql(cohortSql: string, study: string, db: string, eventType: string, testKey: string): string { - return ( - "WITH cohort AS (" + cohortSql + ")\n" + - "SELECT " + nameFoldSql("t.value") + " AS tk, arraySort((x) -> (-x.2, x.1), arrayMap((v) -> (v, countEqual(groupArray(trimBoth(t.value)), v)), groupUniqArray(trimBoth(t.value))))[1].1 AS test,\n" + - " uniqExact(ce.patient_id) AS patients, count() AS results\n" + - "FROM " + db + ".clinical_event ce\n" + - "JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + - "JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + - "JOIN " + db + ".clinical_event_data t ON t.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("t.key", testKey) + "\n" + - "WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", eventType) + " AND p.stable_id IN (SELECT pid FROM cohort)\n" + - "GROUP BY tk ORDER BY patients DESC, tk LIMIT 40" - ); -} - -// --------------------------------------------------------------------------- -// cancertypes: a gene list's alteration frequency per group of samples - -/** - * Per group of the cohort's samples (a sample attribute's value, or the study) and per gene — plus - * the row "*" for the genes together — the units profiled for that gene and how many are altered, - * split by kind: only mutations, only amplifications, only deep deletions, only structural variants, - * or more than one kind. A unit is a SAMPLE, or a PATIENT counted in each group one of their - * samples falls in (the portal's default); a patient is profiled when any of their samples is, and - * carries the union of their samples' alterations. Counts only. - */ -export function cancerTypesSql(cohortSql: string, study: string, db: string, genes: string[], attribute: string | null, - pin: string | null, measure: "driver" | "any", unit: "sample" | "patient" = "sample"): string { - const geneList = genes.map(sq).join(","); - const drv = measure === "driver" && pin; - const fold = clinicalValueFoldSql("attribute_value"); - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "samp AS (\n" + - " SELECT sample_unique_id AS suid, patient_unique_id AS puid FROM " + db + ".sample_derived\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - (attribute - ? "grp AS (\n" + - " SELECT sample_unique_id AS suid, any(" + fold + ") AS g, any(attribute_value) AS label FROM " + clinicalTable(db, [attribute]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(attribute) + "\n" + - " AND sample_unique_id IN (SELECT suid FROM samp) GROUP BY suid\n" + - "),\n" - : "grp AS (SELECT suid, " + sq(study) + " AS g, " + sq(study) + " AS label FROM samp),\n") + - "alt AS (\n" + - " SELECT g.sample_unique_id AS suid, g.hugo_gene_symbol AS gene,\n" + - " multiIf(g.variant_type = 'mutation', 'mutation', g.variant_type = 'cna' AND g.cna_alteration >= 2, 'amp',\n" + - " g.variant_type = 'cna', 'homdel', 'sv') AS t\n" + - " FROM " + db + ".genomic_event_derived g\n" + - (drv ? driverJoin(pin) : "") + - " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.hugo_gene_symbol IN (" + geneList + ") AND " + ALTERED_COND + "\n" + - " AND g.sample_unique_id IN (SELECT suid FROM samp)" + (drv ? " AND ann.is_driver" : "") + "\n" + - " GROUP BY suid, gene, t\n" + - "),\n" + - // a sample is profiled for a gene its panel lists, and for one it is altered in (an off-panel call is a measurement) - "cov AS (\n" + - " SELECT DISTINCT sp.sample_unique_id AS suid, pg.gene AS gene FROM " + db + ".sample_to_gene_panel_derived sp\n" + - " JOIN " + db + ".gene_panel_to_gene_derived pg ON pg.gene_panel_id = sp.gene_panel_id AND pg.gene IN (" + geneList + ")\n" + - " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.sample_unique_id IN (SELECT suid FROM samp)\n" + - " AND sp.alteration_type IN ('MUTATION_EXTENDED','COPY_NUMBER_ALTERATION','STRUCTURAL_VARIANT')\n" + - " UNION DISTINCT SELECT DISTINCT suid, gene FROM alt\n" + - "),\n" + - "sg AS (\n" + - " SELECT cov.suid AS suid, cov.gene AS gene, groupUniqArrayIf(alt.t, alt.t != '') AS types\n" + - " FROM cov LEFT JOIN alt ON alt.suid = cov.suid AND alt.gene = cov.gene GROUP BY suid, gene\n" + - "),\n" + - "sg2 AS (SELECT suid, gene, types FROM sg UNION ALL SELECT suid, '*' AS gene, arrayDistinct(arrayFlatten(groupArray(types))) AS types FROM sg GROUP BY suid),\n" + - "u AS (\n" + - " SELECT " + (unit === "patient" ? "samp.puid" : "sg2.suid") + " AS k, ifNull(grp.g, '') AS g, any(ifNull(grp.label, '')) AS label, sg2.gene AS gene,\n" + - " arrayDistinct(arrayFlatten(groupArray(sg2.types))) AS types\n" + - " FROM sg2 JOIN samp ON samp.suid = sg2.suid LEFT JOIN grp ON grp.suid = sg2.suid\n" + - " GROUP BY k, g, gene\n" + - "),\n" + - // the whole cohort, each unit once however many groups it is in: grp '' is reserved for it below - "uall AS (SELECT k, '\\x01all' AS g, '' AS label, gene, arrayDistinct(arrayFlatten(groupArray(types))) AS types FROM u GROUP BY k, gene),\n" + - "uu AS (SELECT k, g, label, gene, types FROM u UNION ALL SELECT k, g, label, gene, types FROM uall)\n" + - "SELECT g AS grp, gene, any(label) AS label, count() AS profiled,\n" + - " countIf(length(types) > 0) AS altered,\n" + - " countIf(types = ['mutation']) AS mutation, countIf(types = ['amp']) AS amp,\n" + - " countIf(types = ['homdel']) AS homdel, countIf(types = ['sv']) AS sv,\n" + - " countIf(length(types) > 1) AS multiple\n" + - "FROM uu GROUP BY grp, gene" - ); -} - -/** Each group's units (samples, or patients with a sample in the group), profiled or not, and how many - * were profiled for mutations, copy number and structural variants at all — the portal's "+" rows. */ -export function cancerTypeSamplesSql(cohortSql: string, study: string, db: string, attribute: string | null, unit: "sample" | "patient" = "sample"): string { - const fold = clinicalValueFoldSql("attribute_value"); - const k = unit === "patient" ? "samp.puid" : "samp.suid"; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "samp AS (\n" + - " SELECT sample_unique_id AS suid, patient_unique_id AS puid FROM " + db + ".sample_derived\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - "prof AS (\n" + - " SELECT sample_unique_id AS suid, groupUniqArray(alteration_type) AS types FROM " + db + ".sample_to_gene_panel_derived\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND sample_unique_id IN (SELECT suid FROM samp) GROUP BY suid\n" + - ")\n" + - "SELECT " + (attribute ? "ifNull(grp.g, '')" : sq(study)) + " AS grp, uniqExact(" + k + ") AS samples,\n" + - " uniqExactIf(" + k + ", has(prof.types, 'MUTATION_EXTENDED')) AS mut,\n" + - " uniqExactIf(" + k + ", has(prof.types, 'COPY_NUMBER_ALTERATION')) AS cna,\n" + - " uniqExactIf(" + k + ", has(prof.types, 'STRUCTURAL_VARIANT')) AS sv\n" + - "FROM samp LEFT JOIN prof ON prof.suid = samp.suid\n" + - (attribute - ? "LEFT JOIN (\n" + - " SELECT sample_unique_id AS suid, any(" + fold + ") AS g FROM " + clinicalTable(db, [attribute]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(attribute) + "\n" + - " AND sample_unique_id IN (SELECT suid FROM samp) GROUP BY suid\n" + - ") grp ON grp.suid = samp.suid\n" - : "") + - "GROUP BY grp\n" + - // the whole cohort, each unit once - "UNION ALL SELECT '\\x01all' AS grp, uniqExact(" + k + "), uniqExactIf(" + k + ", has(prof.types, 'MUTATION_EXTENDED')),\n" + - " uniqExactIf(" + k + ", has(prof.types, 'COPY_NUMBER_ALTERATION')), uniqExactIf(" + k + ", has(prof.types, 'STRUCTURAL_VARIANT'))\n" + - "FROM samp LEFT JOIN prof ON prof.suid = samp.suid" - ); -} - -// --------------------------------------------------------------------------- -// overlap: how several cohorts' patients intersect - -/** - * Per membership pattern — a bitmask, bit i set when the patient is in cohort i — how many of the - * study's patients in any of the cohorts have exactly that pattern. 2^k − 1 counts at most, and - * nothing else; the caller names each bit. - */ -export function overlapCountsSql(cohortSqls: string[], study: string, db: string): string { - if (!cohortSqls.length) throw new Error("overlapCountsSql needs at least one cohort"); - const ctes = cohortSqls.map((c, i) => "c" + i + " AS (" + c + ")").join(",\n"); - const union = cohortSqls.map((_, i) => "SELECT pid FROM c" + i).join(" UNION DISTINCT "); - const mask = cohortSqls.map((_, i) => "(pid IN (SELECT pid FROM c" + i + ")) * " + (1 << i)).join(" + "); - // each patient's samples ride along, so the same patterns are counted in samples too - return "WITH " + ctes + ",\nu AS (" + union + "),\nm AS (SELECT pid, " + mask + " AS mask FROM u),\n" + - "s AS (SELECT " + pidOf("patient_unique_id", study) + " AS pid, count() AS ns FROM " + db + ".sample_derived\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM u) GROUP BY pid)\n" + - "SELECT mask, count() AS n, sum(s.ns) AS samples FROM m LEFT JOIN s ON s.pid = m.pid GROUP BY mask ORDER BY n DESC"; +/** The tests a lab event type holds, with how many patients and results each has. */ +export function labTestsSql(cohortSql: string, study: string, db: string, eventType: string, testKey: string): string { + return ( + "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT " + nameFoldSql("t.value") + " AS tk, arraySort((x) -> (-x.2, x.1), arrayMap((v) -> (v, countEqual(groupArray(trimBoth(t.value)), v)), groupUniqArray(trimBoth(t.value))))[1].1 AS test,\n" + + " uniqExact(ce.patient_id) AS patients, count() AS results\n" + + "FROM " + db + ".clinical_event ce\n" + + "JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + "JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + "JOIN " + db + ".clinical_event_data t ON t.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("t.key", testKey) + "\n" + + "WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", eventType) + " AND p.stable_id IN (SELECT pid FROM cohort)\n" + + "GROUP BY tk ORDER BY patients DESC, tk LIMIT 40" + ); } // --------------------------------------------------------------------------- // enrichment: every gene's alteration frequency in a cohort, for one alteration type export type EnrichAlteration = "mutation" | "cna" | "amp" | "homdel" | "sv"; -const ENRICH_PANEL_TYPE: Record = { +export const ENRICH_PANEL_TYPE: Record = { mutation: "MUTATION_EXTENDED", cna: "COPY_NUMBER_ALTERATION", amp: "COPY_NUMBER_ALTERATION", homdel: "COPY_NUMBER_ALTERATION", sv: "STRUCTURAL_VARIANT" }; -const enrichEventCond = (a: EnrichAlteration): string => +export const enrichEventCond = (a: EnrichAlteration): string => a === "mutation" ? "g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED'" : a === "sv" ? "g.variant_type = 'structural_variant'" : a === "amp" ? "g.variant_type = 'cna' AND g.cna_alteration >= 2" : a === "homdel" ? "g.variant_type = 'cna' AND g.cna_alteration <= -2" : "g.variant_type = 'cna' AND abs(g.cna_alteration) >= 2"; -/** Per gene: the cohort's samples with that alteration type in it (uncalled mutations excluded, as the - * portal's gene tables exclude them; high-level copy number only). Counts only. */ -export function enrichAlteredSql(cohortSql: string, study: string, db: string, alteration: EnrichAlteration, pin: string | null, driver: boolean): string { - const pid = pidOf("g.patient_unique_id", study); - return ( - "WITH cohort AS (" + cohortSql + ")\n" + - "SELECT g.hugo_gene_symbol AS gene, uniqExact(g.sample_unique_id) AS altered\n" + - "FROM " + db + ".genomic_event_derived g\n" + - (driver && pin ? driverJoin(pin) : "") + - "WHERE g.cancer_study_identifier = " + sq(study) + " AND " + enrichEventCond(alteration) + "\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)" + (driver && pin ? " AND ann.is_driver" : "") + "\n" + - "GROUP BY gene" - ); -} - -/** Per gene: the cohort's samples on a (non-WES) panel covering it for that alteration type — samples - * are counted per panel first and the panels then joined to their genes, Explore's own construction - * (distinct samples, never rows: a study with two CNA profiles lists each sample twice). */ -export function enrichPanelProfiledSql(cohortSql: string, study: string, db: string, alteration: EnrichAlteration): string { - const covPid = pidOf("sd.patient_unique_id", study); - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "pc AS (\n" + - " SELECT sp.gene_panel_id AS panel, uniqExact(sp.sample_unique_id) AS n\n" + - " FROM " + db + ".sample_to_gene_panel_derived sp\n" + - " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + - " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(ENRICH_PANEL_TYPE[alteration]) + "\n" + - " AND sp.gene_panel_id != 'WES' AND " + covPid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY panel\n" + - ")\n" + - "SELECT gptg.gene AS gene, sum(pc.n) AS profiled\n" + - "FROM " + db + ".gene_panel_to_gene_derived gptg INNER JOIN pc ON pc.panel = gptg.gene_panel_id\n" + - "GROUP BY gene" - ); -} - -/** The cohort's whole-exome samples for that alteration type: profiled for every gene, so one number. */ -export function enrichWesSql(cohortSql: string, study: string, db: string, alteration: EnrichAlteration): string { - const covPid = pidOf("sd.patient_unique_id", study); - return ( - "WITH cohort AS (" + cohortSql + ")\n" + - "SELECT uniqExact(sp.sample_unique_id) AS n\n" + - "FROM " + db + ".sample_to_gene_panel_derived sp\n" + - "JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + - "WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(ENRICH_PANEL_TYPE[alteration]) + "\n" + - " AND sp.gene_panel_id = 'WES' AND " + covPid + " IN (SELECT pid FROM cohort)" - ); -} - // --------------------------------------------------------------------------- // assays: a generic-assay profile's every entity, compared between cohorts @@ -1259,38 +245,6 @@ export function assayProfileSql(study: string, db: string, profileType: string): "WHERE stable_id = " + sq(study + "_" + profileType) + " AND genetic_alteration_type = 'GENERIC_ASSAY'"; } -/** The cohort's rows of one generic-assay profile. The profile type is the table's leading sort - * key, so only that profile's rows are read; the study's own profile id then picks its slice. */ -function assayRowsCte(cohortSql: string, study: string, db: string, profileType: string): string { - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "rows AS (\n" + - " SELECT entity_stable_id AS entity, value FROM " + db + ".generic_assay_data_derived\n" + - " WHERE profile_type = " + sq(profileType) + "\n" + - " AND profile_stable_id = " + sq(study + "_" + profileType) + "\n" + - " AND patient_unique_id IN (SELECT concat(" + sq(study + "_") + ", pid) FROM cohort)\n" + - ")" - ); -} - -/** Per entity, over the cohort's samples (or patients, for a patient-level assay): n, Σv and Σv² of - * the values that read as numbers, and how many did not (a limit value like ">10", or NA). */ -export function assayMomentsSql(cohortSql: string, study: string, db: string, profileType: string): string { - return ( - assayRowsCte(cohortSql, study, db, profileType) + "\n" + - "SELECT entity, countIf(isFinite(toFloat64OrNull(value))) AS n,\n" + - " sumIf(toFloat64OrNull(value), isFinite(toFloat64OrNull(value))) AS sx,\n" + - " sumIf(pow(toFloat64OrNull(value), 2), isFinite(toFloat64OrNull(value))) AS sxx,\n" + - " countIf(NOT isFinite(ifNull(toFloat64OrNull(value), nan))) AS other\n" + - "FROM rows GROUP BY entity" - ); -} - -/** Per entity and value: how many of the cohort's samples (or patients) take it. Counts only. */ -export function assayValueCountsSql(cohortSql: string, study: string, db: string, profileType: string): string { - return assayRowsCte(cohortSql, study, db, profileType) + "\nSELECT entity, value, count() AS n FROM rows GROUP BY entity, value"; -} - /** Display names for a profile's entities, where the study gave them (NAME). Metadata only: an * entity is a signature, an arm or a compound, never a patient. */ export function assayEntityNamesSql(study: string, db: string, profileType: string): string { @@ -1302,72 +256,6 @@ export function assayEntityNamesSql(study: string, db: string, profileType: stri ")\nSELECT entity, name FROM named"; } -// --------------------------------------------------------------------------- -// composition: parts of a whole per case (lib/composition.ts builds the vectors) - -/** - * Over the cohort's cases that have every part: n, and per part (arrays in part order) Σp and Σp² - * of the values compared; Σh and Σh² of each case's Shannon entropy; and how many cases were left - * out — with only some of the parts, or with a part below zero or none above. One row of sums: the - * vectors are built and summed inside the database. - */ -export function compositionMomentsSql(cohortSql: string, study: string, db: string, s: CompositionSource): string { - const k = s.parts.length; - return casesCte(s, study, db, cohortSql) + - "SELECT count() AS n, sumForEach(ps) AS sx, sumForEach(arrayMap(x -> x * x, ps)) AS sxx, sum(h) AS hx, sum(h * h) AS hxx,\n" + - " (SELECT countIf(k < " + k + ") FROM raw) AS incomplete,\n" + - " (SELECT countIf(k = " + k + " AND NOT (lo >= 0 AND tot > 0)) FROM raw) AS invalid\n" + - "FROM cases"; -} - -/** A generic-assay profile's entities, in id order. Metadata: an entity is a cell type or a - * signature, never a case. */ -export function compositionEntitiesSql(study: string, db: string, profileType: string): string { - return "WITH entities AS (\n" + - " SELECT DISTINCT entity_stable_id AS entity FROM " + db + ".generic_assay_data_derived\n" + - " WHERE profile_type = " + sq(profileType) + " AND profile_stable_id = " + sq(study + "_" + profileType) + "\n" + - ")\nSELECT entity FROM entities ORDER BY entity"; -} - -// --------------------------------------------------------------------------- -// correlates: one gene's expression against every gene's, genome-wide - -/** - * Per GENE, over the cohort's patients with a value for both it and the query gene (each the mean - * of the patient's samples, as profileMomentsSql takes it): the six sums Pearson's r needs, n, Σx, - * Σy, Σxy, Σx², Σy² — and the same six over the two values' average ranks among those patients, - * which is Spearman's ρ. x is the query gene on `profileType`; y every gene on `otherProfile` - * (the same profile unless given). Sums only: ranks and values never leave the database. - */ -export function correlatesMomentsSql(cohortSql: string, study: string, db: string, gene: string, profileType: string, otherProfile: string): string { - const pid = pidOf("sd.patient_unique_id", study); - const raw = finiteSql("ga.alteration_value"); - const vals = (pt: string, geneCond: string) => - " SELECT " + pid + " AS pid, ga.hugo_gene_symbol AS gene, avg(" + raw + ") AS v\n" + - " FROM " + db + ".genetic_alteration_derived ga\n" + - " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = ga.sample_unique_id\n" + - " WHERE ga.cancer_study_identifier = " + sq(study) + " AND ga.profile_type = " + sq(pt) + geneCond + "\n" + - " AND " + raw + " IS NOT NULL AND isFinite(" + raw + ")\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid, gene\n"; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "q AS (\n" + vals(profileType, " AND ga.hugo_gene_symbol = " + sq(gene)) + "),\n" + - "o AS (\n" + vals(otherProfile, "") + "),\n" + - "paired AS (SELECT o.gene AS gene, q.v AS x, o.v AS y FROM o INNER JOIN q ON q.pid = o.pid),\n" + - // average rank among tied values: the first rank of the tie plus half its width - "ranked AS (\n" + - " SELECT gene, x, y,\n" + - " rank() OVER (PARTITION BY gene ORDER BY x) + (count() OVER (PARTITION BY gene, x) - 1) / 2 AS rx,\n" + - " rank() OVER (PARTITION BY gene ORDER BY y) + (count() OVER (PARTITION BY gene, y) - 1) / 2 AS ry\n" + - " FROM paired\n" + - ")\n" + - "SELECT gene, count() AS n, sum(x) AS sx, sum(y) AS sy, sum(x * y) AS sxy, sum(x * x) AS sxx, sum(y * y) AS syy,\n" + - " sum(rx) AS rsx, sum(ry) AS rsy, sum(rx * ry) AS rsxy, sum(rx * rx) AS rsxx, sum(ry * ry) AS rsyy\n" + - "FROM ranked GROUP BY gene" - ); -} - /** Each gene's cytoband in the study's own reference genome (the portal's Cytoband column). Metadata only. */ export function geneCytobandsSql(genes: string[], study: string, db: string): string { return "SELECT g.hugo_gene_symbol AS gene, any(rgg.cytoband) AS cytoband FROM " + db + ".gene g\n" + @@ -1533,7 +421,7 @@ export interface TxEndpointOpts { } /** Timeline events of the study's cohort patients, aliased `alias` (the FROM … WHERE of a CTE). */ -function cohortEventsFrom(db: string, study: string, alias: string): string { +export function cohortEventsFrom(db: string, study: string, alias: string): string { return " FROM " + db + ".clinical_event " + alias + "\n" + " JOIN " + db + ".patient p ON p.internal_id = " + alias + ".patient_id\n" + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + @@ -1541,7 +429,7 @@ function cohortEventsFrom(db: string, study: string, alias: string): string { } /** an event of `alias` carrying `key` (folded) with a value meeting `valueCond` */ -function eventHasKv(db: string, alias: string, key: string, valueCond: string): string { +export function eventHasKv(db: string, alias: string, key: string, valueCond: string): string { return " AND " + alias + ".clinical_event_id IN (SELECT clinical_event_id FROM " + db + ".clinical_event_data WHERE " + foldedEqualsSql("key", key) + " AND " + valueCond + ")\n"; } @@ -1550,7 +438,7 @@ function eventHasKv(db: string, alias: string, key: string, valueCond: string): export interface ProgressionEvent { eventType: string; key: string; values: string[]; probabilityKey?: string; minProbability?: number } /** `os`: each cohort patient's OS_MONTHS (months) and whether OS_STATUS says deceased (dead) */ -function osCte(study: string, db: string): string { +export function osCte(study: string, db: string): string { const pid = pidOf("patient_unique_id", study); return "os AS (\n" + " SELECT " + pid + " AS pid,\n" + @@ -1564,7 +452,7 @@ function osCte(study: string, db: string): string { } /** `lastday`: each cohort patient's latest timeline day (a start or a stop) — the clock check's input */ -function lastDayCte(study: string, db: string): string { +export function lastDayCte(study: string, db: string): string { return "lastday AS (\n" + " SELECT p.stable_id AS pid, max(greatest(ce.start_date, coalesce(ce.stop_date, ce.start_date))) AS d\n" + cohortEventsFrom(db, study, "ce") + " GROUP BY pid\n" + @@ -1572,7 +460,7 @@ function lastDayCte(study: string, db: string): string { } /** `prog`: (pid, day) of every progression event of the cohort's patients */ -function progressionCte(study: string, db: string, pr: ProgressionEvent): string { +export function progressionCte(study: string, db: string, pr: ProgressionEvent): string { return "prog AS (\n" + " SELECT p.stable_id AS pid, ce.start_date AS day\n" + cohortEventsFrom(db, study, "ce") + " AND " + foldedEqualsSql("ce.event_type", pr.eventType) + "\n" + @@ -1682,860 +570,13 @@ export function txEndpointCountsSql(cohortSql: string, study: string, db: string "FROM cls GROUP BY kind, entry, t, ev, late ORDER BY t"; } -// --------------------------------------------------------------------------- -// multistate: the illness–death model (start → progression → death) from the timeline's day 0 - -/** the event type whose day-0 record says the timeline's clock starts at sequencing */ -export const SEQUENCING_EVENT = "Sequencing"; - -/** - * Per cohort, counts of patient paths through the illness–death model on the timeline's clock, in - * months from day 0: `prevalent` (a progression event on or before day 0, so the patient starts in - * the progression state), `tp` the first progression after day 0 strictly before the end of - * follow-up (0 when none), `te` the end of follow-up — OS_MONTHS, the timeline's last follow-up - * day being OS_MONTHS × DAYS_PER_MONTH — and `dead` (OS_STATUS 1:…). Flags beside them, for the - * caveat: `afterend` a progression recorded only on or after the end of follow-up (not counted), - * `assessed` any record of the progression key at all (a patient never imaged cannot show - * progression), `seq0` a Sequencing event on day 0 (what day 0 is), and `late` a timeline event - * more than CLOCK_SLACK_DAYS past the end of follow-up (the clock check). 'nofollowup' patients - * (no OS, or OS_MONTHS ≤ 0) are counted apart. Every cohort patient is in exactly one row's n. - */ -export function multistateCountsSql(cohortSql: string, study: string, db: string, pr: ProgressionEvent): string { - return "WITH cohort AS (" + cohortSql + "),\n" + [ - osCte(study, db), - lastDayCte(study, db), - progressionCte(study, db, pr), - "pp AS (SELECT pid, countIf(day <= 0) AS n_pre, countIf(day > 0) AS n_after, minIf(day, day > 0) AS first_after FROM prog GROUP BY pid)", - "assessed AS (\n" + - " SELECT DISTINCT p.stable_id AS pid\n" + cohortEventsFrom(db, study, "ce") + - " AND " + foldedEqualsSql("ce.event_type", pr.eventType) + "\n" + - eventHasKv(db, "ce", pr.key, "1") + - ")", - "seq0 AS (\n" + - " SELECT DISTINCT p.stable_id AS pid\n" + cohortEventsFrom(db, study, "ce") + - " AND " + foldedEqualsSql("ce.event_type", SEQUENCING_EVENT) + " AND ce.start_date = 0\n" + - ")", - "per AS (\n" + - " SELECT os.months AS months, os.months IS NOT NULL AS has_os, os.dead AS dead,\n" + - " pp.pid != '' AND pp.n_pre > 0 AS prevalent, pp.pid != '' AND pp.n_after > 0 AS has_after, toFloat64(pp.first_after) AS fa,\n" + - " a.pid != '' AS assessed, s.pid != '' AS seq0, lastday.d AS ld\n" + - " FROM (SELECT DISTINCT pid FROM cohort) c\n" + - " LEFT JOIN os ON os.pid = c.pid\n" + - " LEFT JOIN pp ON pp.pid = c.pid\n" + - " LEFT JOIN assessed a ON a.pid = c.pid\n" + - " LEFT JOIN seq0 s ON s.pid = c.pid\n" + - " LEFT JOIN lastday ON lastday.pid = c.pid\n" + - ")", - "cls AS (\n" + - " SELECT multiIf(NOT has_os OR months <= 0, 'nofollowup', 'record') AS kind,\n" + - " NOT prevalent AND has_after AND fa < months * " + DAYS_PER_MONTH + " AS hit,\n" + - " NOT prevalent AND has_after AND fa >= months * " + DAYS_PER_MONTH + " AS afterend,\n" + - " if(hit, fa / " + DAYS_PER_MONTH + ", 0) AS tp, months AS te,\n" + - " prevalent, dead, assessed, seq0, ld > months * " + DAYS_PER_MONTH + " + " + CLOCK_SLACK_DAYS + " AS late\n" + - " FROM per\n" + - ")", - ].join(",\n") + "\n" + - "SELECT kind, if(kind = 'record', toUInt8(prevalent), 0) AS prevalent, if(kind = 'record', round(tp, 4), 0) AS tp,\n" + - " if(kind = 'record', round(te, 4), 0) AS te, if(kind = 'record', toUInt8(dead), 0) AS dead,\n" + - " toUInt8(afterend) AS afterend, toUInt8(assessed) AS assessed, toUInt8(seq0) AS seq0, toUInt8(late) AS late, count() AS n\n" + - "FROM cls GROUP BY kind, prevalent, tp, te, dead, afterend, assessed, seq0, late ORDER BY te"; -} - -// --------------------------------------------------------------------------- -// cutpoint: a numeric marker against a survival endpoint, per patient - -/** - * One row per cohort patient with a survival time: months `m`, event `ev`, the marker `v` — the - * patient's value, or for a sample attribute the value of the patient's first sample by sample id - * — and, with `stratum`, that attribute's value read the same way as `s`. Values only, in no - * particular order: the patient id joins the CTEs and is never returned. Run only through - * PatientLevelResolver.viewRows, since a permutation test needs each patient's pair. - */ -export function cutpointRowsSql(cohortSql: string, study: string, db: string, - o: { attribute: string; months: string; status: string; stratum?: string | null }): string { - for (const a of [o.attribute, o.stratum, o.months, o.status]) if (a && !/^[A-Za-z0-9_:/.-]{1,128}$/.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); - const pid = pidOf("patient_unique_id", study); - const attrs = [o.attribute, ...(o.stratum ? [o.stratum] : [])]; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "os AS (\n" + - " SELECT " + pid + " AS pid,\n" + - " " + finiteSql("anyIf(attribute_value, attribute_name = " + sq(o.months) + ")") + " AS months,\n" + - " anyIf(attribute_value, attribute_name = " + sq(o.status) + ") AS status\n" + - " FROM " + clinicalTable(db, [o.months, o.status]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN (" + sq(o.months) + ", " + sq(o.status) + ")\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - "),\n" + - "val AS (\n" + - " SELECT " + pid + " AS pid,\n" + - " " + finiteSql("argMinIf(attribute_value, sample_unique_id, attribute_name = " + sq(o.attribute) + ")") + " AS v" + - (o.stratum ? ",\n argMinIf(trimBoth(attribute_value), sample_unique_id, attribute_name = " + sq(o.stratum) + ") AS s" : "") + "\n" + - " FROM " + clinicalTable(db, attrs) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + attrs.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - ")\n" + - "SELECT os.months AS m, if(os.status LIKE '1%', 1, 0) AS ev, val.v AS v" + (o.stratum ? ", val.s AS s" : "") + "\n" + - "FROM os LEFT JOIN val ON val.pid = os.pid\n" + - "WHERE os.months IS NOT NULL AND os.months >= 0" - ); -} -// hla: HLA class I genotype and HLA LOH per patient - -export interface HlaRowsOpts { - /** per locus A, B, C: the attributes that may hold its first allele, its second, or both as one "x/y" value */ - loci: { first: string[]; second: string[]; pair: string[] }[]; - /** allele-name fields compared (1, 2, or 99 for all) */ - fields: number; - lohAttributes: string[]; lohValues: string[]; - months: string; status: string; - covariates: string[]; -} - -/** an allele's name as its fields: hla_a_02_01_01_01, A*02:01 and A0201 all start ["02", "01"] */ -const hlaFieldsSql = (x: string) => { - const y = "replaceRegexpOne(lowerUTF8(" + x + "), '^(hla[-_]?)?[abc][*_]?', '')"; - return "if(match(" + y + ", '[_:]'), splitByRegexp('[_:]', " + y + "), [substring(" + y + ", 1, 2), substring(" + y + ", 3)])"; -}; - -/** - * One row per patient of the cohort: per locus whether its two alleles match at `fields` fields - * (hA, hB, hC; NULL when either allele is missing), the fewest fields any allele was typed at (nf), - * HLA LOH in any sample (loss; NULL when no sample has a call), the endpoint (m, ev) and each - * covariate as c0, c1, … (the patient's, or the first sample's). Values only: the patient id joins - * the CTEs and is never returned. Run only through PatientLevelResolver.viewRows. - */ -export function hlaRowsSql(cohortSql: string, study: string, db: string, o: HlaRowsOpts): string { - const names = [...o.loci.flatMap((l) => [...l.first, ...l.second, ...l.pair]), ...o.lohAttributes, o.months, o.status, ...o.covariates]; - for (const a of names) if (!/^[A-Za-z0-9_:/.-]{1,128}$/.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); - const pid = pidOf("patient_unique_id", study); - const inList = (xs: string[]) => xs.length ? "k IN (" + xs.map(sq).join(", ") + ")" : "0"; - const allele = (l: HlaRowsOpts["loci"][number], i: 1 | 2) => { - const own = "anyIf(v, " + inList(i === 1 ? l.first : l.second) + ")"; - return "if(" + own + " != '', " + own + ", splitByRegexp('\\\\s*[/,;]\\\\s*', anyIf(v, " + inList(l.pair) + "))[" + i + "])"; - }; - const genoAttrs = o.loci.flatMap((l) => [...l.first, ...l.second, ...l.pair]); - const fields = Math.max(1, Math.min(99, Math.floor(o.fields))); - const key = (x: string) => "arrayStringConcat(arraySlice(" + hlaFieldsSql(x) + ", 1, " + fields + "), ':')"; - const homAt = (i: number) => "if(k[" + (2 * i + 1) + "] = '' OR k[" + (2 * i + 2) + "] = '', NULL, toUInt8(k[" + (2 * i + 1) + "] = k[" + (2 * i + 2) + "]))"; - const covs = o.covariates; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "gt AS (\n" + - " SELECT pid, [" + o.loci.flatMap((l) => [allele(l, 1), allele(l, 2)]).join(",\n ") + "] AS raw\n" + - " FROM (SELECT " + pid + " AS pid, attribute_name AS k, trimBoth(attribute_value) AS v FROM " + clinicalTable(db, genoAttrs) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + genoAttrs.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort))\n" + - " GROUP BY pid\n" + - "),\n" + - "keys AS (\n" + - " SELECT pid, arrayMap(x -> if(x = '', '', " + key("x") + "), raw) AS k,\n" + - " arrayMin(arrayMap(x -> if(x = '', 99, length(" + hlaFieldsSql("x") + ")), raw)) AS nf0\n" + - " FROM gt\n" + - "),\n" + - "hom AS (SELECT pid, " + homAt(0) + " AS hA, " + homAt(1) + " AS hB, " + homAt(2) + " AS hC, toNullable(nf0) AS nf FROM keys),\n" + - "loh AS (\n" + - " SELECT " + pid + " AS pid, toNullable(max(" + clinicalValueFoldSql("attribute_value") + " IN (" + o.lohValues.map((v) => clinicalValueFoldSql(sq(v))).join(", ") + "))) AS loss\n" + - " FROM " + clinicalTable(db, o.lohAttributes) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + o.lohAttributes.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - "),\n" + - "os AS (\n" + - " SELECT " + pid + " AS pid,\n" + - " " + finiteSql("anyIf(attribute_value, attribute_name = " + sq(o.months) + ")") + " AS months,\n" + - " anyIf(attribute_value, attribute_name = " + sq(o.status) + ") AS status\n" + - " FROM " + clinicalTable(db, [o.months, o.status]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN (" + sq(o.months) + ", " + sq(o.status) + ")\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - ")" + - (covs.length - ? ",\ncov AS (\n" + - " SELECT " + pid + " AS pid" + covs.map((c, i) => ",\n toNullable(argMinIf(trimBoth(attribute_value), sample_unique_id, attribute_name = " + sq(c) + ")) AS c" + i).join("") + "\n" + - " FROM " + clinicalTable(db, covs) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + covs.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - ")\n" - : "\n") + - "SELECT hom.hA AS hA, hom.hB AS hB, hom.hC AS hC, hom.nf AS nf, loh.loss AS loss,\n" + - " if(os.months >= 0, os.months, NULL) AS m, if(os.status LIKE '1%', 1, 0) AS ev" + covs.map((_, i) => ", cov.c" + i + " AS c" + i).join("") + "\n" + - "FROM cohort LEFT JOIN hom ON hom.pid = cohort.pid LEFT JOIN loh ON loh.pid = cohort.pid LEFT JOIN os ON os.pid = cohort.pid" + - (covs.length ? " LEFT JOIN cov ON cov.pid = cohort.pid" : "") - ); -} - -/** the attribute names in a study that mention HLA, with how many values each holds */ -export function hlaAttributesSql(_cohortSql: string, study: string, db: string): string { - return "SELECT attribute_name AS name, any(type) AS type, count() AS n FROM " + clinicalTable(db, []) + "\n" + - "WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name ILIKE '%hla%' AND trimBoth(attribute_value) != ''\n" + - "GROUP BY name ORDER BY name LIMIT 40"; -} - -// adjusted: each gene's alteration between two cohorts, by logistic regression on covariates - -/** - * One row per patient of the cohort with a mutation panel: the panels their samples were sequenced - * on, the genes mutated in them (called mutations only; OncoKB drivers only with `driverPin`), and - * each covariate's value as c0, c1, … (the patient's, or the first sample's). Values only: the - * patient id joins the CTEs and is never returned. Run only through PatientLevelResolver.viewRows. - */ -export function adjustedRowsSql(cohortSql: string, study: string, db: string, o: { covariates: string[]; driverPin?: string | null }): string { - const { covariates, driverPin = null } = o; - for (const a of covariates) if (!/^[A-Za-z0-9_:/.-]{1,128}$/.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); - const pid = pidOf("patient_unique_id", study), sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study); - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "panels AS (\n" + - " SELECT " + sdPid + " AS pid, groupUniqArray(toString(sp.gene_panel_id)) AS panels\n" + - " FROM " + db + ".sample_to_gene_panel_derived sp\n" + - " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + - " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = 'MUTATION_EXTENDED'\n" + - " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - "),\n" + - "mut AS (\n" + - " SELECT " + gPid + " AS pid, groupUniqArray(g.hugo_gene_symbol) AS genes\n" + - " FROM " + db + ".genomic_event_derived g\n" + - (driverPin ? driverJoin(driverPin) : "") + - " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED'\n" + - (driverPin ? " AND ann.is_driver = 1\n" : "") + - " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - ")" + - (covariates.length ? - ",\nattrs AS (\n" + - " SELECT " + pid + " AS pid,\n" + - covariates.map((a, k) => " argMinIf(attribute_value, sample_unique_id, attribute_name = " + sq(a) + ") AS a" + k).join(",\n") + "\n" + - " FROM " + clinicalTable(db, covariates) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + covariates.map(sq).join(",") + ") AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - ")" : "") + "\n" + - "SELECT panels.panels AS panels, mut.genes AS genes" + covariates.map((_, k) => ", attrs.a" + k + " AS c" + k).join("") + "\n" + - "FROM panels\n" + - " LEFT JOIN mut ON mut.pid = panels.pid\n" + - (covariates.length ? " LEFT JOIN attrs ON attrs.pid = panels.pid\n" : "") - ); -} - -// --------------------------------------------------------------------------- -// actionability: each patient's highest OncoKB therapeutic level - -/** OncoKB's sensitive levels, best first, and the resistance levels; rank 9 is none */ -export const SENSITIVE_LEVELS = ["LEVEL_1", "LEVEL_2", "LEVEL_3A", "LEVEL_3B", "LEVEL_4"] as const; -export const RESISTANCE_LEVELS = ["LEVEL_R1", "LEVEL_R2"] as const; -const rankOf = (col: string, levels: readonly string[]) => "multiIf(" + levels.map((l, i) => col + " = " + sq(l) + ", " + (i + 1)).join(", ") + ", 9)"; - -/** the cohort's samples sequenced for mutations, and per sample its best sensitive and resistance - * rank over its called mutations and high-level copy-number calls (OncoKB at `pin`) */ -function actionCtes(cohortSql: string, study: string, db: string, pin: string): string { - const sdPid = pidOf("sd.patient_unique_id", study); - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "seq AS (\n" + - " SELECT DISTINCT sp.sample_unique_id AS sid, " + sdPid + " AS pid\n" + - " FROM " + db + ".sample_to_gene_panel_derived sp\n" + - " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + - " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = 'MUTATION_EXTENDED'\n" + - " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - "lv AS (\n" + - " SELECT g.sample_unique_id AS sid, min(" + rankOf("ann.highest_sensitive_level", SENSITIVE_LEVELS) + ") AS s,\n" + - " min(" + rankOf("ann.highest_resistance_level", RESISTANCE_LEVELS) + ") AS r\n" + - " FROM " + db + ".genomic_event_derived g\n" + - // the store's rows with a level at the pin only — a few thousand of its millions — matched as driverJoin matches - " INNER JOIN (SELECT hugo_gene_symbol, alteration_type, alteration, highest_sensitive_level, highest_resistance_level\n" + - " FROM oncokb_annotation.variant_annotation\n" + - " WHERE oncokb_version = " + sq(pin) + " AND (highest_sensitive_level != '' OR highest_resistance_level != '')) ann\n" + - " ON ann.hugo_gene_symbol = g.hugo_gene_symbol\n" + - " AND ((g.variant_type = 'mutation' AND ann.alteration_type = 'MUTATION' AND ann.alteration = g.mutation_variant)\n" + - " OR (g.variant_type = 'cna' AND ann.alteration_type = 'CNA' AND ann.alteration = if(g.cna_alteration >= 2, 'AMPLIFICATION', 'DELETION'))\n" + - " OR (g.variant_type = 'structural_variant' AND ann.alteration_type = 'SV' AND ann.alteration = 'Fusion'))\n" + - " WHERE g.cancer_study_identifier = " + sq(study) + "\n" + - " AND ((g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED') OR (g.variant_type = 'cna' AND abs(g.cna_alteration) >= 2) OR g.variant_type = 'structural_variant')\n" + - " AND g.sample_unique_id IN (SELECT sid FROM seq)\n" + - " GROUP BY sid\n" + - ")" - ); -} - -/** - * Per group (a clinical attribute's value per patient — "Multiple" when their samples disagree, - * "" when none is recorded — or one group without `groupBy`) and per pair of a patient's best - * sensitive and resistance ranks over every sequenced sample: the number of patients. Counts only. - */ -export function actionLevelsSql(cohortSql: string, study: string, db: string, pin: string, groupBy: string | null): string { - if (groupBy && !/^[A-Za-z0-9_:/.-]{1,128}$/.test(groupBy)) throw new Error("bad attribute name '" + String(groupBy).slice(0, 40) + "'"); - const pid = pidOf("patient_unique_id", study); - return ( - actionCtes(cohortSql, study, db, pin) + ",\n" + - "pl AS (\n" + - " SELECT seq.pid AS pid, min(if(lv.s = 0, 9, lv.s)) AS s, min(if(lv.r = 0, 9, lv.r)) AS r\n" + - " FROM seq LEFT JOIN lv ON lv.sid = seq.sid\n" + - " GROUP BY pid\n" + - ")" + - (groupBy ? - ",\ngrp AS (\n" + - " SELECT " + pid + " AS pid, if(uniqExact(attribute_value) > 1, 'Multiple', any(attribute_value)) AS g\n" + - " FROM " + clinicalTable(db, [groupBy]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(groupBy) + " AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - ")" : "") + "\n" + - "SELECT " + (groupBy ? "grp.g" : "''") + " AS grp, pl.s AS level, pl.r AS resistance, count() AS n\n" + - "FROM pl" + (groupBy ? " LEFT JOIN grp ON grp.pid = pl.pid" : "") + "\n" + - "GROUP BY grp, level, resistance" - ); -} - -/** - * Patients with a sequenced sample on each side of a sample attribute (e.g. SAMPLE_TYPE Primary - * against Metastasis, values matched case-insensitively): per pair of their best sensitive rank - * on side a and on side b, the number of patients. Counts only. - */ -export function actionConcordanceSql(cohortSql: string, study: string, db: string, pin: string, by: { attribute: string; a: string[]; b: string[] }): string { - if (!/^[A-Za-z0-9_:/.-]{1,128}$/.test(by.attribute)) throw new Error("bad attribute name '" + String(by.attribute).slice(0, 40) + "'"); - const side = (vals: string[]) => "lower(trimBoth(attribute_value)) IN (" + vals.map((v) => sq(String(v).trim().toLowerCase())).join(", ") + ")"; - return ( - actionCtes(cohortSql, study, db, pin) + ",\n" + - "st AS (\n" + - " SELECT sample_unique_id AS sid, multiIf(" + side(by.a) + ", 'a', " + side(by.b) + ", 'b', '') AS side\n" + - " FROM " + clinicalTable(db, [by.attribute]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(by.attribute) + "\n" + - " AND sample_unique_id IN (SELECT sid FROM seq)\n" + - "),\n" + - "ps AS (\n" + - " SELECT seq.pid AS pid, minIf(if(lv.s = 0, 9, lv.s), st.side = 'a') AS sa, minIf(if(lv.s = 0, 9, lv.s), st.side = 'b') AS sb,\n" + - " countIf(st.side = 'a') AS na, countIf(st.side = 'b') AS nb\n" + - " FROM seq INNER JOIN st ON st.sid = seq.sid LEFT JOIN lv ON lv.sid = seq.sid\n" + - " GROUP BY pid\n" + - " HAVING na > 0 AND nb > 0\n" + - ")\n" + - "SELECT sa AS a, sb AS b, count() AS n FROM ps GROUP BY a, b" - ); -} - -/** The alteration types the store holds a therapeutic level for at `pin` (reference data, no cohort). */ -export function actionStoreTypesSql(_study: string, _db: string, pin: string): string { - return "SELECT toString(alteration_type) AS type, count() AS n FROM oncokb_annotation.variant_annotation\n" + - "WHERE oncokb_version = " + sq(pin) + " AND (highest_sensitive_level != '' OR highest_resistance_level != '')\nGROUP BY type"; -} - // --------------------------------------------------------------------------- // drivernegative: patients without a driver in a canonical list, against those with one /** a gene list as SQL literals, checked */ -function geneList(genes: string[]): string { +export function geneList(genes: string[]): string { for (const g of genes) if (!/^[A-Za-z0-9_.-]{1,40}$/.test(g)) throw new Error("bad gene symbol '" + String(g).slice(0, 40) + "'"); return genes.map(sq).join(", "); } -/** - * Each sequenced patient's group: "positive" with an OncoKB driver (mutation, high-level copy - * number or structural variant) in one of `drivers`; else "negative" when a mutation panel of - * theirs covers every listed gene (or whole-exome); else "unprofiled". With `type`, also `pp`: - * each patient's panels for that alteration type and whether one is whole-exome. - */ -function dnegCtes(cohortSql: string, study: string, db: string, pin: string, drivers: string[], type: EnrichAlteration): string { - const sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study), list = geneList(drivers); - const panelsOf = (name: string, alterationType: string) => name + " AS (\n" + - " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + - " FROM " + db + ".sample_to_gene_panel_derived sp\n" + - " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + - " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(alterationType) + "\n" + - " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + - ")"; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - panelsOf("mp", "MUTATION_EXTENDED") + ",\n" + - panelsOf("pp", ENRICH_PANEL_TYPE[type]) + ",\n" + - "cov AS (\n" + - " SELECT mp.pid AS pid, max(mp.panel = 'WES') AS wes, uniqExactIf(gp.gene, gp.gene IN (" + list + ")) AS ng\n" + - " FROM mp LEFT JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = mp.panel\n" + - " GROUP BY pid\n" + - "),\n" + - "drv AS (\n" + - " SELECT DISTINCT " + gPid + " AS pid\n" + - " FROM " + db + ".genomic_event_derived g\n" + driverJoin(pin) + - " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.hugo_gene_symbol IN (" + list + ") AND ann.is_driver\n" + - " AND ((g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED') OR (g.variant_type = 'cna' AND abs(g.cna_alteration) >= 2) OR g.variant_type = 'structural_variant')\n" + - " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - "grp AS (\n" + - " SELECT pid, multiIf(pid IN (SELECT pid FROM drv), 'positive', wes = 1 OR ng = " + new Set(drivers).size + ", 'negative', 'unprofiled') AS g FROM cov\n" + - ")" - ); -} - -/** Patients per group (positive, negative, unprofiled), of the cohort's patients sequenced for mutations. Counts only. */ -export function dnegGroupsSql(cohortSql: string, study: string, db: string, pin: string, drivers: string[]): string { - return dnegCtes(cohortSql, study, db, pin, drivers, "mutation") + "\nSELECT g AS grp, count() AS n FROM grp GROUP BY grp"; -} - -/** Per gene outside `drivers` and per group (positive, negative): patients with that alteration in it - * (OncoKB drivers only with `driverOnly`). Counts only. */ -export function dnegAlteredSql(cohortSql: string, study: string, db: string, pin: string, drivers: string[], alteration: EnrichAlteration, driverOnly: boolean): string { - const gPid = pidOf("g.patient_unique_id", study); - return ( - dnegCtes(cohortSql, study, db, pin, drivers, alteration) + "\n" + - "SELECT g.hugo_gene_symbol AS gene, grp.g AS grp, uniqExact(" + gPid + ") AS altered\n" + - "FROM " + db + ".genomic_event_derived g\n" + (driverOnly ? driverJoin(pin) : "") + - " INNER JOIN grp ON grp.pid = " + gPid + "\n" + - "WHERE g.cancer_study_identifier = " + sq(study) + " AND " + enrichEventCond(alteration) + "\n" + - " AND g.hugo_gene_symbol NOT IN (" + geneList(drivers) + ") AND grp.g IN ('positive', 'negative')" + (driverOnly ? " AND ann.is_driver" : "") + "\n" + - "GROUP BY gene, grp" - ); -} - -/** Per gene and group: patients not on whole-exome with a panel covering the gene for that alteration - * type; gene '' carries each group's whole-exome patients, profiled for every gene. Counts only. */ -export function dnegProfiledSql(cohortSql: string, study: string, db: string, pin: string, drivers: string[], alteration: EnrichAlteration): string { - return ( - dnegCtes(cohortSql, study, db, pin, drivers, alteration) + ",\n" + - "pw AS (SELECT pid, max(panel = 'WES') AS wes FROM pp GROUP BY pid)\n" + - "SELECT if(pw.wes = 1, '', gp.gene) AS gene, grp.g AS grp, uniqExact(pp.pid) AS profiled\n" + - "FROM pp INNER JOIN pw ON pw.pid = pp.pid INNER JOIN grp ON grp.pid = pp.pid\n" + - " LEFT JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = pp.panel\n" + - "WHERE grp.g IN ('positive', 'negative')\n" + - "GROUP BY gene, grp" - ); -} - -// --------------------------------------------------------------------------- -// organotropism: which genes go with metastasis to which organ - -export type OrgSourceKind = "flags" | "timeline" | "sample"; -export interface OrgSource { - kind: OrgSourceKind; - /** flags: the per-organ attributes' common prefix (e.g. DMETS_DX_), valued Yes / No */ - prefix?: string; - /** timeline: the event type and the key whose value names the site */ - eventType?: string; key?: string; - /** sample: the attribute naming a metastasis sample's biopsy site, mapped through ORG_SITE_MAP */ - attribute?: string; -} - -/** - * The organ a free-text biopsy site is counted under: the first rule whose pattern matches the - * lower-cased value; anything else is "Other". The rules are ours, written against the public - * METASTATIC_SITE vocabularies (msk_met_2021, msk_chord_2024, msk_impact_50k_2026), and every - * result lists the values that fell to "Other". - */ -const ORG_SITE_MAP: [string, string][] = [ - ["Lymph node", "lymph|\\bln\\b|node|axilla"], - ["CNS/Brain", "brain|cereb|csf|epidural|dura\\b|dural|mening|spinal cord|cns"], - ["Liver", "liver|hepat"], - ["Lung", "^lung|pulmon|\\blung\\b"], - ["Pleura", "pleura"], - ["Bone", "bone|spine|vertebr|\\brib\\b|femur|iliac|sacr|stern|scapula|humer|skull|acetab|clavic|tibia|ischi|pubic|calvari"], - ["Adrenal gland", "adrenal"], - ["Peritoneum / abdomen", "periton|omentum|mesenter|abdom|diaphragm|intra-abdominal|ascites"], - ["Bowel / GI tract", "bowel|colon|rect|intestin|duoden|ileum|jejun|append|stomach|gastr|esophag|biliary|gallbladder|pancrea"], - ["Skin / soft tissue", "skin|scalp|soft tissue|chest wall|subcut|muscle|\\bback\\b|paraspinal|breast"], - ["Genital organs", "ovar|uter|vagin|adnex|endomet|cervix|fallop|genital|testi|prostat|vulva"], - ["Kidney / urinary tract", "kidney|renal|bladder|ureter|urethr|\\but\\b"], - ["Head and neck", "head|neck|thyroid|parotid|oral|tongue|larynx|orbit"], - ["Mediastinum / heart", "mediastin|pericard|heart"], -]; - -/** A biopsy-site value's organ, in SQL (ORG_SITE_MAP's rules over the lower-cased, trimmed value). */ -function orgSiteMapSql(expr: string): string { - const v = "lowerUTF8(trimBoth(" + expr + "))"; - return "multiIf(" + ORG_SITE_MAP.map(([organ, re]) => "match(" + v + ", " + sq(re) + "), " + sq(organ)).join(", ") + ", 'Other')"; -} - -const orgAttr = (a: string) => { if (!VIEW_ATTR_RE.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); return a; }; -const ORG_YES = "('yes', 'y', 'true', '1')", ORG_NO = "('no', 'n', 'false', '0')"; - -/** - * `ps`: one row per (patient, site) the source can speak to, `has` 1 when the patient has a - * metastasis there; `sl`: each site's label. Flags: a patient is evaluable for a site when its - * flag is recorded Yes or No. Timeline: a patient with any site recorded is evaluable for every - * site seen. Sample: every cohort patient is evaluable; a site is one a sample was taken from. - */ -function orgSiteCtes(study: string, db: string, src: OrgSource): string { - const pid = pidOf("patient_unique_id", study); - if (src.kind === "flags") { - const prefix = orgAttr(src.prefix || "DMETS_DX_"); - return ( - "fl AS (\n" + - " SELECT " + pid + " AS pid, substring(attribute_name, " + (prefix.length + 1) + ") AS site,\n" + - " max(" + clinicalValueFoldSql("attribute_value") + " IN " + ORG_YES + ") AS has,\n" + - " max(" + clinicalValueFoldSql("attribute_value") + " IN " + ORG_YES + " OR " + clinicalValueFoldSql("attribute_value") + " IN " + ORG_NO + ") AS known\n" + - " FROM " + clinicalTable(db, []) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND startsWith(attribute_name, " + sq(prefix) + ") AND length(attribute_name) > " + prefix.length + "\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid, site\n" + - "),\n" + - "ps AS (SELECT pid, site, toUInt8(has) AS has FROM fl WHERE known),\n" + - "sl AS (SELECT DISTINCT site, site AS label FROM ps)" - ); - } - if (src.kind === "timeline") { - return ( - "evr AS (\n" + - " SELECT DISTINCT p.stable_id AS pid, " + nameFoldSql("d.value") + " AS site, trimBoth(d.value) AS raw\n" + - " FROM " + db + ".clinical_event ce\n" + - " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id\n" + - " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + - " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + - " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", src.eventType || "Diagnosis") + "\n" + - " AND " + foldedEqualsSql("d.key", src.key || "TUMOR_SITE") + " AND trimBoth(d.value) != ''\n" + - " AND p.stable_id IN (SELECT pid FROM cohort)\n" + - "),\n" + - "ev AS (SELECT DISTINCT pid, site FROM evr),\n" + - "sl AS (SELECT site, min(raw) AS label FROM evr GROUP BY site),\n" + - "ps AS (\n" + - " SELECT e.pid AS pid, s.site AS site, toUInt8((e.pid, s.site) IN (SELECT pid, site FROM ev)) AS has\n" + - " FROM (SELECT DISTINCT pid FROM ev) e CROSS JOIN (SELECT site FROM sl) s\n" + - ")" - ); - } - const attr = orgAttr(src.attribute || "METASTATIC_SITE"); - return ( - "sm AS (\n" + - " SELECT DISTINCT " + pid + " AS pid, " + orgSiteMapSql("attribute_value") + " AS site\n" + - " FROM " + clinicalTable(db, [attr]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(attr) + " AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - "sl AS (SELECT DISTINCT site, site AS label FROM sm),\n" + - "ps AS (\n" + - " SELECT c.pid AS pid, s.site AS site, toUInt8((c.pid, s.site) IN (SELECT pid, site FROM sm)) AS has\n" + - " FROM (SELECT DISTINCT pid FROM cohort) c CROSS JOIN (SELECT site FROM sl) s\n" + - ")" - ); -} - -/** Which site sources the cohort's patients carry: patients with a flag attribute (and how many - * flags), with a site on the timeline, and with a biopsy-site value. Counts only. */ -export function orgSourcesSql(cohortSql: string, study: string, db: string, o: { prefix: string; eventType: string; key: string; attribute: string }): string { - const pid = pidOf("patient_unique_id", study), prefix = orgAttr(o.prefix), attr = orgAttr(o.attribute); - return ( - "WITH cohort AS (" + cohortSql + ")\n" + - "SELECT\n" + - " (SELECT uniqExact(" + pid + ") FROM " + clinicalTable(db, []) + " WHERE cancer_study_identifier = " + sq(study) + - " AND startsWith(attribute_name, " + sq(prefix) + ") AND " + pid + " IN (SELECT pid FROM cohort)) AS flag_patients,\n" + - " (SELECT uniqExact(attribute_name) FROM " + clinicalTable(db, []) + " WHERE cancer_study_identifier = " + sq(study) + - " AND startsWith(attribute_name, " + sq(prefix) + ") AND " + pid + " IN (SELECT pid FROM cohort)) AS flags,\n" + - " (SELECT uniqExact(p.stable_id) FROM " + db + ".clinical_event ce\n" + - " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id\n" + - " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + - " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + - " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", o.eventType) + " AND " + foldedEqualsSql("d.key", o.key) + - " AND trimBoth(d.value) != '' AND p.stable_id IN (SELECT pid FROM cohort)) AS timeline_patients,\n" + - " (SELECT uniqExact(" + pid + ") FROM " + clinicalTable(db, [attr]) + " WHERE cancer_study_identifier = " + sq(study) + - " AND attribute_name = " + sq(attr) + " AND trimBoth(attribute_value) != '' AND " + pid + " IN (SELECT pid FROM cohort)) AS sample_patients\n" + - "FROM system.one" - ); -} - -/** Per site: patients evaluable for it and those with a metastasis there. Counts only. */ -export function orgSitesSql(cohortSql: string, study: string, db: string, src: OrgSource): string { - return ( - "WITH cohort AS (" + cohortSql + "),\n" + orgSiteCtes(study, db, src) + "\n" + - "SELECT ps.site AS site, any(sl.label) AS label, count() AS evaluable, sum(ps.has) AS with_site\n" + - "FROM ps INNER JOIN sl ON sl.site = ps.site GROUP BY site" - ); -} - -/** Sample source: the biopsy-site values that ORG_SITE_MAP sends to "Other", with their patients. Counts only. */ -export function orgOtherValuesSql(cohortSql: string, study: string, db: string, attribute: string): string { - const pid = pidOf("patient_unique_id", study), attr = orgAttr(attribute); - return ( - "WITH cohort AS (" + cohortSql + ")\n" + - "SELECT trimBoth(attribute_value) AS value, uniqExact(" + pid + ") AS patients\n" + - "FROM " + clinicalTable(db, [attr]) + "\n" + - "WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(attr) + " AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort) AND " + orgSiteMapSql("attribute_value") + " = 'Other'\n" + - "GROUP BY value ORDER BY patients DESC, value LIMIT 30" - ); -} - -export interface OrgGenes { genes: string[] | null; alteration: EnrichAlteration; driverPin: string | null } - -/** `prof` (patient, gene) for every listed gene a patient's panel covers for the alteration type (whole-exome - * covers all); `alt` the same pairs with the alteration called on a covering panel (OncoKB drivers only with - * driverPin). Without a gene list, `alt` holds every gene. Both read `cohort`. */ -function orgGeneCtes(study: string, db: string, g: OrgGenes): string { - const sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study); - const list = g.genes ? geneList(g.genes) : null; - return ( - "pp AS (\n" + - " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + - " FROM " + db + ".sample_to_gene_panel_derived sp\n" + - " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + - " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(ENRICH_PANEL_TYPE[g.alteration]) + "\n" + - " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - (list - ? "prof AS (\n" + - " SELECT DISTINCT pp.pid AS pid, gp.gene AS gene FROM pp JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = pp.panel WHERE gp.gene IN (" + list + ")\n" + - " UNION DISTINCT\n" + - " SELECT pid, arrayJoin([" + list + "]) AS gene FROM pp WHERE panel = 'WES'\n" + - "),\n" - : "") + - "alt AS (\n" + - " SELECT DISTINCT " + gPid + " AS pid, g.hugo_gene_symbol AS gene\n" + - " FROM " + db + ".genomic_event_derived g\n" + (g.driverPin ? driverJoin(g.driverPin) : "") + - " WHERE g.cancer_study_identifier = " + sq(study) + " AND " + enrichEventCond(g.alteration) + " AND NOT g.off_panel\n" + - (list ? " AND g.hugo_gene_symbol IN (" + list + ")\n" : "") + (g.driverPin ? " AND ann.is_driver\n" : "") + - " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + - ")" - ); -} - -/** The cohort's most altered genes (patients with the alteration on a covering panel), at most `limit`. Counts only. */ -export function orgTopGenesSql(cohortSql: string, study: string, db: string, g: Omit, limit: number): string { - return ( - "WITH cohort AS (" + cohortSql + "),\n" + orgGeneCtes(study, db, { ...g, genes: null }) + "\n" + - "SELECT gene, count() AS altered FROM alt GROUP BY gene ORDER BY altered DESC, gene LIMIT " + Math.max(1, Math.min(200, Math.floor(limit))) - ); -} - -/** - * Per (site, gene, stratum): the patients evaluable for the site AND profiled for the gene, those - * of them with a metastasis there, those altered, and those both — the four margins of one 2×2 - * per stratum (a patient's `stratifyBy` value; "Multiple" when their samples disagree, '' when none - * is recorded). Counts only. - */ -export function orgCountsSql(cohortSql: string, study: string, db: string, src: OrgSource, g: OrgGenes & { genes: string[] }, stratifyBy: string | null): string { - const pid = pidOf("patient_unique_id", study); - const strat = stratifyBy ? orgAttr(stratifyBy) : null; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + orgSiteCtes(study, db, src) + ",\n" + orgGeneCtes(study, db, g) + ",\n" + - "st AS (\n" + - (strat - ? " SELECT " + pid + " AS pid, if(uniqExact(" + clinicalValueFoldSql("attribute_value") + ") > 1, 'Multiple', any(trimBoth(attribute_value))) AS s\n" + - " FROM " + clinicalTable(db, [strat]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(strat) + " AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" - : " SELECT pid, '' AS s FROM cohort\n") + - ")\n" + - "SELECT ps.site AS site, prof.gene AS gene, st.s AS stratum, count() AS n, sum(ps.has) AS with_site,\n" + - " countIf(alt.pid != '') AS altered, countIf(ps.has = 1 AND alt.pid != '') AS both\n" + - "FROM ps INNER JOIN prof ON prof.pid = ps.pid\n" + - " LEFT JOIN alt ON alt.pid = ps.pid AND alt.gene = prof.gene\n" + - " LEFT JOIN st ON st.pid = ps.pid\n" + - "GROUP BY site, gene, stratum" - ); -} - -/** - * Cumulative incidence of the first timeline site event (src's event type, key = `siteValue`) with - * death competing, for the cohort's patients profiled for `gene` and altered in it (`altered`) or - * not: incidenceCountsSql over that half of the cohort. Counts only. - */ -export function orgIncidenceSql(cohortSql: string, study: string, db: string, src: OrgSource, g: OrgGenes & { genes: [string] }, altered: boolean, siteValue: string): string { - const half = "SELECT pid FROM (" + cohortSql + ") WHERE pid IN (\n WITH cohort AS (" + cohortSql + "),\n" + orgGeneCtes(study, db, g) + "\n" + - " SELECT pid FROM prof)\n AND pid " + (altered ? "" : "NOT ") + "IN (\n WITH cohort AS (" + cohortSql + "),\n" + orgGeneCtes(study, db, g) + "\n SELECT pid FROM alt)"; - return incidenceCountsSql(half, study, db, { eventType: src.eventType || "Diagnosis", attrs: [{ key: src.key || "TUMOR_SITE", value: siteValue }] }, null); -} - -// --------------------------------------------------------------------------- -// molsubtype: a published molecular classifier applied per patient - -export interface MolMsiRule { - attribute: string; - /** a categorical MSI call: the values meaning MSI-high and those meaning stable (case-insensitive) */ - high?: string[]; stable?: string[]; - /** a numeric score: MSI-high at or above `threshold`, stable below `stableBelow`, in between indeterminate */ - threshold?: number; stableBelow?: number; -} -export interface MolEndometrialOpts { - /** POLE exonuclease-domain hotspots (protein changes) */ - poleHotspots: string[]; - msi: MolMsiRule; - /** an abnormal p53: an OncoKB-driver TP53 mutation at this pin, or (null) any non-synonymous TP53 mutation */ - p53Pin: string | null; - /** a patient attribute to cross the subtypes with */ - by: string | null; -} - -/** TP53 mutation classes counted as abnormal without OncoKB: protein-changing calls */ -const MOL_TP53_CLASSES = ["Missense_Mutation", "Nonsense_Mutation", "Frame_Shift_Del", "Frame_Shift_Ins", "Splice_Site", "In_Frame_Del", "In_Frame_Ins", "Nonstop_Mutation", "Translation_Start_Site", "Splice_Region"]; - -/** - * `cls`: each cohort patient's endometrial molecular class — the TCGA / ProMisE / WHO 2020 hierarchy, - * first match wins: 'POLEmut' (a POLE hotspot called in any sample), 'MMRd' (MSI-high in any sample), - * 'p53abn' (an abnormal TP53 mutation), 'NSMP' (none of these) — and 'NC' (not classifiable) with - * `why` when an input the hierarchy needs before deciding is missing: not sequenced, not profiled for - * POLE or TP53 (panel-aware), or no MSI value. Also `grp`, the `by` attribute per patient. - */ -function molEndometrialCtes(cohortSql: string, study: string, db: string, o: MolEndometrialOpts): string { - const sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study), pid = pidOf("patient_unique_id", study); - const attr = orgAttr(o.msi.attribute); - const hot = o.poleHotspots.map((h) => { if (!/^[A-Z][0-9]{1,5}[A-Z*]$/.test(h)) throw new Error("bad POLE hotspot '" + String(h).slice(0, 20) + "'"); return sq(h); }).join(", "); - const v = clinicalValueFoldSql("attribute_value"); - const msiState = o.msi.threshold != null - ? "multiIf(" + finiteSql("trimBoth(attribute_value)") + " IS NULL OR " + finiteSql("trimBoth(attribute_value)") + " < 0, 0, " + finiteSql("trimBoth(attribute_value)") + " >= " + Number(o.msi.threshold) + - ", 2, " + finiteSql("trimBoth(attribute_value)") + " < " + Number(o.msi.stableBelow ?? o.msi.threshold) + ", 1, 0)" - : "multiIf(" + v + " IN (" + (o.msi.high || []).map((x) => sq(x.trim().toLowerCase())).join(", ") + "), 2, " + v + " IN (" + (o.msi.stable || []).map((x) => sq(x.trim().toLowerCase())).join(", ") + "), 1, 0)"; - const by = o.by ? orgAttr(o.by) : null; - return ( - "WITH cohort AS (" + cohortSql + "),\n" + - "pp AS (\n" + - " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + - " FROM " + db + ".sample_to_gene_panel_derived sp\n" + - " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + - " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = 'MUTATION_EXTENDED' AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - "cov AS (\n" + - " SELECT pp.pid AS pid, max(pp.panel = 'WES' OR gp.gene = 'POLE') AS pole, max(pp.panel = 'WES' OR gp.gene = 'TP53') AS tp53\n" + - " FROM pp LEFT JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = pp.panel AND gp.gene IN ('POLE', 'TP53')\n" + - " GROUP BY pid\n" + - "),\n" + - "mut AS (\n" + - " SELECT " + gPid + " AS pid,\n" + - " max(g.hugo_gene_symbol = 'POLE' AND g.mutation_variant IN (" + hot + ")) AS pole_hot,\n" + - " max(g.hugo_gene_symbol = 'TP53' AND " + (o.p53Pin ? "ann.is_driver" : "g.mutation_type IN (" + MOL_TP53_CLASSES.map(sq).join(", ") + ")") + ") AS p53\n" + - " FROM " + db + ".genomic_event_derived g\n" + (o.p53Pin ? driverJoin(o.p53Pin) : "") + - " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED'\n" + - " AND lowerUTF8(g.mutation_status) != 'germline' AND NOT g.off_panel AND g.hugo_gene_symbol IN ('POLE', 'TP53')\n" + - " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - "),\n" + - "msi AS (\n" + - " SELECT " + pid + " AS pid, max(" + msiState + ") AS m\n" + - " FROM " + clinicalTable(db, [attr]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(attr) + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - "),\n" + - "cls AS (\n" + - " SELECT c.pid AS pid,\n" + - " multiIf(cov.pid = '', 'NC', mut.pole_hot = 1, 'POLEmut', cov.pole = 0, 'NC', msi.m = 2, 'MMRd', msi.m = 0, 'NC', cov.tp53 = 0, 'NC', mut.p53 = 1, 'p53abn', 'NSMP') AS cls,\n" + - " multiIf(cov.pid = '', 'not sequenced', mut.pole_hot = 1, '', cov.pole = 0, 'not profiled for POLE', msi.m = 2, '', msi.m = 0, 'no MSI call', cov.tp53 = 0, 'not profiled for TP53', '') AS why\n" + - " FROM (SELECT DISTINCT pid FROM cohort) c\n" + - " LEFT JOIN cov ON cov.pid = c.pid LEFT JOIN mut ON mut.pid = c.pid LEFT JOIN msi ON msi.pid = c.pid\n" + - "),\n" + - "grp AS (\n" + - (by - ? " SELECT " + pid + " AS pid, if(uniqExact(" + v + ") > 1, 'Multiple', any(trimBoth(attribute_value))) AS g\n" + - " FROM " + clinicalTable(db, [by]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(by) + " AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" - : " SELECT pid, '' AS g FROM cohort\n") + - ")" - ); -} - -/** Patients per (class, why, group). Counts only. */ -export function molCountsSql(cohortSql: string, study: string, db: string, o: MolEndometrialOpts): string { - return molEndometrialCtes(cohortSql, study, db, o) + "\n" + - "SELECT cls.cls AS cls, cls.why AS why, grp.g AS grp, count() AS n FROM cls LEFT JOIN grp ON grp.pid = cls.pid GROUP BY cls, why, grp"; -} - -/** Overall survival per class: counts of (class, months, dead) from OS_MONTHS / OS_STATUS. Counts only. */ -export function molSurvivalSql(cohortSql: string, study: string, db: string, o: MolEndometrialOpts): string { - const pid = pidOf("patient_unique_id", study); - return molEndometrialCtes(cohortSql, study, db, o) + ",\n" + - "os AS (\n" + - " SELECT " + pid + " AS pid, " + finiteSql("anyIf(attribute_value, attribute_name = 'OS_MONTHS')") + " AS months,\n" + - " anyIf(attribute_value, attribute_name = 'OS_STATUS') LIKE '1%' AS dead\n" + - " FROM " + clinicalTable(db, ["OS_MONTHS", "OS_STATUS"]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN ('OS_MONTHS', 'OS_STATUS')\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - ")\n" + - "SELECT cls.cls AS cls, round(os.months, 2) AS t, os.dead AS dead, count() AS n\n" + - "FROM cls INNER JOIN os ON os.pid = cls.pid WHERE os.months IS NOT NULL AND os.months >= 0 AND cls.cls != 'NC' GROUP BY cls, t, dead ORDER BY t"; -} - -/** Which MSI attributes the cohort's samples carry, with how many values and patients. Counts only. */ -export function molMsiAttributesSql(cohortSql: string, study: string, db: string): string { - const pid = pidOf("patient_unique_id", study); - return "WITH cohort AS (" + cohortSql + ")\n" + - "SELECT attribute_name AS attribute, uniqExact(" + pid + ") AS patients, uniqExact(attribute_value) AS nvalues, arraySort(groupUniqArray(8)(attribute_value)) AS examples\n" + - "FROM " + clinicalTable(db, []) + "\n" + - "WHERE cancer_study_identifier = " + sq(study) + " AND positionCaseInsensitive(attribute_name, 'MSI') > 0 AND trimBoth(attribute_value) != ''\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - "GROUP BY attribute ORDER BY patients DESC"; -} - -// --------------------------------------------------------------------------- -// germline: carriers of a germline variant and their somatic second hits - -const GERMLINE = "lowerUTF8(trimBoth(g.mutation_status)) = 'germline'"; - -/** Per gene: the cohort's patients with a germline call in it. Counts only. */ -export function germCarriersSql(cohortSql: string, study: string, db: string): string { - const gPid = pidOf("g.patient_unique_id", study); - return "WITH cohort AS (" + cohortSql + ")\n" + - "SELECT g.hugo_gene_symbol AS gene, uniqExact(" + gPid + ") AS carriers\n" + - "FROM " + db + ".genomic_event_derived g\n" + - "WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation' AND " + GERMLINE + "\n" + - " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + - "GROUP BY gene ORDER BY carriers DESC, gene LIMIT 500"; -} - -/** (patient, gene) pairs a panel of theirs covers for one alteration type (whole-exome covers all), over `genes`. */ -function germProfiledCte(name: string, study: string, db: string, alterationType: string, list: string): string { - const sdPid = pidOf("sd.patient_unique_id", study); - return name + "p AS (\n" + - " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + - " FROM " + db + ".sample_to_gene_panel_derived sp JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + - " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(alterationType) + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + - "),\n" + - name + " AS (\n" + - " SELECT DISTINCT x.pid AS pid, gp.gene AS gene FROM " + name + "p x JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = x.panel WHERE gp.gene IN (" + list + ")\n" + - " UNION DISTINCT\n" + - " SELECT pid, arrayJoin([" + list + "]) AS gene FROM " + name + "p WHERE panel = 'WES'\n" + - ")"; -} - -function germCtes(cohortSql: string, study: string, db: string, genes: string[]): string { - const gPid = pidOf("g.patient_unique_id", study), list = geneList(genes); - const calls = (name: string, cond: string) => name + " AS (\n" + - " SELECT DISTINCT " + gPid + " AS pid, g.hugo_gene_symbol AS gene FROM " + db + ".genomic_event_derived g\n" + - " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.hugo_gene_symbol IN (" + list + ") AND " + cond + "\n" + - " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + - ")"; - return "WITH cohort AS (" + cohortSql + "),\n" + - germProfiledCte("mprof", study, db, "MUTATION_EXTENDED", list) + ",\n" + - germProfiledCte("cprof", study, db, "COPY_NUMBER_ALTERATION", list) + ",\n" + - calls("germ", "g.variant_type = 'mutation' AND " + GERMLINE) + ",\n" + - calls("som", "g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED' AND NOT (" + GERMLINE + ") AND NOT g.off_panel") + ",\n" + - calls("del", "g.variant_type = 'cna' AND g.cna_alteration <= -2 AND NOT g.off_panel") + ",\n" + - "base AS (SELECT pid, gene FROM mprof UNION DISTINCT SELECT pid, gene FROM germ)"; -} - -/** - * Per gene and combination of: profiled for mutations (mp), a germline call (carrier), a somatic - * mutation (som, any call not germline and not uncalled, on a covering panel), profiled for copy number - * (cp) and a deep deletion (del): the number of patients. Carriers whose tumour panel does not list the - * gene appear with mp = 0. Counts only. - */ -export function germCountsSql(cohortSql: string, study: string, db: string, genes: string[]): string { - return germCtes(cohortSql, study, db, genes) + ",\n" + - "fl AS (\n" + - " SELECT gene, toUInt8((pid, gene) IN (SELECT pid, gene FROM mprof)) AS mp, toUInt8((pid, gene) IN (SELECT pid, gene FROM germ)) AS carrier,\n" + - " toUInt8((pid, gene) IN (SELECT pid, gene FROM som)) AS som, toUInt8((pid, gene) IN (SELECT pid, gene FROM cprof)) AS cp,\n" + - " toUInt8((pid, gene) IN (SELECT pid, gene FROM del)) AS del\n" + - " FROM base\n" + - ")\n" + - "SELECT gene, mp, carrier, som, cp, del, count() AS n FROM fl GROUP BY gene, mp, carrier, som, cp, del"; -} - -/** Overall survival of the patients profiled for `gene`, by carrier status: counts of (carrier, months, dead). Counts only. */ -export function germSurvivalSql(cohortSql: string, study: string, db: string, gene: string): string { - const pid = pidOf("patient_unique_id", study); - return germCtes(cohortSql, study, db, [gene]) + ",\n" + - "os AS (\n" + - " SELECT " + pid + " AS pid, " + finiteSql("anyIf(attribute_value, attribute_name = 'OS_MONTHS')") + " AS months,\n" + - " anyIf(attribute_value, attribute_name = 'OS_STATUS') LIKE '1%' AS dead\n" + - " FROM " + clinicalTable(db, ["OS_MONTHS", "OS_STATUS"]) + "\n" + - " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN ('OS_MONTHS', 'OS_STATUS')\n" + - " AND " + pid + " IN (SELECT pid FROM cohort)\n" + - " GROUP BY pid\n" + - "),\n" + - "sv AS (\n" + - " SELECT toUInt8(mprof.pid IN (SELECT pid FROM germ)) AS carrier, os.months AS months, os.dead AS dead\n" + - " FROM mprof INNER JOIN os ON os.pid = mprof.pid WHERE os.months IS NOT NULL AND os.months >= 0\n" + - ")\n" + - "SELECT carrier, round(months, 2) AS t, dead, count() AS n FROM sv GROUP BY carrier, t, dead ORDER BY t"; -} +export const orgAttr = (a: string) => { if (!VIEW_ATTR_RE.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); return a; }; diff --git a/lib/federation.ts b/lib/federation.ts index c1ac377..f32385d 100644 --- a/lib/federation.ts +++ b/lib/federation.ts @@ -1780,7 +1780,7 @@ abstract class BaseResolver implements AggregateCapable { ...(spec.colorBy ? { colorLabel: axisLabel(spec.colorBy), legend: legendFor(violin.map((v) => v.c || "")) } : {}) }); } - // ---- a federated plot: one pass per site, pooled by lib/views/plot.ts ------- + // ---- a federated plot: one pass per site, pooled by lib/views/plot/run.ts ------- // // plot() above answers a single study end to end. A cohort spanning several studies cannot // just call plot() per site and stack the results — a scatter's bin grid is scaled to @@ -1813,7 +1813,7 @@ abstract class BaseResolver implements AggregateCapable { unit: spec.unit === "sample" ? "sample" as const : "patient" as const, cohortSql: this.cohortSql(def), ...this.oqlCtx() }; } - /** This site's cases, and how many carry each axis — summed across sites by lib/views/plot.ts. */ + /** This site's cases, and how many carry each axis — summed across sites by lib/views/plot/run.ts. */ async plotCoverage(def: CohortDef | null | undefined, spec: PlotRequest, xk: AxisKind, yk: AxisKind): Promise> { const ctx = this.plotCtx(def, spec); @@ -1867,7 +1867,7 @@ abstract class BaseResolver implements AggregateCapable { /** * This site's per-group quartile summary for a numeric x categorical pair. n/lo/hi sum and * pool exactly; q1/median/q3 do not decompose exactly across sites and are approximated by - * lib/views/plot.ts with the same size-weighted mean Explore's federated numeric histograms + * lib/views/plot/run.ts with the same size-weighted mean Explore's federated numeric histograms * already use for the same reason — stated as an approximation, never silent. */ async plotBoxRows(def: CohortDef | null | undefined, spec: PlotRequest, xk: AxisKind, yk: AxisKind): diff --git a/lib/figures/bars.ts b/lib/figures/bars.ts index 9e92592..0658db4 100644 --- a/lib/figures/bars.ts +++ b/lib/figures/bars.ts @@ -2,11 +2,15 @@ // clinical): what MCP and `?figures=svg` get for them. Compare is grouped bars, one group per gene // and one bar per cohort; the other three are one horizontal bar per row on a labelled value axis, // signed about a zero line where the value has a direction. -import type { CooccurRow, CompareRow, ClinicalRow, CoexprRow, DrawnResultOf } from "../types.ts"; +import type { DrawnResultOf } from "../types.ts"; import { esc, palette, frame, caption, linear, axisBottom, bottomAxisHeight, legend, label, textWidth, seriesStyle, fmtNum, fmtPct, fmtQ, fmtInt, FS, SW, OTHER, SERIES, type SvgOpts, type LegendItem, type Scale } from "../svg-kit.ts"; +import type { ClinicalRow } from "../views/clinical/types.ts"; +import type { CoexprRow } from "../views/coexpression/types.ts"; +import type { CompareRow } from "../views/compare/types.ts"; +import type { CooccurRow } from "../views/cooccurrence/types.ts"; type Pal = ReturnType; diff --git a/lib/figures/forest.ts b/lib/figures/forest.ts index 7d26f63..1dce37a 100644 --- a/lib/figures/forest.ts +++ b/lib/figures/forest.ts @@ -5,11 +5,12 @@ // glance. The scale is set by the estimates, not by the widest interval: a covariate that barely // converged can have an upper bound in the millions, which would otherwise squash every other row // to a dot. An interval running past the scale ends in an arrow, and the caption says so. -import type { DrawnResultOf, ForestPooled } from "../types.ts"; +import type { DrawnResultOf } from "../types.ts"; import { palette, frame, caption, log, axisBottom, bottomAxisHeight, label, textWidth, fmtRatio, pDigits, FS, SW, SERIES, type SvgOpts } from "../svg-kit.ts"; +import type { ForestPooled } from "../views/forest/types.ts"; interface Coef { label: string; reference?: string; ciLow: number; ciHigh: number; ratio: number; p: number } interface Panel { heading: string | null; coefs: Coef[] } diff --git a/lib/figures/index.ts b/lib/figures/index.ts index 102eaf3..1e7fbe6 100644 --- a/lib/figures/index.ts +++ b/lib/figures/index.ts @@ -3,38 +3,38 @@ // same frame, palette and caption (lib/svg-kit.ts), so a figure here cannot drift from the others. import { isDrawn, type ViewResult, type ViewResultOf, type DrawnResultOf, type ViewKind } from "../types.ts"; import type { SvgOpts } from "../svg-kit.ts"; -import { attritionSvg } from "./attrition.ts"; -import { genomeSvg } from "./genome.ts"; -import { pathwaysSvg } from "./pathways.ts"; -import { diffexprSvg } from "./diffexpr.ts"; -import { pairedSvg } from "./paired.ts"; -import { clonalitySvg } from "./clonality.ts"; -import { incidenceSvg } from "./incidence.ts"; -import { labsSvg } from "./labs.ts"; -import { riskmodelSvg } from "./riskmodel.ts"; -import { cancertypesSvg } from "./cancertypes.ts"; -import { overlapSvg } from "./overlap.ts"; -import { enrichmentSvg } from "./enrichment.ts"; -import { assaysSvg } from "./assays.ts"; -import { compositionSvg } from "./composition.ts"; -import { correlatesSvg } from "./correlates.ts"; -import { acquiredSvg } from "./acquired.ts"; -import { ctdnaSvg } from "./ctdna.ts"; -import { exclusivitySvg } from "./exclusivity.ts"; -import { txendpointsSvg } from "./txendpoints.ts"; -import { multistateSvg } from "./multistate.ts"; -import { rmstSvg } from "./rmst.ts"; -import { predictiveSvg } from "./predictive.ts"; -import { cutpointSvg } from "./cutpoint.ts"; -import { hlaSvg } from "./hla.ts"; +import { attritionSvg } from "../views/attrition/figure.ts"; +import { genomeSvg } from "../views/genome/figure.ts"; +import { pathwaysSvg } from "../views/pathways/figure.ts"; +import { diffexprSvg } from "../views/diffexpr/figure.ts"; +import { pairedSvg } from "../views/paired/figure.ts"; +import { clonalitySvg } from "../views/clonality/figure.ts"; +import { incidenceSvg } from "../views/incidence/figure.ts"; +import { labsSvg } from "../views/labs/figure.ts"; +import { riskmodelSvg } from "../views/riskmodel/figure.ts"; +import { cancertypesSvg } from "../views/cancertypes/figure.ts"; +import { overlapSvg } from "../views/overlap/figure.ts"; +import { enrichmentSvg } from "../views/enrichment/figure.ts"; +import { assaysSvg } from "../views/assays/figure.ts"; +import { compositionSvg } from "../views/composition/figure.ts"; +import { correlatesSvg } from "../views/correlates/figure.ts"; +import { acquiredSvg } from "../views/acquired/figure.ts"; +import { ctdnaSvg } from "../views/ctdna/figure.ts"; +import { exclusivitySvg } from "../views/exclusivity/figure.ts"; +import { txendpointsSvg } from "../views/txendpoints/figure.ts"; +import { multistateSvg } from "../views/multistate/figure.ts"; +import { rmstSvg } from "../views/rmst/figure.ts"; +import { predictiveSvg } from "../views/predictive/figure.ts"; +import { cutpointSvg } from "../views/cutpoint/figure.ts"; +import { hlaSvg } from "../views/hla/figure.ts"; import { metaForestSvg } from "./forest.ts"; -import { adjustedSvg } from "./adjusted.ts"; -import { instabilitySvg } from "./instability.ts"; -import { actionabilitySvg } from "./actionability.ts"; -import { drivernegativeSvg } from "./drivernegative.ts"; -import { organotropismSvg } from "./organotropism.ts"; -import { molsubtypeSvg } from "./molsubtype.ts"; -import { germlineSvg } from "./germline.ts"; +import { adjustedSvg } from "../views/adjusted/figure.ts"; +import { instabilitySvg } from "../views/instability/figure.ts"; +import { actionabilitySvg } from "../views/actionability/figure.ts"; +import { drivernegativeSvg } from "../views/drivernegative/figure.ts"; +import { organotropismSvg } from "../views/organotropism/figure.ts"; +import { molsubtypeSvg } from "../views/molsubtype/figure.ts"; +import { germlineSvg } from "../views/germline/figure.ts"; /** a kind's figure, drawn only when its result has something to draw, and its one-line title */ interface Figure { draw(v: DrawnResultOf, o: SvgOpts): string | null; title(v: ViewResultOf): string } diff --git a/lib/hypothesis.ts b/lib/hypothesis.ts index 12d3120..475b621 100644 --- a/lib/hypothesis.ts +++ b/lib/hypothesis.ts @@ -9,7 +9,8 @@ // the replication step. Pure and browser-safe; the ledger is lib/campaign.ts. import { benjaminiHochberg } from "./stats.ts"; import { upgradeDocument } from "./doc-version.ts"; -import type { AnalysisDoc, AnalysisResult, ClinicalCell, CohortDef, ViewResult } from "./types.ts"; +import type { AnalysisDoc, AnalysisResult, CohortDef, ViewResult } from "./types.ts"; +import type { ClinicalCell } from "./views/clinical/types.ts"; export type Primary = /** one gene's alteration frequency, first cohort vs second, from a compare view */ diff --git a/lib/lollipop-svg.ts b/lib/lollipop-svg.ts index a44330b..8b9b9e2 100644 --- a/lib/lollipop-svg.ts +++ b/lib/lollipop-svg.ts @@ -2,11 +2,12 @@ // shows beside it as controls. Imports only the drawing kit and lib/lollipop.ts, so the app draws // it in the browser (app/_lollipop/HotspotsBody.tsx) from the same code the server uses for MCP. import { IMPACT_COLORS, IMPACT_TYPES, filterHotspotGene, cellLabel, type AlterationCell } from "./lollipop.ts"; -import type { HotspotGene } from "./types.ts"; + import { esc, palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, legend, label, textWidth, textOn, fmtInt, fmtShare, FS, SW, type SvgOpts, type LegendItem } from "./svg-kit.ts"; +import type { HotspotGene } from "./views/hotspots/types.ts"; /** * What the grid beside a lollipop shows in the app — somatic mutation frequency and the Driver/VUS diff --git a/lib/lollipop.ts b/lib/lollipop.ts index ba61665..67e8b74 100644 --- a/lib/lollipop.ts +++ b/lib/lollipop.ts @@ -2,7 +2,7 @@ // cbioportal-frontend-commons). Each rule below cites the function it copies, so a difference // from the portal is a bug here rather than a design choice. Pure except fetchProteinInfo. import type { LollipopReport } from "./federation.ts"; -import type { HotspotGene, LollipopPosition, LollipopDomain } from "./types.ts"; +import type { HotspotGene, LollipopPosition, LollipopDomain } from "./views/hotspots/types.ts"; // ---- mutation types: getCanonicalMutationType / getProteinImpactTypeFromCanonical ---------- diff --git a/lib/method.ts b/lib/method.ts index 0d676f6..beb74a2 100644 --- a/lib/method.ts +++ b/lib/method.ts @@ -9,14 +9,9 @@ // // Browser-safe: no I/O, so the analysis page can import its types. -import type { CoxCovariate, CompositionCell } from "./types.ts"; -import type { CompositionSource } from "./composition.ts"; +import type { CoxCovariate } from "./types.ts"; import type { AxisKind } from "./plot.ts"; import { OQL_UNALTERED } from "./plot-oql.ts"; -import { BUILD_COMMIT } from "./build-info.ts"; -import { COX_MAX_ITERATIONS, LOGISTIC_MAX_ITERATIONS } from "./stats.ts"; -import { SYSTEMIC_LINE_CLASSES, CLASS_KEYS } from "./treatment-lines.ts"; -import { table1RowKey, TABLE1_CONSIDERED, TABLE1_DEFAULT_ATTRIBUTES, TABLE1_DEFAULT_MAX_VALUES, TABLE1_MAX_VALUES, type Table1Histogram } from "./views/riskmodel-table1.ts"; export interface MethodStep { kind: "fetch" | "transform" | "statistic"; @@ -53,10 +48,10 @@ export interface CheckValue { v: number | null; rel?: number; abs?: number } /** The line a script's last output starts with, followed by its RESULT as JSON. */ export const RESULT_MARKER = "METHOD_RESULT "; -const RESULT_INIT = "RESULT = {} # the numbers the automated check compares with the view's (printed at the end)"; +export const RESULT_INIT = "RESULT = {} # the numbers the automated check compares with the view's (printed at the end)"; /** The end of every script: RESULT as one machine-readable line, NaN and infinities as null. */ -function withResult(python: string): string { +export function withResult(python: string): string { return python.trimEnd() + "\n\n" + [ "# for the check that this script reproduces the view (scripts/check-method-scripts.ts)", "import json as _json, math as _math", @@ -71,7 +66,7 @@ function withResult(python: string): string { } /** check entries for a list of named results, each field with its own tolerance */ -function checks(prefix: string, rows: Record[], keyOf: (r: any) => string, +export function checks(prefix: string, rows: Record[], keyOf: (r: any) => string, fields: [string, string, { rel?: number; abs?: number }?][]): Record { const out: Record = {}; for (const r of rows) for (const [field, name, tol] of fields) { @@ -105,7 +100,7 @@ export const VERIFIED = { cif: { library: "statsmodels", version: "0.15.0", fn: "CumIncidenceRight" }, mcnemar: { library: "statsmodels", version: "0.15.0", fn: "mcnemar(exact=True)" }, } as const; -const GOLDEN_TEST = "test/regression-golden.test.ts"; +export const GOLDEN_TEST = "test/regression-golden.test.ts"; /** One dummy-coded or numeric model term, as buildCoxColumns produced it. */ export interface MethodColumn { axisIndex: number; label: string; reference?: string; level?: string } @@ -132,18 +127,10 @@ export interface CoxTrace extends FitTrace { coefficients: { label: string; beta: number; se: number; p: number }[]; } -export interface LogisticTrace extends FitTrace { - outcomeKind: AxisKind; - outcomeEvent?: string; - outcomeOther: string[]; - /** what the fit reported, per term — for the script check */ - coefficients: { label: string; beta: number; se: number; p: number }[]; -} - -const plural = (n: number, one: string, many = one + "s") => n.toLocaleString() + " " + (n === 1 ? one : many); -const py = (s: string) => JSON.stringify(s); +export const plural = (n: number, one: string, many = one + "s") => n.toLocaleString() + " " + (n === 1 ? one : many); +export const py = (s: string) => JSON.stringify(s); /** Text for a `#` comment: a line break in a cohort name or a value would end the comment and run the rest. */ -const pyComment = (s: unknown) => String(s).replace(/[\r\n]+/g, " "); +export const pyComment = (s: unknown) => String(s).replace(/[\r\n]+/g, " "); /** Why a categorical covariate's reference level is the one it is — mirrors buildCoxColumns. */ function referenceReason(cov: CoxCovariate, reference: string): string { @@ -156,7 +143,7 @@ function referenceReason(cov: CoxCovariate, reference: string): string { return cov.reference ? "declared “" + cov.reference + "” was never observed, so " + fallback : fallback; } -function fetchSteps(t: FitTrace, query: string, keep: string): MethodStep[] { +export function fetchSteps(t: FitTrace, query: string, keep: string): MethodStep[] { return t.perStudy.map((s) => ({ kind: "fetch" as const, title: "Fetch one row per patient", @@ -170,7 +157,7 @@ function fetchSteps(t: FitTrace, query: string, keep: string): MethodStep[] { })); } -function codingStep(t: FitTrace): MethodStep { +export function codingStep(t: FitTrace): MethodStep { const params: [string, string][] = t.columns.map((col, i) => { const source = "c" + col.axisIndex; if (t.kinds[col.axisIndex] === "num") return [col.label, "numeric, as fetched (" + source + ")"]; @@ -188,7 +175,7 @@ function codingStep(t: FitTrace): MethodStep { } /** pandas columns for the design matrix, one line per term */ -function pyTerms(t: FitTrace, indent: string): string { +export function pyTerms(t: FitTrace, indent: string): string { return t.columns.map((col) => { const c = py("c" + col.axisIndex); return indent + py(col.label) + ": " + (t.kinds[col.axisIndex] === "num" @@ -197,172 +184,11 @@ function pyTerms(t: FitTrace, indent: string): string { }).join("\n"); } -function pyCategoricalDtypes(t: FitTrace, extra: string[] = []): string { +export function pyCategoricalDtypes(t: FitTrace, extra: string[] = []): string { const cols = [...extra, ...t.kinds.map((k, i) => k === "num" ? null : "c" + i).filter((c): c is string => !!c)]; return cols.length ? ", dtype={" + cols.map((c) => py(c) + ": str").join(", ") + "}" : ""; } -function pyFileName(base: string, scope?: string): string { - return base + (scope ? "_" + scope.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_|_$/g, "") : "") + ".csv"; -} - -export interface CoxMethodOptions { - endpoint: string; - monthsColumn: string; - statusColumn: string; - cutoffMonths?: number; - stratifyByStudy: boolean; - queryLabel: string; -} - -export function coxMethod(traces: CoxTrace[], o: CoxMethodOptions): MethodRecord { - const steps: MethodStep[] = []; - const keep = "Patients with a " + o.monthsColumn + " value and a value for every covariate are kept. " + - "Event: " + o.statusColumn + " starting with “1” (e.g. “1:DECEASED”); any other value, including a missing one, is censored."; - for (const t of traces) { - steps.push(...fetchSteps(t, o.queryLabel, keep).map((s) => t.scope ? { ...s, scope: t.scope + " · " + s.scope } : s)); - const rows = t.perStudy.reduce((n, s) => n + s.rows, 0); - if (o.cutoffMonths != null) { - steps.push({ kind: "transform", title: "Censor at " + o.cutoffMonths + " months", - detail: plural(t.censoredAtCutoff, "patient") + " followed past " + o.cutoffMonths + " months had their time set to " + - o.cutoffMonths + " and their event to censored.", - ...(t.scope ? { scope: t.scope } : {}), rowsIn: rows, rowsOut: rows }); - } - if (t.droppedBadTime > 0) { - steps.push({ kind: "transform", title: "Drop invalid times", - detail: plural(t.droppedBadTime, "patient") + " with a negative or non-numeric time were dropped.", - ...(t.scope ? { scope: t.scope } : {}), rowsIn: rows, rowsOut: rows - t.droppedBadTime }); - } - steps.push(codingStep(t)); - steps.push({ - kind: "statistic", - title: "Fit a Cox proportional-hazards model", - detail: "Maximum partial likelihood with Efron's method for tied event times, by Newton–Raphson with step-halving.", - ...(t.scope ? { scope: t.scope } : {}), - params: [ - ["patients / events", t.fit.n.toLocaleString() + " / " + t.fit.events.toLocaleString()], - ["terms", String(t.columns.length)], - ["baseline hazard", o.stratifyByStudy ? "one per study (stratified by study)" : "one, shared by every patient"], - ["standard errors", "from the observed information at the estimate"], - ["95% interval", "exp(β ± 1.959964·SE), Wald"], - ["p-value", "Wald χ² on 1 df, (β/SE)²"], - ["fit", t.fit.iterations < COX_MAX_ITERATIONS ? "converged in " + plural(t.fit.iterations, "iteration") - : "stopped at the " + COX_MAX_ITERATIONS + "-iteration limit without converging"], - ["log partial likelihood", t.fit.loglik.toFixed(4)], - ], - }); - } - return { - steps, - implementation: { functions: ["coxRegression"], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "Matches " + VERIFIED.cox.library + " " + VERIFIED.cox.version + " " + VERIFIED.cox.fn + - " (" + VERIFIED.cox.ties + " ties) to a relative 1e-6 on coefficients, SEs, p-values and intervals (" + GOLDEN_TEST + ").", - python: withResult(coxPython(traces, o)), - // lifelines can stop ~1e-5 short of the optimum on a term with only a handful of patients - check: Object.assign({}, ...traces.map((t) => checks("cox:" + (t.scope || "") + ":", t.coefficients, (c) => c.label, - [["beta", "beta", { rel: 1e-4, abs: 1e-6 }], ["se", "se", { rel: 1e-4 }], ["p", "p", { rel: 1e-3, abs: 1e-12 }]]))), - }; -} - -function coxPython(traces: CoxTrace[], o: CoxMethodOptions): string { - const multiStudy = traces.some((t) => t.perStudy.length > 1); - const lines = [ - "# Refit with lifelines (" + VERIFIED.cox.version + " is the version our fit is tested against).", - "# Input: the rows the " + o.queryLabel + " query above returns — columns m (months), ev (0/1), c0, c1, …", - multiStudy || o.stratifyByStudy - ? "# It runs once per study: concatenate the results and add a \"study\" column." - : "# Save them as CSV.", - "import pandas as pd", - "from lifelines import CoxPHFitter", - "", - RESULT_INIT, - ...(traces.some((t) => t.scope) ? ["fits = {} # one independent model per cohort", ""] : []), - ]; - for (const t of traces) { - if (t.scope) lines.push("# --- cohort " + py(t.scope) + ": its own model, own coding ---"); - lines.push("df = pd.read_csv(" + py(pyFileName("cox_rows", t.scope)) + pyCategoricalDtypes(t) + ")"); - if (o.cutoffMonths != null) { - lines.push("df.loc[df[\"m\"] > " + o.cutoffMonths + ", \"ev\"] = 0 # censor at " + o.cutoffMonths + " months", - "df[\"m\"] = df[\"m\"].clip(upper=" + o.cutoffMonths + ")"); - } - lines.push("df = df[df[\"m\"] >= 0]", - "X = pd.DataFrame({", - " \"m\": df[\"m\"], \"ev\": df[\"ev\"],", - pyTerms(t, " "), - "})"); - if (o.stratifyByStudy) lines.push("X[\"study\"] = df[\"study\"]"); - lines.push("cph = CoxPHFitter() # Efron ties and no penalty: the same model", - "# a tighter tolerance than lifelines' default (1e-7), which can stop about 1e-6 short of the optimum", - "cph.fit(X, duration_col=\"m\", event_col=\"ev\"" + (o.stratifyByStudy ? ", strata=[\"study\"]" : "") + - ", fit_options={\"precision\": 1e-10})", - "cph.print_summary() # coef, se(coef), p and the exp(coef) 95% bounds are the forest plot's numbers", - ...(t.scope ? ["fits[" + py(t.scope) + "] = cph"] : []), - "for term, row in cph.summary.iterrows():", - " k = " + py("cox:" + (t.scope || "") + ":") + " + term", - " RESULT[k + \":beta\"], RESULT[k + \":se\"], RESULT[k + \":p\"] = row[\"coef\"], row[\"se(coef)\"], row[\"p\"]", - ""); - } - return lines.join("\n").trimEnd() + "\n"; -} - -export function logisticMethod(t: LogisticTrace, o: { outcomeLabel: string; queryLabel: string }): MethodRecord { - const steps: MethodStep[] = fetchSteps(t, o.queryLabel, - "Patients with a value for " + o.outcomeLabel + " and for every covariate are kept."); - steps.push({ - kind: "transform", - title: "Code the outcome", - detail: t.outcomeKind === "num" - ? o.outcomeLabel + " is numeric and already coded 0/1; it is used as is." - : "1 if " + o.outcomeLabel + " = “" + t.outcomeEvent + "”, else 0" + - (t.outcomeOther.length ? " — every other observed value (" + t.outcomeOther.join(", ") + ") counts as not the event." : "."), - }); - steps.push(codingStep(t)); - steps.push({ - kind: "statistic", - title: "Fit a logistic regression", - detail: "Maximum likelihood for a binomial GLM with a logit link and an intercept, by Newton–Raphson with step-halving.", - params: [ - ["patients / events", t.fit.n.toLocaleString() + " / " + t.fit.events.toLocaleString()], - ["terms", t.columns.length + " plus an intercept (not drawn)"], - ["standard errors", "from the Fisher information at the estimate"], - ["95% interval", "exp(β ± 1.959964·SE), Wald"], - ["p-value", "Wald χ² on 1 df, (β/SE)²"], - ["fit", t.fit.iterations < LOGISTIC_MAX_ITERATIONS ? "converged in " + plural(t.fit.iterations, "iteration") - : "stopped at the " + LOGISTIC_MAX_ITERATIONS + "-iteration limit without converging"], - ["log likelihood", t.fit.loglik.toFixed(4)], - ], - }); - const y = t.outcomeKind === "num" ? "df[\"y\"].astype(int)" : "(df[\"y\"] == " + py(t.outcomeEvent!) + ").astype(int)"; - const python = [ - "# Refit with statsmodels (" + VERIFIED.logistic.version + " is the version our fit is tested against).", - "# Input: the rows the " + o.queryLabel + " query above returns — columns y, c0, c1, …", - t.perStudy.length > 1 ? "# It runs once per study: concatenate the results." : "# Save them as CSV.", - "import numpy as np", - "import pandas as pd", - "import statsmodels.api as sm", - "", - RESULT_INIT, - "df = pd.read_csv(\"logistic_rows.csv\"" + pyCategoricalDtypes(t, t.outcomeKind === "num" ? [] : ["y"]) + ")", - "y = " + y + " # the outcome: " + (t.outcomeKind === "num" ? "already 0/1" : "event = " + pyComment(t.outcomeEvent)), - "X = pd.DataFrame({", - pyTerms(t, " "), - "})", - "fit = sm.Logit(y, sm.add_constant(X)).fit(method=\"newton\")", - "print(fit.summary()) # coefficients, SEs and p-values", - "print(np.exp(fit.conf_int())) # the odds-ratio 95% intervals drawn in the forest plot", - "for term in X.columns:", - " RESULT[\"logistic:\" + term + \":beta\"], RESULT[\"logistic:\" + term + \":se\"], RESULT[\"logistic:\" + term + \":p\"] = fit.params[term], fit.bse[term], fit.pvalues[term]", - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: ["logisticRegression"], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "Matches " + VERIFIED.logistic.library + " " + VERIFIED.logistic.version + " " + VERIFIED.logistic.fn + - " to a relative 1e-6 on coefficients, SEs, p-values and intervals (" + GOLDEN_TEST + ").", - python: withResult(python), - check: checks("logistic:", t.coefficients, (c) => c.label, [["beta", "beta"], ["se", "se"], ["p", "p", { rel: 1e-5, abs: 1e-14 }]]), - }; -} - // ---- aggregate inputs, embedded in a script --------------------------------------------------- // The aggregate views' inputs are counts with no identifiers in them, so a script can carry them // and run as is. Past this many rows it names the query instead of inlining a table that size. @@ -375,7 +201,7 @@ function csvField(v: string | number | null | undefined): string { } /** `name = pd.read_csv(...)`: inline when small enough, else from a file the reader exports. */ -function pyTable(name: string, what: string, columns: string[], rows: (string | number | null | undefined)[][], file: string): string[] { +export function pyTable(name: string, what: string, columns: string[], rows: (string | number | null | undefined)[][], file: string): string[] { if (rows.length > INLINE_ROWS_MAX) { return ["# " + pyComment(what) + " — " + rows.length.toLocaleString() + " rows, too many to include here: export them as " + pyComment(file), "# with columns " + pyComment(columns.join(", ")) + ".", @@ -389,1749 +215,22 @@ function pyTable(name: string, what: string, columns: string[], rows: (string | "])))"]; } -// ---- survival: Kaplan-Meier and log-rank ------------------------------------------------------- - -export interface SurvivalTrace { - endpoint: string; - monthsColumn: string; - statusColumn: string; - queryLabel: string; - /** the timeline event survival was re-anchored to, which brings delayed entry with it */ - anchor?: string; - cutoffMonths?: number; - beyondCutoff: number; - display: "survival" | "incidence"; - cohorts: { - name: string; - perStudy: { study: string; cohortPatients: number; patients: number; rows: number }[]; - n: number; events: number; median: number | null; - /** follow-up ending at or before entry: never at risk, left out */ - unobserved?: number; - }[]; - /** every (months, event, n[, entry]) row each study returned, per cohort */ - counts: { cohort: string; study: string; months: number; event: number; n: number; entry?: number }[]; - test: - | { kind: "none"; reason: string } - | { kind: "logrank" | "logrankK"; stratified: boolean; strata: number; chi2: number; df: number; p: number; - pooled?: { chi2: number; p: number } }; -} - -const pFmt = (p: number) => p === 0 ? "<1e-300" : p < 1e-4 ? p.toExponential(2) : p.toPrecision(3); - -export function survivalMethod(t: SurvivalTrace): MethodRecord { - const steps: MethodStep[] = []; - for (const c of t.cohorts) for (const s of c.perStudy) { - steps.push({ - kind: "fetch", title: "Count patients by time and status", - detail: "The " + t.queryLabel + " query above: patients grouped by identical " + t.monthsColumn + ", event" + - (t.anchor ? " and entry time" : "") + ", returned as counts — no patient identifier leaves the database. Event: " + - t.statusColumn + " starting with “1”; any other value is censored. " + - (s.cohortPatients > s.patients ? plural(s.cohortPatients - s.patients, "cohort patient") + " had no " + t.monthsColumn + " value." : "Every cohort patient had a value."), - scope: c.name + " · " + s.study, rowsIn: s.cohortPatients, rowsOut: s.patients, - params: [["count rows", s.rows.toLocaleString()]], - }); - } - if (t.cutoffMonths != null) { - steps.push({ kind: "transform", title: "Censor at " + t.cutoffMonths + " months", - detail: plural(t.beyondCutoff, "patient") + " followed past " + t.cutoffMonths + " months had their time set to " + - t.cutoffMonths + " and their event to censored." }); - } - steps.push({ - kind: "statistic", title: "Estimate survival by Kaplan–Meier", - detail: "The product-limit estimator, one curve per cohort, pooled over its studies" + - (t.anchor ? ", with delayed entry: re-anchored to " + t.anchor + ", a patient is at risk only from the time the study could first observe them" : "") + - (t.cohorts.some((c) => c.unobserved) ? ". A patient whose follow-up ends at or before their entry was never at risk and is left out: " + - t.cohorts.filter((c) => c.unobserved).map((c) => c.name + " " + c.unobserved!.toLocaleString("en-US")).join(", ") : "") + - ". The median is the first time the curve is at or below 0.5" + - (t.display === "incidence" ? ". The figure shows cumulative incidence, 1 − S(t)" : "") + ".", - params: t.cohorts.map((c) => [c.name, c.n.toLocaleString() + " patients, " + c.events.toLocaleString() + " events, median " + - (c.median == null ? "not reached" : c.median.toFixed(1) + " months")] as [string, string]), - }); - if (t.test.kind === "none") { - steps.push({ kind: "statistic", title: "No log-rank test", detail: t.test.reason }); - } else { - const k = t.cohorts.length, x = t.test; - steps.push({ - kind: "statistic", - title: (k === 2 ? "Two-group" : k + "-group") + " log-rank test" + (x.stratified ? ", stratified by study" : ""), - detail: "At each event time, every cohort's observed events against its share of the risk set, with the hypergeometric variance" + - (k > 2 ? " and covariance; χ² on " + x.df + " df from the deviation vector" : "; χ² on 1 df") + - (x.stratified ? ". Each study is its own stratum — compared only against itself — and the strata are summed, so a difference in case mix between studies cannot pass for a difference between cohorts" : "") + ".", - params: [ - ["χ² / df / p", x.chi2.toFixed(4) + " / " + x.df + " / " + pFmt(x.p)], - ...(x.stratified ? [["strata with events", String(x.strata)] as [string, string]] : []), - ...(x.pooled ? [["pooled, for contrast", "χ² " + x.pooled.chi2.toFixed(4) + ", p " + pFmt(x.pooled.p)] as [string, string]] : []), - ], - }); - } - const hasEntry = t.counts.some((r) => r.entry != null); - const cols = ["cohort", "study", "months", "event", "n", ...(hasEntry ? ["entry"] : [])]; - const python = [ - "# Refit with lifelines (Kaplan-Meier) and statsmodels (log-rank): lifelines " + VERIFIED.km.version + - " and statsmodels " + VERIFIED.logrank.version + " are the versions our code is tested against.", - "import io", - "import pandas as pd", - "from lifelines import KaplanMeierFitter", - "from scipy.stats import chi2 as chi2_dist", - "from statsmodels.duration.survfunc import survdiff", - "", - RESULT_INIT, - ...pyTable("counts", "What the " + t.queryLabel + " query returned for each cohort and study: n patients per (months, event" + - (hasEntry ? ", entry" : "") + ")", cols, t.counts.map((r) => [r.cohort, r.study, r.months, r.event, r.n, ...(hasEntry ? [r.entry] : [])]), - "survival_counts.csv"), - "# one row per patient again: a count row stands for n patients with exactly those values", - "df = counts.loc[counts.index.repeat(counts[\"n\"])].reset_index(drop=True)", - ...(t.cutoffMonths != null ? [ - "df.loc[df[\"months\"] > " + t.cutoffMonths + ", \"event\"] = 0 # censor at " + t.cutoffMonths + " months", - "df[\"months\"] = df[\"months\"].clip(upper=" + t.cutoffMonths + ")"] : []), - "df = df[df[\"months\"] >= 0]", - ...(hasEntry ? ["df = df[df[\"months\"] > df[\"entry\"]] # follow-up ending at or before entry was never at risk"] : []), - "", - "for cohort, g in df.groupby(\"cohort\", sort=False):", - " km = KaplanMeierFitter().fit(g[\"months\"], g[\"event\"]" + (hasEntry ? ", entry=g[\"entry\"]" : "") + ", label=cohort)", - " print(f\"{cohort}: n={len(g)}, events={g['event'].sum()}, median={km.median_survival_time_}\")", - " RESULT[f\"km:{cohort}:n\"], RESULT[f\"km:{cohort}:events\"], RESULT[f\"km:{cohort}:median\"] = len(g), g[\"event\"].sum(), km.median_survival_time_", - ...(t.test.kind === "none" ? [] : [ - "", - "# p from the chi-squared tail directly: survdiff's own p rounds to 0 below about 1e-16", - ...(t.test.stratified ? [ - "chi2, p = survdiff(df[\"months\"], df[\"event\"], df[\"cohort\"], strata=df[\"study\"]" + (hasEntry ? ", entry=df[\"entry\"]" : "") + ")", - "print(f\"log-rank, stratified by study: chi2={chi2:.4f}, p={chi2_dist.sf(chi2, " + t.test.df + "):.4g}\")", - "RESULT[\"logrank:chi2\"], RESULT[\"logrank:p\"] = chi2, chi2_dist.sf(chi2, " + t.test.df + ")", - "chi2, p = survdiff(df[\"months\"], df[\"event\"], df[\"cohort\"]" + (hasEntry ? ", entry=df[\"entry\"]" : "") + ")", - "print(f\"log-rank, pooled: chi2={chi2:.4f}, p={chi2_dist.sf(chi2, " + t.test.df + "):.4g}\")", - "RESULT[\"logrank:pooled:chi2\"], RESULT[\"logrank:pooled:p\"] = chi2, chi2_dist.sf(chi2, " + t.test.df + ")", - ] : [ - "chi2, p = survdiff(df[\"months\"], df[\"event\"], df[\"cohort\"]" + (hasEntry ? ", entry=df[\"entry\"]" : "") + ")", - "print(f\"log-rank: chi2={chi2:.4f}, p={chi2_dist.sf(chi2, " + t.test.df + "):.4g}\")", - "RESULT[\"logrank:chi2\"], RESULT[\"logrank:p\"] = chi2, chi2_dist.sf(chi2, " + t.test.df + ")", - ]), - ]), - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: ["kaplanMeier", ...(t.test.kind === "logrank" ? ["logRank"] : t.test.kind === "logrankK" ? ["logRankK"] : [])], - file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "Kaplan–Meier matches " + VERIFIED.km.library + " " + VERIFIED.km.version + " " + VERIFIED.km.fn + - " (curves are reported to 4 decimals); the log-rank matches " + VERIFIED.logrank.library + " " + VERIFIED.logrank.version + " " + - VERIFIED.logrank.fn + " to a relative 1e-6, pooled, stratified, with delayed entry and for k groups (test/survival-golden.test.ts).", - python: withResult(python), - check: { - ...checks("km:", t.cohorts, (c) => c.name, [["n", "n", { abs: 0 }], ["events", "events", { abs: 0 }], ["median", "median", { abs: 1e-9 }]]), - ...(t.test.kind === "none" ? {} : { - "logrank:chi2": { v: t.test.chi2, abs: 5e-5 }, "logrank:p": { v: t.test.p, rel: 1e-5, abs: 1e-300 }, - ...(t.test.pooled ? { "logrank:pooled:chi2": { v: t.test.pooled.chi2, abs: 5e-5 }, "logrank:pooled:p": { v: t.test.pooled.p, rel: 1e-5, abs: 1e-300 } } : {}), - }), - }, - }; -} +export const pFmt = (p: number) => p === 0 ? "<1e-300" : p < 1e-4 ? p.toExponential(2) : p.toPrecision(3); // ---- compare: per-gene alteration frequency, Fisher / chi-squared, Benjamini-Hochberg --------- /** What "altered" and "driver" mean in every gene count, stated once for the views that use them. */ -const ALTERED_MEANING = "altered: any mutation or structural variant, or a copy-number change of ±2 (amplification or deep deletion)"; -const driverMeaning = (pin: string | null) => pin +export const ALTERED_MEANING = "altered: any mutation or structural variant, or a copy-number change of ±2 (amplification or deep deletion)"; +export const driverMeaning = (pin: string | null) => pin ? "driver: an alteration OncoKB calls oncogenic, from the OncoKB release pinned for this run (" + pin + ")" : "driver: no OncoKB release is pinned, so no alteration is counted as a driver"; -export interface CompareTrace { - oncokbPin: string | null; - cohorts: string[]; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - /** the combined ("any") row each study returned per gene */ - counts: { cohort: string; study: string; gene: string; profiled: number; altered: number; driver: number }[]; - genes: string[]; - /** what the view reported per gene — for the script check */ - results: { gene: string; pAny?: number; pDriver?: number; qAny?: number; qDriver?: number }[]; -} - -export function compareMethod(t: CompareTrace): MethodRecord { - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Count profiled and altered patients per gene", - detail: "The geneCounts query above. Per gene: patients with a sample on a gene panel that covers the gene (profiled — the denominator, so a gene a panel omits is not counted as wild-type), " + - ALTERED_MEANING + ", and " + driverMeaning(t.oncokbPin) + ". Counts only.", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - params: [["genes", String(t.genes.length)]], - })); - const multiStudy = t.cohorts.some((c) => t.perSite.filter((s) => s.cohort === c).length > 1); - if (multiStudy) { - steps.push({ kind: "transform", title: "Sum each cohort's counts across its studies", - detail: "Profiled, altered and driver counts are added study by study, so the denominator stays panel-aware. The test that follows is on the pooled counts, not stratified by study." }); - } - const k = t.cohorts.length; - if (k === 2) { - steps.push({ kind: "statistic", title: "Fisher's exact test per gene", - detail: "Two-sided, on the 2×2 table of altered vs not altered (profiled − altered) in each cohort — once for any alteration and once for drivers only.", - params: [["genes tested", String(t.genes.length)]] }); - } else if (k > 2) { - steps.push({ kind: "statistic", title: "Pearson χ² test per gene", - detail: "On the 2×" + k + " table of altered vs not altered, one column per cohort, with no continuity correction; an all-zero row or column is dropped first. Once for any alteration and once for drivers only.", - params: [["genes tested", String(t.genes.length)], ["df", String(k - 1)]] }); - } - if (k >= 2) { - steps.push({ kind: "statistic", title: "Benjamini–Hochberg across genes", - detail: "q-values over the " + t.genes.length + " genes tested here, separately for the any-alteration and driver-only families." }); - } else { - steps.push({ kind: "statistic", title: "No test", detail: "One cohort: frequencies only, nothing to compare against." }); - } - const python = [ - "# Recompute with scipy and statsmodels: scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + - " are the versions our code is tested against.", - "import io", - "import pandas as pd", - k > 2 ? "from scipy.stats import chi2_contingency" : "from scipy.stats import fisher_exact", - "from statsmodels.stats.multitest import multipletests", - "", - RESULT_INIT, - ...pyTable("counts", "What the geneCounts query returned for each cohort and study: patients profiled for, altered in, and with a driver in each gene", - ["cohort", "study", "gene", "profiled", "altered", "driver"], - t.counts.map((r) => [r.cohort, r.study, r.gene, r.profiled, r.altered, r.driver]), "gene_counts.csv"), - "cohorts = " + JSON.stringify(t.cohorts), - "per_cohort = counts.groupby([\"gene\", \"cohort\"])[[\"profiled\", \"altered\", \"driver\"]].sum()", - "for (gene, cohort), r in per_cohort.iterrows():", - " for f in (\"profiled\", \"altered\", \"driver\"):", - " RESULT[f\"compare:{gene}:{cohort}:{f}\"] = r[f]", - "", - ...(k < 2 ? ["print(per_cohort)"] : [ - "def test(gene, col):", - " t = per_cohort.loc[gene].reindex(cohorts).fillna(0)", - ...(k === 2 ? [ - " (a, pa), (b, pb) = [(r[col], r[\"profiled\"]) for _, r in t.iterrows()]", - " return fisher_exact([[a, pa - a], [b, pb - b]], alternative=\"two-sided\").pvalue", - ] : [ - " table = pd.DataFrame({\"yes\": t[col], \"no\": t[\"profiled\"] - t[col]})", - " table = table.loc[table.sum(axis=1) > 0, table.sum(axis=0) > 0] # drop an all-zero row or column", - " if min(table.shape) < 2:", - " return 1.0", - " return chi2_contingency(table.to_numpy(), correction=False).pvalue", - ]), - "", - "genes = " + JSON.stringify(t.genes), - "result = pd.DataFrame({\"gene\": genes,", - " \"p_any\": [test(g, \"altered\") for g in genes],", - " \"p_driver\": [test(g, \"driver\") for g in genes]})", - "result[\"q_any\"] = multipletests(result[\"p_any\"], method=\"fdr_bh\")[1]", - "result[\"q_driver\"] = multipletests(result[\"p_driver\"], method=\"fdr_bh\")[1]", - "print(result.sort_values(\"p_any\").to_string(index=False))", - "for r in result.itertuples():", - " for f in (\"p_any\", \"p_driver\", \"q_any\", \"q_driver\"):", - " RESULT[f\"compare:{r.gene}:{f}\"] = getattr(r, f)", - ]), - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: k === 2 ? ["fisherExact", "benjaminiHochberg"] : k > 2 ? ["chiSquareTest", "benjaminiHochberg"] : [], - file: "lib/stats.ts", commit: BUILD_COMMIT }, - ...(k >= 2 ? { verification: (k === 2 ? "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact" - : "The χ² test matches scipy " + VERIFIED.chi2.version + " chi2_contingency(correction=False)") + - " and Benjamini–Hochberg matches statsmodels " + VERIFIED.bh.version + " " + VERIFIED.bh.fn + ", to a relative 1e-6 (test/tables-golden.test.ts)." } : {}), - python: withResult(python), - check: { - ...checks("compare:", t.results, (r) => r.gene, [["pAny", "p_any"], ["pDriver", "p_driver"], ["qAny", "q_any"], ["qDriver", "q_driver"]]), - // the summed counts behind every cell, exact - ...checks("compare:", [...t.counts.reduce((m, r) => { - const k = r.gene + ":" + r.cohort, cur = m.get(k) || { key: k, profiled: 0, altered: 0, driver: 0 }; - cur.profiled += r.profiled; cur.altered += r.altered; cur.driver += r.driver; return m.set(k, cur); - }, new Map()).values()], (r) => r.key, - [["profiled", "profiled", { abs: 0 }], ["altered", "altered", { abs: 0 }], ["driver", "driver", { abs: 0 }]]), - }, - }; -} - -// ---- cooccurrence: per-pair 2x2, Fisher or CMH stratified by study, Benjamini-Hochberg -------- - -export interface CooccurrenceTrace { - cohort: string; - cohortPatients: { study: string; patients: number }[]; - /** the four counts each study returned per pair */ - counts: { study: string; a: string; b: string; both: number; onlyA: number; onlyB: number; neither: number }[]; - pairs: number; - assessable: number; - /** what the view reported per assessable pair — for the script check */ - results: { a: string; b: string; p: number; q: number; pStratified?: number }[]; -} - -export function cooccurrenceMethod(t: CooccurrenceTrace): MethodRecord { - const stratified = t.cohortPatients.length > 1; - const steps: MethodStep[] = t.cohortPatients.map((s) => ({ - kind: "fetch" as const, title: "Count each gene pair's 2×2 table", - detail: "The pairCounts query above. Of the patients with a sample on a mutation panel covering both genes: how many are altered in both, only the first, only the second, or neither (" + - ALTERED_MEANING + "). Four counts per pair, nothing else.", - scope: t.cohort + " · " + s.study, rowsIn: s.patients, - params: [["pairs", String(t.pairs)]], - })); - if (t.assessable < t.pairs) { - steps.push({ kind: "transform", title: "Set aside pairs no panel covers", - detail: plural(t.pairs - t.assessable, "pair") + " had no patient profiled for both genes — no denominator, so no test; they are omitted rather than reported as p = 1.", - rowsIn: t.pairs, rowsOut: t.assessable }); - } - steps.push({ kind: "statistic", title: "Log₂ odds ratio per pair", - detail: "log₂((both · neither) / (only first · only second)) on the counts summed across studies, with 0.5 added to every cell when any cell is 0." + - (stratified ? " The direction reported (co-occurrence vs mutual exclusivity) follows the Mantel–Haenszel odds ratio below." : "") }); - steps.push(stratified - ? { kind: "statistic", title: "Cochran–Mantel–Haenszel test, stratified by study", - detail: "Each study's 2×2 is a stratum (one with an empty margin says nothing and is dropped); the test of a common odds ratio of 1, with the 0.5 continuity correction applied when |Σa − ΣE[a]| ≥ 0.5 (as R's mantelhaen.test), on 1 df. This p is the one corrected and ranked; the two-sided Fisher p on the pooled counts is reported alongside." } - : { kind: "statistic", title: "Fisher's exact test per pair", detail: "Two-sided, on the pair's 2×2 table." }); - steps.push({ kind: "statistic", title: "Benjamini–Hochberg across pairs", - detail: "q-values over the " + t.assessable + " assessable pairs, from the " + (stratified ? "stratified" : "Fisher") + " p-values." }); - const python = [ - "# Recompute with scipy and statsmodels: scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + - " are the versions our code is tested against.", - "import io", - "import numpy as np", - "import pandas as pd", - "from scipy.stats import fisher_exact", - ...(stratified ? ["from scipy.stats import chi2 as chi2_dist", "from statsmodels.stats.contingency_tables import StratifiedTable"] : []), - "from statsmodels.stats.multitest import multipletests", - "", - RESULT_INIT, - ...pyTable("counts", "What the pairCounts query returned for each study, cohort " + JSON.stringify(t.cohort), - ["study", "a", "b", "both", "only_a", "only_b", "neither"], - t.counts.map((r) => [r.study, r.a, r.b, r.both, r.onlyA, r.onlyB, r.neither]), "pair_counts.csv"), - "", - "def log2_or(a, b, c, d):", - " if 0 in (a, b, c, d):", - " a, b, c, d = a + 0.5, b + 0.5, c + 0.5, d + 0.5", - " return np.log2(a * d / (b * c))", - "", - "rows = []", - "for (a, b), g in counts.groupby([\"a\", \"b\"], sort=False):", - " s = g[[\"both\", \"only_a\", \"only_b\", \"neither\"]].sum()", - " if s.sum() == 0:", - " continue # no patient profiled for both genes: not assessable", - " row = {\"a\": a, \"b\": b, \"log2_or\": log2_or(*s),", - " \"p_fisher\": fisher_exact([[s.both, s.only_a], [s.only_b, s.neither]], alternative=\"two-sided\").pvalue}", - ...(stratified ? [ - " strata = [np.array([[r.both, r.only_a], [r.only_b, r.neither]], dtype=float) for r in g.itertuples()", - " if min(r.both + r.only_a, r.only_b + r.neither, r.both + r.only_b, r.only_a + r.neither) > 0]", - " if strata:", - " st = StratifiedTable(strata)", - " expected = sum(t[0].sum() * t[:, 0].sum() / t.sum() for t in strata)", - " delta = abs(sum(t[0, 0] for t in strata) - expected)", - " stat = st.test_null_odds(correction=bool(delta >= 0.5)).statistic # R's continuity-correction rule", - " row[\"p_cmh\"] = chi2_dist.sf(stat, 1) # from the tail directly: test_null_odds' own p is 0 below about 1e-16", - " row[\"or_mh\"] = st.oddsratio_pooled", - " else:", - " row[\"p_cmh\"] = 1.0", - ] : []), - " rows.append(row)", - "", - "result = pd.DataFrame(rows)", - "p = result[\"" + (stratified ? "p_cmh" : "p_fisher") + "\"]", - "result[\"q\"] = multipletests(p, method=\"fdr_bh\")[1]", - "print(result.sort_values(\"" + (stratified ? "p_cmh" : "p_fisher") + "\").to_string(index=False))", - "for r in result.itertuples():", - " RESULT[f\"cooccurrence:{r.a}/{r.b}:p\"], RESULT[f\"cooccurrence:{r.a}/{r.b}:q\"] = r.p_fisher, r.q", - ...(stratified ? [" RESULT[f\"cooccurrence:{r.a}/{r.b}:p_cmh\"] = r.p_cmh"] : []), - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: ["fisherExact", ...(stratified ? ["cochranMantelHaenszel"] : []), "log2OddsRatio", "benjaminiHochberg"], - file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact" + - (stratified ? ", the CMH test and Mantel–Haenszel odds ratio statsmodels " + VERIFIED.cmh.version + " " + VERIFIED.cmh.fn : "") + - " and Benjamini–Hochberg statsmodels " + VERIFIED.bh.version + " " + VERIFIED.bh.fn + ", to a relative 1e-6 (test/tables-golden.test.ts).", - python: withResult(python), - check: checks("cooccurrence:", t.results, (r) => r.a + "/" + r.b, [["p", "p"], ["q", "q"], ["pStratified", "p_cmh"]]), - }; -} - -// ---- clinical: chi-squared, Welch t / ANOVA from moments, rank tests from histograms, BH ------- - -export interface ClinicalTrace { - cohorts: string[]; - singleStudy: boolean; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - attributes: { - attribute: string; per: "patient" | "sample"; kind: "cat" | "num"; skipped?: string; - test?: string; statistic?: number; df?: number; df2?: number; p?: number; q?: number; - rankStatistic?: number; rankP?: number; distinct?: number; minExpected?: number; histogramTruncated?: boolean; - }[]; - categorical: { cohort: string; study: string; attribute: string; value: string; n: number }[]; - numeric: { cohort: string; study: string; attribute: string; n: number; sum: number; sumsq: number }[]; - /** single-study only: each numeric attribute's complete (value, count) distribution per cohort */ - histograms: { cohort: string; attribute: string; value: number; n: number }[]; - /** the medians and quartiles the view reported — for the script check */ - medians: { attribute: string; cohort: string; median: number | null; q1: number | null; q3: number | null }[]; -} - -export function clinicalMethod(t: ClinicalTrace): MethodRecord { - const k = t.cohorts.length; - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Describe each attribute", - detail: "The attributeStats query above. A categorical attribute comes back as a count per value; a numeric one as n, Σx and Σx² (from which the mean and variance are exact, and which add across studies)" + - (t.singleStudy ? ", plus its complete value distribution as (value, count) pairs for the median, IQR and rank tests" : "") + - ". Patients for a patient-level attribute, samples for a sample-level one. No individual value leaves the database" + - (t.singleStudy ? " except as a count of how many share it" : "") + ".", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - })); - if (!t.singleStudy) { - steps.push({ kind: "transform", title: "Add each cohort's counts and moments across its studies", - detail: "Value counts, n, Σx and Σx² add exactly. Vocabularies are not harmonized: the same value spelled differently in two studies stays two values. Medians and rank tests need each study's full distribution in one place, so they are not computed for a view that spans studies." }); - } - const skipped = t.attributes.filter((a) => a.skipped); - if (skipped.length) { - steps.push({ kind: "transform", title: "Set aside attributes that cannot be tested", - detail: "Each is named with its reason rather than dropped.", - params: skipped.map((a) => [a.attribute, a.skipped!] as [string, string]) }); - } - const fmtP = (p?: number) => p == null ? "—" : pFmt(p); - const cat = t.attributes.filter((a) => !a.skipped && a.kind === "cat"); - const num = t.attributes.filter((a) => !a.skipped && a.kind === "num"); - if (cat.length) { - steps.push({ kind: "statistic", title: "Pearson χ² for each categorical attribute", - detail: "On the cohort × value table over the full vocabulary, with no continuity correction; an all-zero row or column is dropped first. The smallest expected count is reported because the χ² approximation is poor below about 5.", - params: cat.map((a) => [a.attribute, a.distinct + " values · χ² " + a.statistic + " on " + a.df + " df · p " + fmtP(a.p) + - " · smallest expected " + a.minExpected] as [string, string]) }); - } - if (num.length) { - steps.push({ kind: "statistic", title: k === 2 ? "Welch's t-test for each numeric attribute" : "Welch's ANOVA for each numeric attribute", - detail: (k === 2 - ? "Unequal-variance t from each cohort's n, mean and variance, with Welch–Satterthwaite degrees of freedom; two-sided." - : "The unequal-variance F test from each cohort's n, mean and variance; a cohort with fewer than 2 values or no variance is left out.") + - " Mean and variance are from the summed moments: variance = (Σx² − (Σx)²/n) / (n − 1).", - params: num.map((a) => [a.attribute, (k === 2 ? "t " + a.statistic + " on " + a.df + " df" : "F " + a.statistic + " on " + a.df + " and " + a.df2 + " df") + - " · p " + fmtP(a.p)] as [string, string]) }); - if (t.singleStudy) { - steps.push({ kind: "statistic", title: k === 2 ? "Mann–Whitney U alongside" : "Kruskal–Wallis alongside", - detail: "From each cohort's complete value distribution: ranks averaged within tied values, the variance tie-corrected" + - (k === 2 ? ", the normal approximation with a 0.5 continuity correction; two-sided" : "; H on " + (k - 1) + " df") + - ". Medians and quartiles from the same distribution, interpolating linearly between ranks. A check against skew and outliers — not part of the q-value correction.", - params: num.map((a) => [a.attribute, a.histogramTruncated ? "not computed: too many distinct values to send the distribution whole" - : a.rankStatistic == null ? "—" : (k === 2 ? "U " : "H ") + a.rankStatistic + " · p " + fmtP(a.rankP)] as [string, string]) }); - } - } - const tested = t.attributes.filter((a) => a.p != null); - steps.push({ kind: "statistic", title: "Benjamini–Hochberg across attributes", - detail: "q-values over the " + tested.length + " tested attributes, from the χ² and Welch p-values together." }); - - const hasCat = t.categorical.length > 0, hasNum = t.numeric.length > 0, hasHist = t.histograms.length > 0; - const python = [ - "# Recompute with scipy and statsmodels: scipy " + VERIFIED.welch.version + " and statsmodels " + VERIFIED.anova.version + - " are the versions our code is tested against.", - "import io", - "import numpy as np", - "import pandas as pd", - ...((names) => names.length ? ["from scipy.stats import " + names.join(", ")] : [])( - [hasCat ? "chi2_contingency" : "", hasHist ? (k === 2 ? "mannwhitneyu" : "kruskal") : "", hasNum && k === 2 ? "ttest_ind_from_stats" : ""].filter(Boolean)), - ...(hasNum && k > 2 ? ["from statsmodels.stats.oneway import anova_generic"] : []), - "from statsmodels.stats.multitest import multipletests", - "", - "cohorts = " + JSON.stringify(t.cohorts), - RESULT_INIT, - "p_values = {}", - ...(hasCat ? [ - "", - ...pyTable("categorical", "Categorical attributes: what the attributeStats query returned per cohort and study, a count per value", - ["cohort", "study", "attribute", "value", "n"], t.categorical.map((r) => [r.cohort, r.study, r.attribute, r.value, r.n]), "categorical_counts.csv"), - "categorical[\"value\"] = categorical[\"value\"].astype(str)", - "for attr, g in categorical.groupby(\"attribute\", sort=False):", - " table = g.pivot_table(index=\"cohort\", columns=\"value\", values=\"n\", aggfunc=\"sum\", fill_value=0).reindex(cohorts, fill_value=0)", - " table = table.loc[table.sum(axis=1) > 0, table.sum(axis=0) > 0] # drop an all-zero row or column", - " p = chi2_contingency(table.to_numpy(), correction=False).pvalue if min(table.shape) >= 2 else 1.0", - " p_values[attr] = p", - " print(f\"{attr}: chi-squared p={p:.4g}\")", - ] : []), - ...(hasNum ? [ - "", - ...pyTable("numeric", "Numeric attributes: n, sum and sum of squares per cohort and study", ["cohort", "study", "attribute", "n", "sum", "sumsq"], - t.numeric.map((r) => [r.cohort, r.study, r.attribute, r.n, r.sum, r.sumsq]), "numeric_moments.csv"), - "m = numeric.groupby([\"attribute\", \"cohort\"])[[\"n\", \"sum\", \"sumsq\"]].sum()", - "m[\"mean\"] = m[\"sum\"] / m[\"n\"]", - "m[\"var\"] = ((m[\"sumsq\"] - m[\"sum\"] ** 2 / m[\"n\"]) / (m[\"n\"] - 1)).clip(lower=0) # rounding can put a constant attribute's just below 0", - "for attr in numeric[\"attribute\"].unique():", - " g = m.loc[attr].reindex(cohorts)", - ...(k === 2 ? [ - " a, b = g.iloc[0], g.iloc[1]", - " p = ttest_ind_from_stats(a[\"mean\"], np.sqrt(a[\"var\"]), a[\"n\"], b[\"mean\"], np.sqrt(b[\"var\"]), b[\"n\"], equal_var=False).pvalue", - " print(f\"{attr}: Welch t p={p:.4g}\")", - ] : [ - " g = g[(g[\"n\"] >= 2) & (g[\"var\"] > 0)] # a cohort with under 2 values or no variance says nothing", - " p = anova_generic(g[\"mean\"].to_numpy(), g[\"var\"].to_numpy(), g[\"n\"].to_numpy(), use_var=\"unequal\", welch_correction=True).pvalue", - " print(f\"{attr}: Welch ANOVA p={p:.4g}\")", - ]), - " p_values[attr] = p", - ] : []), - ...(hasHist ? [ - "", - ...pyTable("histograms", "Numeric attributes, single study: each cohort's complete value distribution", ["cohort", "attribute", "value", "n"], - t.histograms.map((r) => [r.cohort, r.attribute, r.value, r.n]), "value_histograms.csv"), - "for attr, g in histograms.groupby(\"attribute\", sort=False):", - " values = [np.repeat(h[\"value\"].to_numpy(), h[\"n\"].to_numpy()) for _, h in g.groupby(\"cohort\", sort=False)]", - " if len(values) < len(cohorts):", - " continue # a cohort's distribution was too large to send whole: no rank test, as in the view", - " for c, v in zip(g[\"cohort\"].unique(), values):", - " print(f\"{attr} {c}: median={np.percentile(v, 50):.3f}, IQR={np.percentile(v, 25):.3f}-{np.percentile(v, 75):.3f}\")", - " for name, q in ((\"median\", 50), (\"q1\", 25), (\"q3\", 75)):", - " RESULT[f\"clinical:{attr}:{c}:{name}\"] = np.percentile(v, q)", - k === 2 - ? " RESULT[f\"clinical:{attr}:rank_p\"] = mannwhitneyu(*values, alternative='two-sided', use_continuity=True, method='asymptotic').pvalue" - : " RESULT[f\"clinical:{attr}:rank_p\"] = kruskal(*values).pvalue", - " print(f\"{attr}: " + (k === 2 ? "Mann-Whitney" : "Kruskal-Wallis") + " p={RESULT[f'clinical:{attr}:rank_p']:.4g}\")", - ] : []), - "", - "q = multipletests(list(p_values.values()), method=\"fdr_bh\")[1]", - "print(pd.DataFrame({\"attribute\": list(p_values), \"p\": list(p_values.values()), \"q\": q}).sort_values(\"p\").to_string(index=False))", - "for (attr, p), qv in zip(p_values.items(), q):", - " RESULT[f\"clinical:{attr}:p\"], RESULT[f\"clinical:{attr}:q\"] = p, qv", - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: [...(cat.length ? ["chiSquareTest"] : []), ...(num.length ? [k === 2 ? "welchT" : "welchAnovaTest"] : []), - ...(num.length && t.singleStudy ? [k === 2 ? "mannWhitneyU" : "kruskalWallis", "quantilesFromHistogram"] : []), "benjaminiHochberg"], - file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "χ² matches scipy " + VERIFIED.chi2.version + " chi2_contingency; Welch's t scipy ttest_ind(equal_var=False) and Welch's ANOVA statsmodels " + - VERIFIED.anova.version + " anova_generic from the same moments; Mann–Whitney, Kruskal–Wallis and quantiles scipy/numpy on the expanded distribution; " + - "Benjamini–Hochberg statsmodels multipletests — all to a relative 1e-6 (test/tables-golden.test.ts, test/moments-golden.test.ts).", - python: withResult(python), - check: { - ...checks("clinical:", t.attributes.filter((a) => !a.skipped), (a) => a.attribute, [["p", "p"], ["q", "q"], ["rankP", "rank_p"]]), - // medians and quartiles are reported to 3 decimals - ...checks("clinical:", t.medians, (m) => m.attribute + ":" + m.cohort, - [["median", "median", { abs: 5e-4 + 1e-9 }], ["q1", "q1", { abs: 5e-4 + 1e-9 }], ["q3", "q3", { abs: 5e-4 + 1e-9 }]]), - }, - }; -} - -// ---- coexpression: Pearson from six sums, Fisher-z meta-analysis across studies, BH ----------- - -export interface CoexpressionTrace { - cohort: string; - profileType: string; - cohortPatients: { study: string; patients: number }[]; - sums: { study: string; a: string; b: string; n: number; sx: number; sy: number; sxy: number; sxx: number; syy: number }[]; - pairs: number; - tested: number; - /** what the view reported per pair — for the script check */ - results: { a: string; b: string; r: number | null; p: number; q: number; pMeta?: number }[]; -} - -export function coexpressionMethod(t: CoexpressionTrace): MethodRecord { - const multi = t.cohortPatients.length > 1; - const steps: MethodStep[] = t.cohortPatients.map((s) => ({ - kind: "fetch" as const, title: "Sum each gene pair's values", - detail: "The pairMoments query above, on “" + t.profileType + "”. Per pair, over the patients with a value for both genes (a patient with several samples contributes their average): n, Σx, Σy, Σxy, Σx² and Σy² — six sums, no individual value.", - scope: t.cohort + " · " + s.study, rowsIn: s.patients, params: [["pairs", String(t.pairs)]], - })); - if (multi) steps.push({ kind: "transform", title: "Add the six sums across studies", - detail: "Every one is a sum, so the pooled totals — and the correlation of all patients taken together — are exact." }); - steps.push({ kind: "statistic", title: "Pearson correlation per pair" + (multi ? ", pooled" : ""), - detail: "r = (nΣxy − ΣxΣy) / √((nΣx² − (Σx)²)(nΣy² − (Σy)²)); t = r√((n − 2)/(1 − r²)), two-sided p on n − 2 df. Undefined for n < 3 or a constant series." }); - if (multi) steps.push({ kind: "statistic", title: "Fisher-z meta-analysis across studies", - detail: "Each study's own r → z = atanh(r), weighted by n − 3 (fixed effect); a study with n < 4 or |r| = 1 is left out. Pooled z / SE against the normal, two-sided. This asks whether the genes move together within a study, which differing study means can hide in the pooled r; it is the p that is corrected and ranked." }); - steps.push({ kind: "statistic", title: "Benjamini–Hochberg across pairs", - detail: "q-values over the " + t.tested + " pairs with a defined correlation, from the " + (multi ? "meta-analysis" : "Pearson") + " p-values." }); - const python = [ - "# Recompute with scipy and statsmodels: scipy " + VERIFIED.pearson.version + " and statsmodels " + VERIFIED.meta.version + - " are the versions our code is tested against.", - "import io", - "import numpy as np", - "import pandas as pd", - "from scipy.stats import t as t_dist", - ...(multi ? ["from statsmodels.stats.meta_analysis import combine_effects"] : []), - "from statsmodels.stats.multitest import multipletests", - "", - RESULT_INIT, - ...pyTable("sums", "What the pairMoments query returned for each study, cohort " + JSON.stringify(t.cohort) + ", profile " + t.profileType, - ["study", "a", "b", "n", "sx", "sy", "sxy", "sxx", "syy"], t.sums.map((r) => [r.study, r.a, r.b, r.n, r.sx, r.sy, r.sxy, r.sxx, r.syy]), "pair_sums.csv"), - "", - "def pearson(s):", - " # the same r and p scipy.stats.pearsonr gives on the values these sums came from", - " vx, vy = s.n * s.sxx - s.sx ** 2, s.n * s.syy - s.sy ** 2", - " if s.n < 3 or vx <= 0 or vy <= 0:", - " return None, 1.0", - " r = float(np.clip((s.n * s.sxy - s.sx * s.sy) / np.sqrt(vx * vy), -1, 1))", - " if abs(r) == 1:", - " return r, 0.0", - " t = r * np.sqrt((s.n - 2) / (1 - r * r))", - " return r, 2 * t_dist.sf(abs(t), s.n - 2)", - "", - "rows = []", - "for (a, b), g in sums.groupby([\"a\", \"b\"], sort=False):", - " r, p = pearson(g[[\"n\", \"sx\", \"sy\", \"sxy\", \"sxx\", \"syy\"]].sum())", - " row = {\"a\": a, \"b\": b, \"r\": r, \"p\": p}", - ...(multi ? [ - " if len(g) > 1: # in more than one study: meta-analyse the per-study correlations", - " per = [(pearson(s)[0], s.n) for s in g.itertuples()]", - " per = [(r_, n) for r_, n in per if r_ is not None and n >= 4 and abs(r_) < 1]", - " if per:", - " z = np.arctanh([r_ for r_, _ in per]); var = np.array([1 / (n - 3) for _, n in per])", - " res = combine_effects(z, var, method_re=\"chi2\")", - " zf, se = res.mean_effect_fe, res.sd_eff_w_fe", - " row.update(r_meta=np.tanh(zf), p_meta=2 * t_dist.sf(abs(zf / se), np.inf)) # normal tail", - " else:", - " row.update(r_meta=None, p_meta=1.0) # no study had a usable correlation", - ] : []), - " rows.append(row)", - "", - "result = pd.DataFrame(rows)", - "tested = result[result[\"r\"].notna()]", - "p = " + (multi ? "tested[\"p_meta\"].fillna(tested[\"p\"])" : "tested[\"p\"]"), - "result.loc[tested.index, \"q\"] = multipletests(p, method=\"fdr_bh\")[1]", - "print(result.reindex(result[\"r\"].abs().sort_values(ascending=False).index).to_string(index=False))", - "for r in result.itertuples():", - " for f in (\"r\", \"p\", \"q\"" + (multi ? ", \"p_meta\"" : "") + "):", - " RESULT[f\"coexpression:{r.a}/{r.b}:{f}\"] = getattr(r, f)", - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: ["pearsonFromMoments", ...(multi ? ["fisherZMeta"] : []), "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "Pearson's r and p from the six sums match scipy " + VERIFIED.pearson.version + " pearsonr on the underlying values" + - (multi ? ", and the Fisher-z meta-analysis statsmodels " + VERIFIED.meta.version + " " + VERIFIED.meta.fn : "") + - ", to a relative 1e-6 (test/moments-golden.test.ts).", - python: withResult(python), - // r is reported to 4 decimals - check: checks("coexpression:", t.results.filter((x) => x.r != null), (x) => x.a + "/" + x.b, - [["r", "r", { abs: 5e-5 + 1e-12 }], ["p", "p"], ["q", "q"], ["pMeta", "p_meta"]]), - }; -} - -// ---- diffexpr: Welch t per gene from moments, BH, preranked GSEA with a seeded null ------------ - -/** python/golden/gsea_null.py verbatim — the Python port of our seeded GSEA null; test/gsea-golden.test.ts - * holds the two to the same text and proves the port gives our p-values exactly. */ -export const GSEA_NULL_PY = "import math\n\n\ndef seed_of(s):\n \"\"\"FNV-1a over the name's UTF-16 code units: the seed each set's null is drawn from.\"\"\"\n h = 0x811C9DC5\n for unit in memoryview(s.encode(\"utf-16-le\")).cast(\"H\"):\n h = ((h ^ unit) * 0x01000193) & 0xFFFFFFFF\n return h\n\n\ndef mulberry32(seed):\n a = seed & 0xFFFFFFFF\n\n def rnd():\n nonlocal a\n a = (a + 0x6D2B79F5) & 0xFFFFFFFF\n t = a\n t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF\n t ^= (t + ((t ^ (t >> 7)) * (t | 61) & 0xFFFFFFFF)) & 0xFFFFFFFF\n return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296\n\n return rnd\n\n\ndef enrichment_score(positions, weights, n):\n \"\"\"Weighted (p = 1) running-sum ES from the members' 0-based ranks, ascending.\"\"\"\n k = len(positions)\n if not k or k >= n:\n return 0.0\n nr = sum(weights)\n unweighted = not nr > 0\n if unweighted:\n nr = k\n miss = 1 / (n - k)\n cum, best = 0.0, 0.0\n for i, (pos, w) in enumerate(zip(positions, weights)):\n before = cum / nr - (pos - i) * miss\n if abs(before) > abs(best):\n best = before\n cum += 1 if unweighted else w\n after = cum / nr - (pos + 1 - (i + 1)) * miss\n if abs(after) > abs(best):\n best = after\n return best\n\n\ndef preranked_gsea(ranked, sets, permutations=1000, min_size=10):\n \"\"\"ranked: [(gene, score)] best first; sets: {name: [genes]}. Returns {name: (size, es, nes, p)}.\n The null for each set is `permutations` random sets of the same size drawn from the same ranking\n (Floyd's sampling), from a generator seeded by the set's name, so p is the same on every run.\"\"\"\n n = len(ranked)\n rank_of = {}\n for i, (g, _) in enumerate(ranked):\n rank_of.setdefault(g, i)\n abs_w = [abs(s) for _, s in ranked]\n out = {}\n for name, genes in sets.items():\n pos = sorted({rank_of[g] for g in set(genes) if g in rank_of})\n k = len(pos)\n if k < min_size or k >= n:\n continue\n es = enrichment_score(pos, [abs_w[i] for i in pos], n)\n rnd = mulberry32(seed_of(name))\n null_pos, null_neg = [], []\n for _ in range(permutations):\n chosen = set()\n for j in range(n - k, n):\n t = math.floor(rnd() * (j + 1))\n chosen.add(j if t in chosen else t)\n pick = sorted(chosen)\n e = enrichment_score(pick, [abs_w[i] for i in pick], n)\n (null_pos if e >= 0 else null_neg).append(e)\n mean = lambda xs: sum(xs) / len(xs) if xs else 0.0\n if es >= 0:\n nes = es / mean(null_pos) if mean(null_pos) > 0 else 0.0\n same, beyond = null_pos, sum(1 for e in null_pos if e >= es)\n else:\n nes = -es / mean(null_neg) if mean(null_neg) < 0 else 0.0\n same, beyond = null_neg, sum(1 for e in null_neg if e <= es)\n out[name] = (k, es, nes, (beyond + 1) / (len(same) + 1))\n return out\n"; - -export interface DiffexprTrace { - profileType: string; - log2: boolean; - groups: [string, string]; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - /** per cohort, summed over its studies: each gene's n, Σv and Σv² */ - moments: { cohort: string; gene: string; n: number; sx: number; sxx: number }[]; - minN: number; - tested: number; - skipped: number; - gsea?: { sets: { name: string; genes: string[] }[]; tested: number; skipped: string[]; permutations: number; minSize: number }; - /** what the view reported — genes drawn as points, and each set tested — for the script check */ - results: { genes: { gene: string; p: number; q: number }[]; sets: { name: string; es: number; nes: number; p: number }[] }; -} - -export function diffexprMethod(t: DiffexprTrace): MethodRecord { - const [A, B] = t.groups; - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Sum every gene's values", - detail: "The diffexpr:moments query above, on “" + t.profileType + "”" + (t.log2 ? " as log2(v + 1) (a negative stored value has no log and is left out, not clamped)" : " as stored") + - ". Per gene, over the cohort's patients with a value (a patient with several samples contributes their average): n, Σv and Σv². Every gene in the profile, no individual value.", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - })); - if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's sums across its studies", - detail: "The totals add exactly; there is no batch correction between studies." }); - steps.push({ kind: "transform", title: "Leave out genes that cannot be tested", - detail: "A gene with fewer than " + t.minN + " patients with a value in either cohort, or with no variance (Var A + Var B ≤ 1e-9 · (1 + mean A² + mean B²) — judged to a tolerance, since a constant gene's variance from the sums is rounding noise).", - rowsIn: t.tested + t.skipped, rowsOut: t.tested }); - steps.push({ kind: "statistic", title: "Welch's t-test per gene", - detail: "Mean A − mean B (" + A + " minus " + B + ") with each cohort's variance (Σv² − (Σv)²/n)/(n − 1); unequal-variance t, Welch–Satterthwaite df, two-sided. With thousands of patients a tiny difference is significant: read the effect too.", - params: [["genes tested", t.tested.toLocaleString()]] }); - steps.push({ kind: "statistic", title: "Benjamini–Hochberg across genes", detail: "q-values over all " + t.tested.toLocaleString() + " genes tested." }); - if (t.gsea) steps.push({ kind: "statistic", title: "Preranked gene-set enrichment (GSEA)", - detail: "Genes ranked by the unrounded t, " + A + " over " + B + " first. Enrichment score as in Subramanian et al. 2005 with weight 1 (each member's step is |t| / Σ|t| of the members). Significance from " + - t.gsea.permutations + " random sets of the same size drawn from the same ranking, from a generator seeded by the set's name — so a shared link gives the same p — with p = (b + 1)/(m + 1) over the m null scores of the same sign and NES = ES / mean of those. Gene-set permutation ignores correlation between genes, so it is anti-conservative next to phenotype permutation. A set with fewer than " + - t.gsea.minSize + " ranked members is not tested. q is Benjamini–Hochberg across the sets tested.", - params: [["sets tested", String(t.gsea.tested)], ...(t.gsea.skipped.length ? [["too small to test", t.gsea.skipped.join(", ")] as [string, string]] : [])] }); - const python = [ - "# Recompute with scipy and statsmodels; the enrichment uses the port of our seeded null below,", - "# which reproduces our permutation p-values exactly (test/gsea-golden.test.ts). Its enrichment scores match gseapy " + VERIFIED.gsea.version + " prerank.", - "import io", - "import json", - "import numpy as np", - "import pandas as pd", - "from scipy.stats import ttest_ind_from_stats", - "from statsmodels.stats.multitest import multipletests", - "", - RESULT_INIT, - ...pyTable("moments", "Each gene's n, sum and sum of squares per cohort (" + (t.log2 ? "log2(v + 1)" : "as stored") + "), from the diffexpr:moments queries, summed over each cohort's studies", - ["cohort", "gene", "n", "sx", "sxx"], t.moments.map((r) => [r.cohort, r.gene, r.n, r.sx, r.sxx]), "diffexpr_moments.csv"), - "", - "a = moments[moments[\"cohort\"] == " + py(A) + "].set_index(\"gene\")", - "b = moments[moments[\"cohort\"] == " + py(B) + "].set_index(\"gene\")", - "g = a.join(b, lsuffix=\"_a\", rsuffix=\"_b\", how=\"inner\")", - "g[\"mean_a\"], g[\"mean_b\"] = g[\"sx_a\"] / g[\"n_a\"], g[\"sx_b\"] / g[\"n_b\"]", - "g[\"var_a\"] = ((g[\"sxx_a\"] - g[\"sx_a\"] ** 2 / g[\"n_a\"]) / (g[\"n_a\"] - 1)).clip(lower=0) # a constant gene's is rounding noise, sometimes below 0", - "g[\"var_b\"] = ((g[\"sxx_b\"] - g[\"sx_b\"] ** 2 / g[\"n_b\"]) / (g[\"n_b\"] - 1)).clip(lower=0)", - "keep = (g[\"n_a\"] >= " + t.minN + ") & (g[\"n_b\"] >= " + t.minN + ") & (g[\"var_a\"] + g[\"var_b\"] > 1e-9 * (1 + g[\"mean_a\"] ** 2 + g[\"mean_b\"] ** 2))", - "g = g[keep].copy()", - "g[\"t\"], g[\"p\"] = ttest_ind_from_stats(g[\"mean_a\"], np.sqrt(g[\"var_a\"]), g[\"n_a\"], g[\"mean_b\"], np.sqrt(g[\"var_b\"]), g[\"n_b\"], equal_var=False)", - "g[\"effect\"] = g[\"mean_a\"] - g[\"mean_b\"]", - "g[\"q\"] = multipletests(g[\"p\"], method=\"fdr_bh\")[1]", - "print(len(g), \"genes tested\")", - "print(g.sort_values(\"p\")[[\"effect\", \"t\", \"p\", \"q\"]].head(20))", - "RESULT[\"diffexpr:tested\"] = len(g)", - "for gene, r in g.iterrows():", - " RESULT[f\"diffexpr:{gene}:p\"], RESULT[f\"diffexpr:{gene}:q\"] = r[\"p\"], r[\"q\"]", - ...(t.gsea ? [ - "", - "# ---- the port of our seeded GSEA null (python/golden/gsea_null.py) ----", - ...GSEA_NULL_PY.trimEnd().split("\n"), - "# ----", - "", - "sets = json.loads(" + py(JSON.stringify(Object.fromEntries(t.gsea.sets.map((x) => [x.name, x.genes])))) + ")", - "ranked = list(g.sort_values(\"t\", ascending=False, kind=\"stable\")[\"t\"].items())", - "res = preranked_gsea(ranked, sets, permutations=" + t.gsea.permutations + ", min_size=" + t.gsea.minSize + ")", - "out = pd.DataFrame([(k, *v) for k, v in res.items()], columns=[\"set\", \"size\", \"es\", \"nes\", \"p\"])", - "out[\"q\"] = multipletests(out[\"p\"], method=\"fdr_bh\")[1]", - "print(out.sort_values([\"q\", \"nes\"], key=lambda c: c if c.name == \"q\" else -c.abs()).to_string(index=False))", - "for r in out.itertuples():", - " RESULT[f\"gsea:{r.set}:es\"], RESULT[f\"gsea:{r.set}:nes\"], RESULT[f\"gsea:{r.set}:p\"] = r.es, r.nes, r.p", - ] : []), - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: ["welchT", "benjaminiHochberg", ...(t.gsea ? ["preRankedGsea"] : [])], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "Welch's t from moments matches scipy " + VERIFIED.welch.version + " ttest_ind(equal_var=False) and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/moments-golden.test.ts, test/tables-golden.test.ts)" + - (t.gsea ? "; enrichment scores match gseapy " + VERIFIED.gsea.version + " prerank, and NES and p match the script's port of our seeded null exactly (test/gsea-golden.test.ts)" : "") + ".", - python: withResult(python), - check: { - "diffexpr:tested": { v: t.tested, abs: 0 }, - ...checks("diffexpr:", t.results.genes, (g) => g.gene, [["p", "p", { rel: 1e-6, abs: 1e-300 }], ["q", "q", { rel: 1e-6, abs: 1e-300 }]]), - // ES and NES are reported to 4 and 3 decimals; the permutation p is a ratio of counts - ...checks("gsea:", t.results.sets, (x) => x.name, [["es", "es", { abs: 5e-5 + 1e-12 }], ["nes", "nes", { abs: 5e-4 + 1e-12 }], ["p", "p", { abs: 0 }]]), - }, - }; -} - -// ---- incidence: Aalen-Johansen with death competing, Gray-type permutation test ---------------- - -/** python/golden/gray_null.py verbatim — the Python port of our seeded Gray permutation test; - * test/incidence-golden.test.ts holds the two to the same text and the port to our exact p. */ -export const GRAY_NULL_PY = "import math\n\n\ndef mulberry32(seed):\n a = seed & 0xFFFFFFFF\n\n def rnd():\n nonlocal a\n a = (a + 0x6D2B79F5) & 0xFFFFFFFF\n t = a\n t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF\n t ^= (t + ((t ^ (t >> 7)) * (t | 61) & 0xFFFFFFFF)) & 0xFFFFFFFF\n return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296\n\n return rnd\n\n\ndef gray_statistic(t, cause, entry, group, K, order, entry_order):\n \"\"\"Sum over groups of (observed - expected)^2 / expected cause-1 events, expected from the pooled\n subdistribution hazard with each group's risk set Y_k (1 - F1_k) / S_k (Gray 1988, weight 1).\n order / entry_order: the records' indices sorted by exit and by entry time.\"\"\"\n n = len(t)\n Y, S, F = [0.0] * K, [1.0] * K, [0.0] * K\n z, E = [0.0] * K, [0.0] * K\n i = ei = 0\n while i < n:\n tj = t[order[i]]\n while ei < n and entry[entry_order[ei]] < tj:\n Y[group[entry_order[ei]]] += 1\n ei += 1\n d1, d2, cc = [0.0] * K, [0.0] * K, [0.0] * K\n while i < n and t[order[i]] == tj:\n r = order[i]\n i += 1\n if cause[r] == 1:\n d1[group[r]] += 1\n elif cause[r] == 2:\n d2[group[r]] += 1\n else:\n cc[group[r]] += 1\n R = [Y[k] * (1 - F[k]) / S[k] if Y[k] > 0 and S[k] > 0 else 0.0 for k in range(K)]\n D1, Rtot = 0.0, 0.0\n for k in range(K):\n D1 += d1[k]\n Rtot += R[k]\n if D1 > 0 and Rtot > 0:\n for k in range(K):\n e = R[k] * D1 / Rtot\n z[k] += d1[k] - e\n E[k] += e\n for k in range(K):\n if Y[k] > 0:\n F[k] += S[k] * d1[k] / Y[k]\n S[k] *= 1 - (d1[k] + d2[k]) / Y[k]\n Y[k] -= d1[k] + d2[k] + cc[k]\n stat = 0.0\n for k in range(K):\n if E[k] > 0:\n stat += z[k] * z[k] / E[k]\n return stat\n\n\ndef gray_permutation_test(groups, permutations=2000, seed=1):\n \"\"\"groups: per cohort, a list of (t, cause, entry or None). Returns (statistic, p): p = (b + 1)/(m + 1)\n over `permutations` seeded shuffles of the cohort labels across the pooled records.\"\"\"\n K = len(groups)\n rows = [(r[0], r[1], -math.inf if r[2] is None else r[2], k) for k, g in enumerate(groups) for r in g]\n t = [r[0] for r in rows]\n cause = [r[1] for r in rows]\n entry = [r[2] for r in rows]\n labels = [r[3] for r in rows]\n order = sorted(range(len(t)), key=lambda i: t[i])\n entry_order = sorted(range(len(t)), key=lambda i: entry[i])\n observed = gray_statistic(t, cause, entry, labels, K, order, entry_order)\n rnd = mulberry32(seed)\n perm = list(labels)\n n = len(perm)\n beyond = 0\n for _ in range(permutations):\n for i in range(n - 1, 0, -1):\n j = math.floor(rnd() * (i + 1))\n perm[i], perm[j] = perm[j], perm[i]\n if gray_statistic(t, cause, entry, perm, K, order, entry_order) >= observed - 1e-12:\n beyond += 1\n return observed, (beyond + 1) / (permutations + 1)\n"; - -export interface IncidenceTrace { - eventLabel: string; - anchor?: string; - horizons: number[]; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - cohorts: { name: string; n: number; events: number; competing: number; censored: number; late: number; - excluded: { prior: number; unobserved: number; noFollowup: number; noAnchor: number } }[]; - /** every (entry, t, cause) count the queries returned for patients who enter the risk set */ - counts: { cohort: string; study: string; entry: number; t: number; cause: number; n: number }[]; - gray?: { statistic: number; p: number; permutations: number }; - /** the view's estimate and interval at each horizon — for the script check */ - horizonResults: { cohort: string; months: number; f1: number; lo: number; hi: number }[]; -} - -export function incidenceMethod(t: IncidenceTrace): MethodRecord { - const hasEntry = t.counts.some((r) => r.entry > 0); - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Classify and count each patient's outcome", - detail: "The incidence query above. Per patient, months from " + (t.anchor ? "the " + t.anchor : "the study's day 0") + - " (days ÷ 30.4375, rounded to 4 decimals) to the first " + t.eventLabel + " after it (cause 1), death without it (cause 2, from OS_STATUS), or last follow-up (censored, OS_MONTHS)" + - (t.anchor ? ", and the delay from the anchor to day 0 as a delayed entry when the anchor comes first" : "") + - "; then counted by (entry, months, cause). Counts only.", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - })); - steps.push({ kind: "transform", title: "Set aside patients who cannot enter the risk set", - detail: "Named and counted rather than treated as event-free. A patient whose first event was recorded after their last follow-up is kept, as censored or dead at follow-up.", - params: t.cohorts.map((c) => [c.name, c.excluded.prior + " already had it " + (t.anchor ? "at or before the anchor" : "by day 0") + - (c.excluded.unobserved ? " · " + c.excluded.unobserved + " had it before observation began" : "") + - " · " + c.excluded.noFollowup + " without follow-up" + (t.anchor ? " · " + c.excluded.noAnchor + " with no anchor event" : "") + - (c.late ? " · " + c.late + " recorded late, kept" : "")] as [string, string]) }); - steps.push({ kind: "statistic", title: "Estimate cumulative incidence by Aalen–Johansen", - detail: "Death is a competing risk: at each time F1 rises by S(t−)·d1/R, with S the all-cause Kaplan–Meier survival and R those at risk" + - (hasEntry ? " (entered strictly before t: delayed entry honoured)" : "") + - ". Standard error by the delta method (Aalen 1978); 95% interval F1 ± 1.959964·SE, clamped to [0, 1]. At each horizon the curve's value there, beside 1 − Kaplan–Meier of the event alone, which counts the dead as still at risk and overstates incidence.", - params: [["horizons (months)", t.horizons.join(", ")], - ...t.cohorts.map((c) => [c.name, c.n.toLocaleString() + " at risk · " + c.events + " events · " + c.competing + " deaths first · " + c.censored + " censored"] as [string, string])] }); - if (t.gray) steps.push({ kind: "statistic", title: "Gray-type test by permutation", - detail: "Per cohort, observed cause-1 events minus those expected from the pooled subdistribution hazard, the subdistribution risk set being Y·(1 − F1)/S (Gray 1988, weight 1), summed as Σ (O − E)²/E over cohorts. This is not Gray's variance-standardised χ², so its p does not come from a χ² table: the cohort labels are shuffled across the pooled records " + - t.gray.permutations.toLocaleString() + " times by a generator seeded with 1, and p = (b + 1)/(m + 1). Exact under the null that every cohort's records, entry times included, come from one distribution.", - params: [["statistic / p", t.gray.statistic.toFixed(4) + " / " + pFmt(t.gray.p)]] }); - const python = [ - "# Recompute: cumulative incidence with statsmodels" + (hasEntry ? " (or, with delayed entry, the short function below — statsmodels takes no entry times and lifelines jitters tied ones)" : "") + - "; the Gray-type test with the port of our seeded permutation test below, which reproduces our p exactly (test/incidence-golden.test.ts).", - "import io", - "import numpy as np", - "import pandas as pd", - ...(hasEntry ? [] : ["from statsmodels.duration.survfunc import CumIncidenceRight"]), - "", - ...pyTable("counts", "What the incidence queries returned for the patients who enter the risk set: n per (entry, months, cause); cause 1 the event, 2 death first, 0 censored", - ["cohort", "study", "entry", "t", "cause", "n"], t.counts.map((r) => [r.cohort, r.study, r.entry, r.t, r.cause, r.n]), "incidence_counts.csv"), - "df = counts.loc[counts.index.repeat(counts[\"n\"])].reset_index(drop=True) # one row per patient", - "horizons = " + JSON.stringify(t.horizons), - RESULT_INIT, - "", - ...(hasEntry ? [ - "def aalen_johansen(t, cause, entry):", - " \"\"\"F1, F2 and the delta-method SE of F1 at each time anyone leaves; at risk = entered strictly before t, not yet left.\"\"\"", - " t, cause, entry = map(np.asarray, (t, cause, entry))", - " times = np.unique(t)", - " S, F1, F2, hist, out = 1.0, 0.0, 0.0, [], []", - " for x in times:", - " R = np.sum((entry < x) & (t >= x))", - " d1, d2 = np.sum((t == x) & (cause == 1)), np.sum((t == x) & (cause == 2))", - " if R <= 0:", - " continue", - " Sprev, d = S, d1 + d2", - " F1 += Sprev * d1 / R; F2 += Sprev * d2 / R; S *= 1 - d / R", - " hist.append((F1, Sprev, d, d1, R))", - " if d > 0:", - " v = sum(((F1 - hF1) ** 2 * hd / (hR * (hR - hd)) if hR - hd > 0 else 0) + hS ** 2 * hd1 * (hR - hd1) / hR ** 3 - 2 * (F1 - hF1) * hS * hd1 / hR ** 2", - " for hF1, hS, hd, hd1, hR in hist if hd > 0)", - " out.append((x, F1, F2, np.sqrt(max(v, 0))))", - " return pd.DataFrame(out, columns=[\"t\", \"f1\", \"f2\", \"se1\"])", - "", - ] : []), - "for cohort, g in df.groupby(\"cohort\", sort=False):", - ...(hasEntry ? [ - " cif = aalen_johansen(g[\"t\"], g[\"cause\"], np.where(g[\"entry\"] > 0, g[\"entry\"], -np.inf))", - ] : [ - " c = CumIncidenceRight(g[\"t\"].to_numpy(), g[\"cause\"].to_numpy())", - " cif = pd.DataFrame({\"t\": c.times, \"f1\": c.cinc[0], \"se1\": c.cinc_se[0]})", - ]), - " for h in horizons:", - " at = cif[cif[\"t\"] <= h].tail(1)", - " f1, se = (float(at[\"f1\"].iloc[0]), float(at[\"se1\"].iloc[0])) if len(at) else (0.0, 0.0)", - " lo, hi = max(0, f1 - 1.959963984540054 * se), min(1, f1 + 1.959963984540054 * se) # 95%: the exact normal quantile", - " print(f\"{cohort} at {h} months: {f1:.4f} (95% CI {lo:.4f}-{hi:.4f})\")", - " RESULT[f\"incidence:{cohort}:{h}:f1\"], RESULT[f\"incidence:{cohort}:{h}:lo\"], RESULT[f\"incidence:{cohort}:{h}:hi\"] = f1, lo, hi", - ...(t.gray ? [ - "", - "# ---- the port of our seeded Gray-type permutation test (python/golden/gray_null.py) ----", - ...GRAY_NULL_PY.trimEnd().split("\n"), - "# ----", - "", - "cohorts = " + JSON.stringify(t.cohorts.map((c) => c.name)), - "# records in the order the test sees them: by time, then cause, then entry (none before any)", - "groups = [[(r.t, r.cause, r.entry if r.entry > 0 else None)", - " for r in df[df[\"cohort\"] == c].sort_values([\"t\", \"cause\", \"entry\"], kind=\"stable\").itertuples()] for c in cohorts]", - "stat, p = gray_permutation_test(groups, permutations=" + t.gray.permutations + ", seed=1) # pure Python: minutes for a large cohort", - "print(f\"Gray-type statistic {stat:.4f}, permutation p {p:.4g}\")", - "RESULT[\"gray:statistic\"], RESULT[\"gray:p\"] = stat, p", - ] : []), - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: ["aalenJohansen", "cifAt", "kaplanMeier", ...(t.gray ? ["grayPermutationTest"] : [])], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "Cumulative incidence and its standard error match statsmodels " + VERIFIED.cif.version + " CumIncidenceRight to 1e-12, and with delayed entry lifelines' AalenJohansenFitter" + - (t.gray ? "; the Gray-type statistic and permutation p match the script's port exactly" : "") + " (test/incidence-golden.test.ts).", - python: withResult(python), - check: { - ...checks("incidence:", t.horizonResults, (h) => h.cohort + ":" + h.months, [["f1", "f1", { abs: 1e-9 }], ["lo", "lo", { abs: 1e-9 }], ["hi", "hi", { abs: 1e-9 }]]), - // the port reproduces the permutation test exactly - ...(t.gray ? { "gray:statistic": { v: t.gray.statistic, rel: 1e-12 }, "gray:p": { v: t.gray.p, abs: 0 } } : {}), - }, - }; -} - -// ---- paired: concordance within patients, exact McNemar, BH -------------------------------------- - -export interface PairedTrace { - pairBy: { attribute: string; a: string[]; b: string[] }; - /** each study's own sides, when they differ from `pairBy` */ - perStudy?: Record; - labels?: { a: string; b: string }; - measure: "driver" | "any"; - oncokbPin: string | null; - /** present when every altered gene was tested and the `top` most one-sided drawn */ - scan?: { top: number }; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - panels: { cohort: string; patients: number; tested?: number }[]; - counts: { cohort: string; study: string; gene: string; pairs: number; both: number; aOnly: number; bOnly: number }[]; - /** what the view reported per cohort and gene — for the script check */ - results: { cohort: string; gene: string; p: number; q?: number }[]; -} - -export function pairedMethod(t: PairedTrace): MethodRecord { - const aL = t.labels?.a ?? t.pairBy.a.join("/"), bL = t.labels?.b ?? t.pairBy.b.join("/"); - const what = t.measure === "driver" ? driverMeaning(t.oncokbPin).replace(/^driver: /, "") : ALTERED_MEANING.replace(/^altered: /, ""); - const steps: MethodStep[] = t.perSite.map((s) => { - const sp = t.perStudy?.[s.study] || t.pairBy; - return { - kind: "fetch" as const, title: "Count each gene's state in paired samples", - detail: "The paired:patients and paired:genes queries above. Patients with both a " + sp.attribute + " = " + sp.a.join("/") + " sample (" + aL + ") and a " + - sp.attribute + " = " + sp.b.join("/") + " sample (" + bL + "); per gene" + (t.scan ? " — every gene altered (" + what + ") in some such patient" : "") + - ", among those profiled for it on both sides by each side's own panels: altered (" + what + ") on both sides, on the " + aL + - " side only, or the " + bL + " side only. A patient with several samples on one side counts as altered there if any is. Counts only.", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - }; - }); - if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's counts across its studies", detail: "The counts add exactly." }); - steps.push({ kind: "statistic", title: "Exact McNemar test per gene", - detail: "Only the discordant patients carry information about direction: of the " + aL + "-only and " + bL + "-only patients, each is a fair coin under the null, so p is twice the smaller binomial tail (capped at 1). Concordance is both ÷ altered anywhere.", - params: t.panels.map((p) => [p.cohort, p.patients.toLocaleString() + " patients with both kinds of sample"] as [string, string]) }); - steps.push({ kind: "statistic", title: "Benjamini–Hochberg across genes, per cohort", detail: "q-values over the genes with at least one discordant patient, separately for each cohort." + - (t.scan ? " In a scan that is every such gene, not only the ones drawn." : "") }); - if (t.scan) steps.push({ kind: "transform", title: "Draw the " + t.scan.top + " most one-sided genes", - detail: "Of the genes with a discordant patient, ordered by McNemar p, then by the size of the imbalance between " + aL + "-only and " + bL + - "-only patients, then by the number of discordant patients, then by name; the first " + t.scan.top + " are drawn and reported.", - params: t.panels.map((p) => [p.cohort, (p.tested ?? 0).toLocaleString() + " genes tested"] as [string, string]) }); - const python = [ - "# Recompute with statsmodels " + VERIFIED.mcnemar.version + ", the version our code is tested against.", - "import io", - "import pandas as pd", - "from statsmodels.stats.contingency_tables import mcnemar", - "from statsmodels.stats.multitest import multipletests", - "", - RESULT_INIT, - ...pyTable("counts", "What the paired:genes queries returned per cohort and study: patients altered on both sides, on the " + aL + " side only, on the " + bL + " side only, of those profiled on both", - ["cohort", "study", "gene", "pairs", "both", "a_only", "b_only"], t.counts.map((r) => [r.cohort, r.study, r.gene, r.pairs, r.both, r.aOnly, r.bOnly]), "paired_counts.csv"), - "g = counts.groupby([\"cohort\", \"gene\"], sort=False)[[\"pairs\", \"both\", \"a_only\", \"b_only\"]].sum().reset_index()", - "g[\"concordance\"] = g[\"both\"] / (g[\"both\"] + g[\"a_only\"] + g[\"b_only\"])", - "g[\"p\"] = [mcnemar([[r.both, r.a_only], [r.b_only, 0]], exact=True).pvalue if r.a_only + r.b_only else 1.0 for r in g.itertuples()]", - "for cohort, c in g.groupby(\"cohort\", sort=False):", - " tested = c[c[\"a_only\"] + c[\"b_only\"] > 0]", - " g.loc[tested.index, \"q\"] = multipletests(tested[\"p\"], method=\"fdr_bh\")[1] if len(tested) else []", - "print(g.to_string(index=False))", - "for r in g.itertuples():", - " RESULT[f\"paired:{r.cohort}:{r.gene}:p\"] = r.p", - " if r.a_only + r.b_only:", - " RESULT[f\"paired:{r.cohort}:{r.gene}:q\"] = r.q", - ].join("\n") + "\n"; - return { steps, implementation: { functions: ["mcnemarExact", "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "The exact McNemar test matches statsmodels " + VERIFIED.mcnemar.version + " mcnemar(exact=True) and Benjamini–Hochberg multipletests, to a relative 1e-6 (test/tables-golden.test.ts).", - python: withResult(python), - check: checks("paired:", t.results, (r) => r.cohort + ":" + r.gene, [["p", "p"], ["q", "q"]]) }; -} - -// ---- genome: per-bin gain / loss frequency, Fisher per bin, BH ---------------------------------- - -export interface GenomeTrace { - build: string; binMb: number; threshold: number; includeX: boolean; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - cohorts: { name: string; samples: number; segmented: number }[]; - bins: number; - pastEnd: number; - /** per cohort, summed over its studies: each bin's samples covered, gained and lost (bins with coverage only) */ - counts: { cohort: string; chrom: string; bin: number; profiled: number; gain: number; loss: number }[]; - /** the view's q-value per bin tested (two cohorts only) — for the script check */ - results: { chrom: string; bin: number; gainQ: number | null; lossQ: number | null }[]; -} - -export function genomeMethod(t: GenomeTrace): MethodRecord { - const two = t.cohorts.length === 2; - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Count gained and lost samples per bin", - detail: "The genome:coverage and genome:bins queries above. Per " + t.binMb + " Mb bin on " + t.build + ": the cohort's SAMPLES (a patient with two segmented samples counts twice) whose segmentation covers the bin's midpoint, and of those the ones gained (log2 segment mean ≥ " + - t.threshold + ") or lost (≤ −" + t.threshold + ") there. Counts only.", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - })); - steps.push({ kind: "transform", title: "Place the bins on one axis", - detail: "Chromosomes 1–22" + (t.includeX ? " and X" : "") + " at " + t.build + " lengths, " + t.bins.toLocaleString() + " bins; counts add across studies (all on one build)." + - (t.pastEnd ? " " + plural(t.pastEnd, "bin row") + " lay past a chromosome's end and were dropped." : ""), - params: t.cohorts.map((c) => [c.name, c.segmented.toLocaleString() + " of " + c.samples.toLocaleString() + " samples segmented"] as [string, string]) }); - if (two) { - steps.push({ kind: "statistic", title: "Fisher's exact test per bin", - detail: "Two-sided, on gained vs not (and separately lost vs not) among each cohort's covered samples, in every bin both cohorts cover." }); - steps.push({ kind: "statistic", title: "Benjamini–Hochberg across bins", - detail: "Separately for gains and losses. Adjacent bins are not independent: a run of significant bins is one region, not many findings." }); - } - const python = [ - "# Recompute" + (two ? " with scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against" : "") + ".", - "import io", - "import pandas as pd", - ...(two ? ["from scipy.stats import fisher_exact", "from statsmodels.stats.multitest import multipletests"] : []), - "", - RESULT_INIT, - ...pyTable("counts", "Per cohort (summed over its studies) and bin: samples covered, gained and lost", ["cohort", "chrom", "bin", "profiled", "gain", "loss"], - t.counts.map((r) => [r.cohort, r.chrom, r.bin, r.profiled, r.gain, r.loss]), "genome_bins.csv"), - "counts[\"chrom\"] = counts[\"chrom\"].astype(str) # \"1\"…\"22\", \"X\": names, not numbers", - "counts[\"gain_pct\"] = 100 * counts[\"gain\"] / counts[\"profiled\"]", - "counts[\"loss_pct\"] = 100 * counts[\"loss\"] / counts[\"profiled\"]", - ...(two ? [ - "a, b = [counts[counts[\"cohort\"] == c].set_index([\"chrom\", \"bin\"]) for c in " + JSON.stringify(t.cohorts.map((c) => c.name)) + "]", - "both = a.join(b, lsuffix=\"_a\", rsuffix=\"_b\", how=\"inner\")", - "both = both[(both[\"profiled_a\"] > 0) & (both[\"profiled_b\"] > 0)]", - "for kind in (\"gain\", \"loss\"):", - " p = [fisher_exact([[r[f\"{kind}_a\"], r[\"profiled_a\"] - r[f\"{kind}_a\"]], [r[f\"{kind}_b\"], r[\"profiled_b\"] - r[f\"{kind}_b\"]]]).pvalue", - " for _, r in both.iterrows()]", - " both[f\"{kind}_q\"] = multipletests(p, method=\"fdr_bh\")[1]", - " print(kind, \"bins with q < 0.05:\", int((both[f\"{kind}_q\"] < 0.05).sum()))", - " for (chrom, b), q in both[f\"{kind}_q\"].items():", - " RESULT[f\"genome:{chrom}:{b}:{kind}_q\"] = q", - ] : ["print(counts.head(30).to_string(index=False))"]), - ].join("\n") + "\n"; - return { steps, implementation: { functions: two ? ["fisherExact", "benjaminiHochberg"] : [], file: "lib/stats.ts", commit: BUILD_COMMIT }, - ...(two ? { verification: "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/tables-golden.test.ts)." } : {}), - python: withResult(python), - check: checks("genome:", t.results.filter((r) => r.gainQ != null), (r) => r.chrom + ":" + r.bin, [["gainQ", "gain_q"], ["lossQ", "loss_q"]]) }; -} - -// ---- pathways: share altered per pathway and member gene, Fisher, BH ----------------------------- - -export interface PathwaysTrace { - measure: "driver" | "any"; - oncokbPin: string | null; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - sets: { name: string; source: string; genes: string[] }[]; - cohorts: string[]; - setCounts: { cohort: string; set: string; profiled: number; altered: number }[]; - geneCounts: { cohort: string; gene: string; profiled: number; altered: number }[]; - /** what the view reported (two cohorts only): each pathway's p and q, and the q of each top gene — for the script check */ - results: { pathways: { name: string; p?: number; q?: number }[]; genes: { gene: string; q: number }[] }; -} - -export function pathwaysMethod(t: PathwaysTrace): MethodRecord { - const two = t.cohorts.length === 2; - const what = t.measure === "driver" ? driverMeaning(t.oncokbPin).replace(/^driver: /, "") : ALTERED_MEANING.replace(/^altered: /, ""); - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Count patients altered per pathway and per gene", - detail: "The geneCounts and pathways:sets queries above. Per pathway: patients profiled for at least one member gene (the denominator) and of those, patients with " + what + - " in at least one member. Per member gene: the same over the patients profiled for that gene. Counts only.", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - params: [["pathways / sets", t.sets.map((x) => x.name + " (" + x.genes.length + ")").join(", ")]], - })); - if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's counts across its studies", detail: "The counts add exactly." }); - if (two) { - steps.push({ kind: "statistic", title: "Fisher's exact test per pathway and per member gene", detail: "Two-sided, altered vs not among the profiled, between the two cohorts." }); - steps.push({ kind: "statistic", title: "Benjamini–Hochberg", detail: "Across the pathways drawn, and separately across every member gene tested, each gene once even when it sits in two pathways." }); - } - const python = [ - "# Recompute" + (two ? " with scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against" : "") + ".", - "import io", - "import pandas as pd", - ...(two ? ["from scipy.stats import fisher_exact", "from statsmodels.stats.multitest import multipletests"] : []), - "", - RESULT_INIT, - ...pyTable("sets", "Per cohort and pathway: patients profiled for at least one member gene, and altered in at least one", ["cohort", "set", "profiled", "altered"], - t.setCounts.map((r) => [r.cohort, r.set, r.profiled, r.altered]), "pathway_counts.csv"), - ...pyTable("genes", "Per cohort and member gene: patients profiled for it, and altered in it", ["cohort", "gene", "profiled", "altered"], - t.geneCounts.map((r) => [r.cohort, r.gene, r.profiled, r.altered]), "gene_counts.csv"), - "cohorts = " + JSON.stringify(t.cohorts), - "for name, table in ((\"pathway\", sets.groupby([\"set\", \"cohort\"]).sum()), (\"gene\", genes.groupby([\"gene\", \"cohort\"]).sum())):", - " table[\"pct\"] = 100 * table[\"altered\"] / table[\"profiled\"]", - ...(two ? [ - " rows = []", - " for key in table.index.get_level_values(0).unique():", - " t = table.loc[key].reindex(cohorts).fillna(0)", - " if (t[\"profiled\"] > 0).all():", - " (a, pa), (b, pb) = [(r.altered, r.profiled) for r in t.itertuples()]", - " rows.append((key, fisher_exact([[a, pa - a], [b, pb - b]]).pvalue))", - " res = pd.DataFrame(rows, columns=[name, \"p\"])", - " res[\"q\"] = multipletests(res[\"p\"], method=\"fdr_bh\")[1]", - " print(res.sort_values(\"p\").to_string(index=False))", - " for r in res.itertuples():", - " RESULT[f\"pathways:{name}:{getattr(r, name)}:p\"], RESULT[f\"pathways:{name}:{getattr(r, name)}:q\"] = r.p, r.q", - ] : [" print(table.to_string())"]), - ].join("\n") + "\n"; - return { steps, implementation: { functions: two ? ["fisherExact", "benjaminiHochberg"] : [], file: "lib/stats.ts", commit: BUILD_COMMIT }, - ...(two ? { verification: "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/tables-golden.test.ts)." } : {}), - python: withResult(python), - check: { ...checks("pathways:pathway:", t.results.pathways, (r) => r.name, [["p", "p"], ["q", "q"]]), - ...checks("pathways:gene:", t.results.genes, (r) => r.gene, [["q", "q"]]) } }; -} - -// ---- attrition: sequential cohort counts --------------------------------------------------------- - -export function attritionMethod(panels: { cohort: string; base: number; steps: { label: string; n: number; excluded: number }[]; studies: string[] }[]): MethodRecord { - return { - steps: panels.map((p) => ({ - kind: "fetch" as const, title: "Count the cohort clause by clause", - detail: "One attrition query per step above, per study: the patients left after ANDing that clause onto every clause before it, in the order the cohort lists them, summed across " + - (p.studies.length === 1 ? p.studies[0] : p.studies.length + " studies (overlap not deduplicated)") + ". Nothing is computed from these counts beyond the differences between steps.", - scope: p.cohort, - params: [["every patient", p.base.toLocaleString()], ...p.steps.map((s) => [s.label, s.n.toLocaleString() + " (−" + s.excluded.toLocaleString() + ")"] as [string, string])], - })), - implementation: { functions: [], file: "lib/views/attrition.ts", commit: BUILD_COMMIT }, - }; -} - -// ---- clonality: FACETS calls per gene, Fisher on clonal vs subclonal and on WGD, BH ----------- - -export interface ClonalityTrace { - measure: "driver" | "any"; - oncokbPin: string | null; - qc: boolean; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - panels: { cohort: string; samples: number; ascnSamples: number; qcPass: number; qcFail: number; wgdYes: number; wgdNo: number }[]; - /** per cohort (summed over its studies) and gene: FACETS's calls */ - genes: { cohort: string; gene: string; calls: number; clonal: number; subclonal: number; indeterminate: number; loh: number; lohKnown: number }[]; - /** what the view reported (two cohorts only) — for the script check */ - results: { genes: { gene: string; p: number; q: number }[]; wgdP?: number }; -} - -export function clonalityMethod(t: ClonalityTrace): MethodRecord { - const two = t.panels.length === 2; - const what = t.measure === "driver" ? "each " + driverMeaning(t.oncokbPin).replace(/^driver: /, "") : "each mutation"; - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Count FACETS's calls per gene", - detail: "The clonality:summary, clonality:purity-ploidy and clonality:genes queries above. Per gene, " + what + - " in one sample is one call, which FACETS labels clonal, subclonal or indeterminate from its cancer-cell fraction (or leaves uncalled); loss of heterozygosity is a minor copy number of 0 where one was estimated. Per sample: whole-genome doubling, purity and ploidy as FACETS called them. Counts only.", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - })); - steps.push({ kind: "transform", title: t.qc ? "Keep samples that pass FACETS QC" : "Keep every sample", - detail: t.qc ? "Only samples whose FACETS_QC is Pass (matched ignoring case) contribute calls, WGD, purity and ploidy." : "qc: false — FACETS_QC failures are used too.", - params: t.panels.map((pn) => [pn.cohort, pn.qcPass.toLocaleString() + " pass, " + pn.qcFail.toLocaleString() + " fail, of " + - pn.ascnSamples.toLocaleString() + " samples with allele-specific copy number"] as [string, string]) }); - if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's counts across its studies", detail: "The counts add exactly." }); - if (two) { - steps.push({ kind: "statistic", title: "Fisher's exact test per gene, and on WGD", - detail: "Two-sided, on clonal against subclonal calls between the two cohorts, for each gene with at least one such call in both; indeterminate and uncalled calls do not enter. And on whole-genome doubled against not, per sample." }); - steps.push({ kind: "statistic", title: "Benjamini–Hochberg across genes", detail: "q-values over the genes tested." }); - } - const python = [ - "# Recompute" + (two ? " with scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against" : "") + ".", - "import io", - "import pandas as pd", - ...(two ? ["from scipy.stats import fisher_exact", "from statsmodels.stats.multitest import multipletests"] : []), - "", - RESULT_INIT, - ...pyTable("calls", "Per cohort (summed over its studies) and gene: FACETS's calls", ["cohort", "gene", "calls", "clonal", "subclonal", "indeterminate", "loh", "loh_known"], - t.genes.map((g) => [g.cohort, g.gene, g.calls, g.clonal, g.subclonal, g.indeterminate, g.loh, g.lohKnown]), "clonality_calls.csv"), - "wgd = " + JSON.stringify(Object.fromEntries(t.panels.map((pn) => [pn.cohort, [pn.wgdYes, pn.wgdNo]]))) + " # cohort: [doubled, not], QC-passing samples", - "calls[\"uncalled\"] = calls[\"calls\"] - calls[\"clonal\"] - calls[\"subclonal\"] - calls[\"indeterminate\"]", - "print(calls.to_string(index=False))", - "for r in calls.itertuples():", - " RESULT[f\"clonality:{r.cohort}:{r.gene}:clonal\"], RESULT[f\"clonality:{r.cohort}:{r.gene}:subclonal\"] = r.clonal, r.subclonal", - ...(two ? [ - "a_name, b_name = " + JSON.stringify(t.panels.map((pn) => pn.cohort)).slice(1, -1), - "a, b = calls[calls[\"cohort\"] == a_name].set_index(\"gene\"), calls[calls[\"cohort\"] == b_name].set_index(\"gene\")", - "tested = [g for g in a.index if g in b.index and a.loc[g, \"clonal\"] + a.loc[g, \"subclonal\"] > 0 and b.loc[g, \"clonal\"] + b.loc[g, \"subclonal\"] > 0]", - "p = [fisher_exact([[a.loc[g, \"clonal\"], a.loc[g, \"subclonal\"]], [b.loc[g, \"clonal\"], b.loc[g, \"subclonal\"]]]).pvalue for g in tested]", - "q = multipletests(p, method=\"fdr_bh\")[1] if p else []", - "for g, pv, qv in zip(tested, p, q):", - " print(f\"{g}: clonal vs subclonal p={pv:.4g}, q={qv:.4g}\")", - " RESULT[f\"clonality:{g}:p\"], RESULT[f\"clonality:{g}:q\"] = pv, qv", - "if sum(wgd[a_name]) and sum(wgd[b_name]):", - " RESULT[\"clonality:wgd:p\"] = fisher_exact([wgd[a_name], wgd[b_name]]).pvalue", - " print(\"whole-genome doubling p =\", RESULT[\"clonality:wgd:p\"])", - ] : []), - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: two ? ["fisherExact", "benjaminiHochberg"] : [], file: "lib/stats.ts", commit: BUILD_COMMIT }, - ...(two ? { verification: "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/tables-golden.test.ts)." } : {}), - python: withResult(python), - check: { - ...checks("clonality:", t.genes, (g) => g.cohort + ":" + g.gene, [["clonal", "clonal", { abs: 0 }], ["subclonal", "subclonal", { abs: 0 }]]), - ...checks("clonality:", t.results.genes, (g) => g.gene, [["p", "p"], ["q", "q"]]), - ...(t.results.wgdP != null ? { "clonality:wgd:p": { v: t.results.wgdP } } : {}), - }, - }; -} - -// ---- labs: % change from baseline after an anchor, its course, best response ------------------- - -export interface LabsTrace { - test: string; - anchoredTo: string; - occurrence: "FIRST" | "LAST"; - baselineDays: number; fromWeeks: number; toWeeks: number; binWeeks: number; responseDrop: number; - pctMin: number; pctMax: number; pctBin: number; - minPointN: number; - waterfallCap: number; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - panels: { cohort: string; anchored: number; baseline: number; positiveBaseline: number; evaluable: number; responders: number; - waterfallWithheld: number; waterfallCapped: number }[]; - /** per cohort, summed over its studies: patients per (week bin, % change bin midpoint) */ - course: { cohort: string; week: number; v: number; n: number }[]; - /** what the view reported — for the script check */ - results: { course: { cohort: string; week: number; q1: number | null; median: number | null; q3: number | null }[]; responseP?: number }; -} - -export function labsMethod(t: LabsTrace): MethodRecord { - const two = t.panels.length === 2; - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Count patients' % change from baseline", - detail: "The labs:course and labs:best queries above (and labs:waterfall, one bar per patient, from a patient-level participant). Counts per bin and per rule, no individual value except the waterfall's bars.", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - })); - steps.push({ kind: "transform", title: "Anchor, baseline and change", - detail: "Day 0 is each patient's " + t.occurrence.toLowerCase() + " " + t.anchoredTo + " (event type, key and value matched ignoring case); a patient without one is not anchored. " + - t.test + " readings are those whose result parses as a number. The baseline is the mean of a patient's readings on the last day with any in the " + t.baselineDays + - " days up to and including day 0; a patient without one has no baseline, and one whose baseline is 0 or below is left out, since a % change from it means nothing. Every later reading becomes 100·(value − baseline)/baseline; readings on the baseline day itself are not used.", - params: t.panels.map((pn) => [pn.cohort, pn.anchored.toLocaleString() + " anchored → " + pn.baseline.toLocaleString() + " with a baseline → " + - pn.positiveBaseline.toLocaleString() + " with a positive one → " + pn.evaluable.toLocaleString() + " with a reading after day 0"] as [string, string]) }); - steps.push({ kind: "transform", title: "Bin the course", - detail: "Weeks " + t.fromWeeks + " to " + t.toWeeks + " around day 0 in " + t.binWeeks + "-week bins; a patient tested several times in a bin counts once, at their mean. % change in " + t.pctBin + "-point bins from " + - t.pctMin + " to " + t.pctMax + ", values beyond either end in the end bin. The figure draws a bin only from " + t.minPointN + " patients." }); - steps.push({ kind: "statistic", title: "Median and interquartile range per week", - detail: "From each bin's histogram, with the bin midpoints as values and linear interpolation between ranks — exact to within a " + t.pctBin + "-point bin." }); - steps.push({ kind: "statistic", title: "Best response", - detail: "Each patient's lowest % change after day 0 within the window; a responder fell by at least " + t.responseDrop + "%, counted from the unbinned values." + - (two ? " The two cohorts' response rates are compared with a two-sided Fisher's exact test." : ""), - params: t.panels.map((pn) => [pn.cohort, pn.responders.toLocaleString() + " responders of " + pn.evaluable.toLocaleString() + - (pn.waterfallWithheld ? " · " + pn.waterfallWithheld + " bars withheld (aggregate-only participant)" : "") + - (pn.waterfallCapped ? " · " + pn.waterfallCapped + " bars past the " + t.waterfallCap.toLocaleString() + "-bar limit" : "")] as [string, string]) }); - const python = [ - "# Recompute the course's quartiles from the binned counts" + (two ? ", and the response test with scipy " + VERIFIED.fisher.version : "") + ".", - "import io", - "import numpy as np", - "import pandas as pd", - ...(two ? ["from scipy.stats import fisher_exact"] : []), - "", - RESULT_INIT, - ...pyTable("course", "Per cohort and week bin: patients whose mean % change fell in each " + t.pctBin + "-point bin (v is the bin's midpoint)", - ["cohort", "week", "v", "n"], t.course.map((r) => [r.cohort, r.week, r.v, r.n]), "labs_course.csv"), - "for (cohort, week), g in course.groupby([\"cohort\", \"week\"], sort=False):", - " values = np.repeat(g[\"v\"].to_numpy(), g[\"n\"].to_numpy())", - " q1, median, q3 = np.percentile(values, [25, 50, 75])", - " print(f\"{cohort} week {week}: n={len(values)}, median {median:.1f}%, IQR {q1:.1f} to {q3:.1f}\")", - " for name, x in ((\"q1\", q1), (\"median\", median), (\"q3\", q3)):", - " RESULT[f\"labs:{cohort}:{week}:{name}\"] = x", - "responders = " + JSON.stringify(Object.fromEntries(t.panels.map((pn) => [pn.cohort, [pn.responders, pn.evaluable]]))) + " # cohort: [responders, evaluable], as the query counted them", - ...(two ? [ - "(ra, ea), (rb, eb) = responders.values()", - "RESULT[\"labs:response:p\"] = fisher_exact([[ra, ea - ra], [rb, eb - rb]]).pvalue", - "print(\"response rates\", {c: f\"{r}/{e}\" for c, (r, e) in responders.items()}, \"Fisher p =\", RESULT[\"labs:response:p\"])", - ] : []), - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: ["quantilesFromHistogram", ...(two ? ["fisherExact"] : [])], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "Quantiles from histograms match numpy percentile on the expanded values" + (two ? ", and Fisher's exact test scipy " + VERIFIED.fisher.version + " fisher_exact" : "") + - ", to a relative 1e-6 (test/moments-golden.test.ts, test/tables-golden.test.ts).", - python: withResult(python), - check: { - // quartiles are reported to 3 decimals - ...checks("labs:", t.results.course, (c) => c.cohort + ":" + c.week, - [["q1", "q1", { abs: 5e-4 + 1e-9 }], ["median", "median", { abs: 5e-4 + 1e-9 }], ["q3", "q3", { abs: 5e-4 + 1e-9 }]]), - ...(t.results.responseP != null ? { "labs:response:p": { v: t.results.responseP } } : {}), - }, - }; -} - -// ---- course: the share of observable patients in each state, per time bin ------------------------ - -export interface CourseTrace { - anchor?: string; - binMonths: number; - fromBin: number; toBin: number; - riskFloor: number; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - dropped: string[]; - /** each measured event type: its label, the spellings folded into it, and whether it carries durations */ - events: { canonical: string; label: string; variants: string[]; isInterval: boolean }[]; - cohorts: { name: string; patients: number; anchored: number; observed: number }[]; - /** per cohort, summed over its studies: patients observable in each bin (event ''), and in each state */ - counts: { cohort: string; bin: number; event: string; n: number }[]; - /** what the view reported — for the script check */ - results: { cohort: string; event: string; bin: number; atRisk: number; n: number; pct: number | null }[]; -} - -export function courseMethod(t: CourseTrace): MethodRecord { - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Count patients per time bin", - detail: "The timelineSpan and eventCourse queries above. Per " + t.binMonths + "-month bin: the cohort's patients observable in it, and of those the ones in each event's state (or with the event in it). Counts only.", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - })); - if (t.dropped.length) steps.push({ kind: "transform", title: "Leave out studies with no timeline structure", - detail: "A study whose every event sits on one day cannot place anything in time: " + t.dropped.join(", ") + "." }); - const folds = t.events.filter((e) => e.variants.length > 1); - steps.push({ kind: "transform", title: "Match event types", - detail: "Event types, keys and values match ignoring case and surrounding whitespace; nothing else is merged." + - (folds.length ? " Folded here: " + folds.map((e) => e.variants.join(" = ")).join("; ") + "." : ""), - params: t.events.map((e) => [e.label, e.isInterval ? "has durations: a state the patient is in during a bin" : "no durations: an event that happened in a bin"] as [string, string]) }); - steps.push({ kind: "transform", title: t.anchor ? "Align on " + t.anchor : "Align on each study's day 0", - detail: (t.anchor ? "Day 0 is each patient's " + t.anchor + "; a patient without one is in no bin." - : "Day 0 is the study's own baseline, which is not comparable across studies.") + - " A patient is observable from their first recorded event to their last contact — the later of their last recorded event and their OS follow-up (OS_MONTHS × 30.4375 days, assuming it starts at the timeline's day 0).", - params: t.cohorts.map((c) => [c.name, c.patients.toLocaleString() + " in the cohort → " + c.anchored.toLocaleString() + (t.anchor ? " with the anchor event" : " aligned") + - " → " + c.observed.toLocaleString() + " observable in the window"] as [string, string]) }); - steps.push({ kind: "statistic", title: "Share per bin", - detail: "For each bin from month " + t.fromBin * t.binMonths + " to " + (t.toBin + 1) * t.binMonths + " (half-open [t, t+" + t.binMonths + ")): patients in the state ÷ patients observable, as a percentage. The figure stops each line where fewer than " + - t.riskFloor + " patients remain observable." }); - const python = [ - "# Recompute each bin's percentage from the counts.", - "import io", - "import pandas as pd", - "", - RESULT_INIT, - ...pyTable("counts", "Per cohort (summed over its studies) and bin: patients observable (event \"\") and in each event's state", ["cohort", "bin", "event", "n"], - t.counts.map((r) => [r.cohort, r.bin, r.event, r.n]), "course_counts.csv"), - "counts[\"event\"] = counts[\"event\"].fillna(\"\")", - "n = counts.set_index([\"cohort\", \"event\", \"bin\"])[\"n\"]", - "events = " + JSON.stringify(Object.fromEntries(t.events.map((e) => [e.canonical, e.label]))) + " # folded name: label", - "for cohort in " + JSON.stringify(t.cohorts.map((c) => c.name)) + ":", - " for event, label in events.items():", - " for b in range(" + t.fromBin + ", " + (t.toBin + 1) + "):", - " at_risk, k = n.get((cohort, \"\", b), 0), n.get((cohort, event, b), 0)", - " pct = 100 * k / at_risk if at_risk else None", - " RESULT[f\"course:{cohort}:{label}:{b}:pct\"], RESULT[f\"course:{cohort}:{label}:{b}:at_risk\"] = pct, at_risk", - " print(cohort, label, [round(RESULT[f'course:{cohort}:{label}:{b}:pct'], 1) if RESULT[f'course:{cohort}:{label}:{b}:pct'] is not None else None", - " for b in range(" + t.fromBin + ", " + (t.toBin + 1) + ")])", - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: [], file: "lib/views/course.ts", commit: BUILD_COMMIT }, - python: withResult(python), - // percentages are reported to 1 decimal - check: checks("course:", t.results, (r) => r.cohort + ":" + r.event + ":" + r.bin, - [["pct", "pct", { abs: 0.05 + 1e-9 }], ["atRisk", "at_risk", { abs: 0 }]]), - }; -} - -// ---- sequence: treatment lines and what follows what --------------------------------------------- - -export interface SequenceTrace { - eventType: string; - labelKey: string; - lineGap: number; - maxSteps: number; - topN: number; - classes: "systemic" | "all" | string[]; - exclude: string[]; - truncated: boolean; - rowLimit: number; - perSite: { cohort: string; study: string; cohortPatients: number }[]; - /** treatment records set aside before lines were built, by class and reason */ - setAside: { cohort: string; cls: string; reason: string; records: number }[]; - /** per cohort, summed over its studies: patients per (line, from, to, fate) */ - flows: { cohort: string; step: number; from: string; to: string; fate: number; n: number }[]; - foldedLabels: number; - /** what the view reported — for the script check */ - results: { cohort: string; started: number; censored: number; nodes: { column: number; kind: string; label: string; n: number }[] }[]; -} - -export function sequenceMethod(t: SequenceTrace): MethodRecord { - const classText = t.classes === "all" ? "every " + t.eventType + " record" - : Array.isArray(t.classes) ? t.eventType + " records whose class is one of " + t.classes.join(", ") - : t.eventType + " records whose class is systemic therapy (" + SYSTEMIC_LINE_CLASSES.join(", ") + ")"; - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Build lines and count what follows what", - detail: "The treatmentSequence query above builds every patient's lines in the database and returns, per line number, how many patients went from one regimen to the next, stopped, or were lost to follow-up — counts only — plus how many records it set aside.", - scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, - })); - const byReason = (reason: string) => { - const m = new Map(); - for (const r of t.setAside.filter((x) => x.reason === reason)) m.set(r.cls, (m.get(r.cls) || 0) + r.records); - return [...m.entries()].sort((a, b) => b[1] - a[1]); - }; - const notLine = byReason("#not a line class"), excluded = byReason("#excluded by the document"); - steps.push({ kind: "transform", title: "Choose the records that form lines", - detail: "The one definition every line count shares (lib/treatment-lines.ts): " + classText + ". A record's class is its SUBTYPE, or its TREATMENT_TYPE where it has none; names and values match ignoring case." + - (t.exclude.length ? " Records labelled " + t.exclude.join(", ") + " are left out by this document." : ""), - params: [ - ...notLine.slice(0, 12).map(([c, n]) => [c, n.toLocaleString() + " records set aside: not a line class"] as [string, string]), - ...(notLine.length > 12 ? [["…", (notLine.length - 12) + " more classes, " + notLine.slice(12).reduce((k, [, n]) => k + n, 0).toLocaleString() + " records"] as [string, string]] : []), - ...excluded.map(([c, n]) => [c, n.toLocaleString() + " records excluded by the document"] as [string, string]), - ] }); - steps.push({ kind: "transform", title: "Group records into lines", - detail: "Per patient, by start date: a record starting more than " + t.lineGap + " days after every earlier one has stopped begins a new line; anything closer — overlapping, or a switch within the gap — is the same line. A line is named by its sorted combination of " + - (CLASS_KEYS.includes(t.labelKey.toLowerCase() as never) ? "classes" : t.labelKey + " values") + " ('A + B'); the label never decides which records form a line." }); - steps.push({ kind: "transform", title: "Decide how each path ends", - detail: "A patient's last line ends in 'no further line' only if they were observed for at least " + t.lineGap + " days after it — their last contact being the later of their last recorded event of any type and their OS follow-up (OS_MONTHS × 30.4375 days) — and in 'record ends here' otherwise. Lines are followed up to line " + t.maxSteps + "." }); - steps.push({ kind: "transform", title: "Keep the " + t.topN + " most common regimens", - detail: "Flows are summed across studies, then regimens ranked by the patients leaving them (or, for one seen only as a destination, arriving), ties by name, across every panel so a regimen keeps one identity; the rest are drawn as 'other'." + - (t.truncated ? " A study's answer reached the " + t.rowLimit + "-flow limit, so its rarest flows are missing." : ""), - params: [["regimens folded into 'other'", String(t.foldedLabels)]] }); - const python = [ - "# Recompute the diagram from the flows: the top-" + t.topN + " folding, node sizes, and who started and was censored.", - "import io", - "import pandas as pd", - "", - RESULT_INIT, - ...pyTable("flows", "Per cohort (summed over its studies): patients per (line, from, to, fate); fate 0 another line, 1 no further line, 2 record ends", - ["cohort", "step", "from_", "to", "fate", "n"], t.flows.map((f) => [f.cohort, f.step, f.from, f.to, f.fate, f.n]), "sequence_flows.csv"), - "flows[\"to\"] = flows[\"to\"].fillna(\"\")", - "out_w = flows.groupby(\"from_\")[\"n\"].sum()", - "in_w = flows[(flows[\"fate\"] == 0) & (flows[\"to\"] != \"\")].groupby(\"to\")[\"n\"].sum()", - "weight = {**{k: v for k, v in in_w.items() if k not in out_w}, **out_w.to_dict()}", - "keep = {label for label, _ in sorted(weight.items(), key=lambda x: (-x[1], x[0]))[:" + t.topN + "]}", - "fold = lambda label: label if label in keep else \"other\"", - "kind = {0: \"regimen\", 1: \"stopped\", 2: \"censored\"}", - "print(len(weight) - len(keep), \"regimens folded into 'other'\")", - "for cohort, g in flows.groupby(\"cohort\", sort=False):", - " out_n, in_n, started, censored = {}, {}, 0, 0", - " for r in g.itertuples():", - " k = kind[r.fate]", - " src, dst = (r.step, \"regimen\", fold(r.from_)), (r.step + 1, k, fold(r.to) if k == \"regimen\" else \"\")", - " out_n[src] = out_n.get(src, 0) + r.n", - " in_n[dst] = in_n.get(dst, 0) + r.n", - " started += r.n if r.step == 1 else 0", - " censored += r.n if k == \"censored\" else 0", - " print(f\"{cohort}: {started} with a first line, {censored} paths end censored\")", - " RESULT[f\"sequence:{cohort}:started\"], RESULT[f\"sequence:{cohort}:censored\"] = started, censored", - " for node in set(out_n) | set(in_n):", - " RESULT[f\"sequence:{cohort}:{node[0]}:{node[1]}:{node[2]}:n\"] = out_n.get(node, in_n.get(node))", - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: [], file: "lib/treatment-lines.ts, lib/views/sequence.ts", commit: BUILD_COMMIT }, - python: withResult(python), - check: Object.assign({}, ...t.results.map((p) => ({ - ["sequence:" + p.cohort + ":started"]: { v: p.started, abs: 0 }, - ["sequence:" + p.cohort + ":censored"]: { v: p.censored, abs: 0 }, - ...checks("sequence:" + p.cohort + ":", p.nodes, (nd) => nd.column + ":" + nd.kind + ":" + nd.label, [["n", "n", { abs: 0 }]]), - }))), - }; -} - -// ---- cancertypes: alteration frequency per cancer type ------------------------------------------ - -export interface CancertypesTrace { - genes: string[]; groupBy: string; measure: "driver" | "any"; oncokbPin: string | null; minSamples: number; - unit: "patient" | "sample"; - perSite: { cohort: string; study: string; groups: number; profiled: number }[]; - panels: { cohort: string; groups: number; pooled: number; profiled: number; altered: number }[]; -} - -export function cancertypesMethod(t: CancertypesTrace): MethodRecord { - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Count " + t.unit + "s per " + t.groupBy + " group", - detail: "The cancertypes queries above. Per group, for the genes together and for each gene: the " + t.unit + "s profiled for it, and of those the " + t.unit + "s with " + - (t.measure === "driver" ? "an OncoKB driver (" + t.oncokbPin + ")" : "any mutation, amplification, deep deletion or structural variant") + - ", split by kind (one kind only, or more than one); and the whole cohort's, each " + t.unit + " once." + - (t.unit === "patient" ? " A patient counts in each group one of their samples is in, profiled if any of those samples is, altered if any is." : "") + - " Per group too, how many were profiled for mutations, copy number and structural variants at all. Counts only.", - scope: s.cohort + " · " + s.study, rowsOut: s.groups, - params: [["genes", t.genes.join(", ")], ["profiled " + t.unit + "s", s.profiled.toLocaleString("en-US")]], - })); - if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) - steps.push({ kind: "transform", title: "Add each group's counts across studies", detail: "Groups are matched by their folded value; the counts add exactly." }); - for (const p of t.panels) steps.push({ - kind: "transform", title: "Pool the small groups", scope: p.cohort, - detail: p.pooled ? p.pooled + " group" + (p.pooled === 1 ? "" : "s") + " with fewer than " + t.minSamples + " profiled " + t.unit + "s drawn as one pooled bar; " + p.groups + " drawn on their own." - : "Every group has at least " + t.minSamples + " profiled " + t.unit + "s; none pooled.", - params: [["profiled", p.profiled.toLocaleString("en-US")], ["altered", p.altered.toLocaleString("en-US")]], - }); - return { steps, implementation: { functions: [], file: "lib/views/cancertypes.ts", commit: BUILD_COMMIT } }; -} - -// ---- overlap: how cohorts intersect ------------------------------------------------------------- - -export function overlapMethod(t: { cohorts: string[]; perSite: { study: string; cohorts: string[]; patients: number }[]; union: number; intersections: number }): MethodRecord { - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Count patients per combination of cohorts", - detail: "The overlap query above: every patient in any of " + s.cohorts.join(", ") + " gets a bitmask of the cohorts it is in, and the study returns one count per bitmask. Counts only.", - scope: s.study, rowsOut: s.patients, - })); - steps.push({ kind: "transform", title: "Add the counts across studies and name each combination", - detail: "Each study's bits are mapped back to the view's cohort order; combinations add exactly, and a patient can only overlap within one study.", - params: [["patients in any cohort", t.union.toLocaleString("en-US")], ["combinations with patients", String(t.intersections)]] }); - return { steps, implementation: { functions: [], file: "lib/views/overlap.ts", commit: BUILD_COMMIT } }; -} - -// ---- enrichment: every gene's alteration frequency between cohorts ------------------------------ - -export interface EnrichmentTrace { - alteration: string; measure: "driver" | "any"; oncokbPin: string | null; cohorts: string[]; minAltered: number; - test: string; two: boolean; - perSite: { cohort: string; study: string; genes: number; wes: number }[]; - tested: number; untested: number; - /** the rows the view shows, whose p and q the script must reproduce */ - results: { gene: string; p: number; q: number }[]; -} - -export function enrichmentMethod(t: EnrichmentTrace): MethodRecord { - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Count altered and profiled samples per gene", - detail: "The enrichment queries above: per gene, samples with " + (t.measure === "driver" ? "an OncoKB driver (" + t.oncokbPin + ") " : "a ") + t.alteration + - "; per gene, samples on a panel listing it (counted per panel, then joined to its genes); and the whole-exome samples, profiled for every gene.", - scope: s.cohort + " · " + s.study, rowsOut: s.genes, params: [["whole-exome samples", s.wes.toLocaleString("en-US")]], - })); - steps.push({ kind: "transform", title: "Profiled = panel samples + whole-exome samples, summed across studies", - detail: "A sample altered in a gene its panel does not list still counts as measured. Genes with fewer than " + t.minAltered + - " altered samples across the cohorts, or not profiled in one of them, are not tested.", - params: [["tested", t.tested.toLocaleString("en-US")], ["not tested", t.untested.toLocaleString("en-US")]] }); - steps.push({ kind: "statistic", title: (t.two ? "Fisher's exact test" : "Chi-squared test") + " per gene, then Benjamini–Hochberg", - detail: "A " + t.test + " on altered vs not among the profiled, per gene; q across every gene tested." }); - const python = [ - "# Recompute with scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against.", - "import pandas as pd", - t.two ? "from scipy.stats import fisher_exact" : "from scipy.stats import chi2_contingency", - "from statsmodels.stats.multitest import multipletests", - "", - RESULT_INIT, - "# Per cohort and gene: altered samples, and samples profiled for the gene (panel + whole-exome),", - "# summed across studies — export it from the enrichment queries above as enrichment_counts.csv", - "counts = pd.read_csv(\"enrichment_counts.csv\")", - "cohorts = " + JSON.stringify(t.cohorts), - "MIN_ALTERED = " + t.minAltered, - "rows = []", - "for gene, g in counts.groupby(\"gene\"):", - " g = g.set_index(\"cohort\").reindex(cohorts).fillna(0)", - " if g[\"altered\"].sum() < MIN_ALTERED or (g[\"profiled\"] == 0).any():", - " continue", - t.two - ? " (a, pa), (b, pb) = [(r.altered, r.profiled) for r in g.itertuples()]\n p = fisher_exact([[a, pa - a], [b, pb - b]], alternative=\"two-sided\").pvalue" - : " p = chi2_contingency([[r.altered, r.profiled - r.altered] for r in g.itertuples()], correction=False).pvalue", - " rows.append((gene, p))", - "res = pd.DataFrame(rows, columns=[\"gene\", \"p\"])", - "res[\"q\"] = multipletests(res[\"p\"], method=\"fdr_bh\")[1]", - "res = res.sort_values([\"p\", \"gene\"])", - "print(len(res), \"genes tested\")", - "print(res.head(30).to_string(index=False))", - "for r in res.itertuples():", - " RESULT[f\"enrichment:{r.gene}:p\"], RESULT[f\"enrichment:{r.gene}:q\"] = r.p, r.q", - "RESULT[\"enrichment:tested\"] = len(res)", - ].join("\n") + "\n"; - return { steps, implementation: { functions: [t.two ? "fisherExact" : "chiSquareTest", "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: (t.two ? "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact" : "The chi-squared test matches scipy " + VERIFIED.chi2.version + " chi2_contingency") + - " and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/tables-golden.test.ts).", - python: withResult(python), - check: { "enrichment:tested": { v: t.tested }, ...checks("enrichment:", t.results.slice(0, 40), (r) => r.gene, [["p", "p"], ["q", "q"]]) } }; -} - -// ---- assays: every generic-assay entity, Welch t / ANOVA or chi-squared, then BH --------------- - -export interface AssaysTrace { - profileType: string; categorical: boolean; unit: "samples" | "patients"; - cohorts: string[]; minN: number; test: string; - perSite: { cohort: string; study: string; rows: number }[]; - tested: number; untested: number; missing: number; - /** numeric: per cohort and entity, summed over its studies */ - moments: { cohort: string; entity: string; n: number; sx: number; sxx: number }[]; - /** categorical: per cohort, entity and called value, summed over its studies */ - counts: { cohort: string; entity: string; value: string; n: number }[]; - /** what the view reported, for the script check */ - results: { entity: string; p: number; q: number }[]; -} - -export function assaysMethod(t: AssaysTrace): MethodRecord { - const k = t.cohorts.length, two = k === 2, cat = t.categorical; - const one = t.unit.replace(/s$/, ""); - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: cat ? "Count each entity's values" : "Sum each entity's values", - detail: "The assays:" + (cat ? "values" : "moments") + " query above, on “" + t.profileType + "”: per entity, over the cohort's " + t.unit + - (cat ? ", how many take each value." : ", n, Σv and Σv² of the values that read as numbers, and how many did not.") + " No individual value leaves the database.", - scope: s.cohort + " · " + s.study, rowsOut: s.rows, - })); - if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's " + (cat ? "counts" : "sums") + " across its studies", - detail: "The totals add exactly; there is no batch correction between studies." }); - steps.push({ kind: "transform", title: "Leave out what cannot be tested", - detail: (cat ? "Values NA or blank (not called) are not a category: " : "Values that are not numbers: ") + t.missing.toLocaleString("en-US") + " left out. " + - "An entity with fewer than " + t.minN + " " + t.unit + " with a value in any cohort" + (cat ? ", or one value throughout," : ", or no spread in any cohort (variance ≤ 1e-12 · (1 + mean²)),") + " is not tested.", - params: [["tested", t.tested.toLocaleString("en-US")], ["not tested", t.untested.toLocaleString("en-US")]] }); - steps.push({ kind: "statistic", title: t.test + " per entity, then Benjamini–Hochberg", - detail: (cat ? "On each entity's cohort × value table, no continuity correction; an all-zero column is dropped." - : two ? "Unequal-variance t from each cohort's n, mean and variance (Σv² − (Σv)²/n)/(n − 1), Welch–Satterthwaite df, two-sided." - : "The unequal-variance F test from each cohort's n, mean and variance; a cohort with fewer than 2 values or no variance is left out of that entity's test.") + - " q across every entity tested, one value per " + one + "." }); - const file = cat ? "assays_counts.csv" : "assays_moments.csv"; - const python = [ - "# Recompute with scipy " + VERIFIED.welch.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against.", - "import io", - "import numpy as np", - "import pandas as pd", - cat ? "from scipy.stats import chi2_contingency" : two ? "from scipy.stats import ttest_ind_from_stats" : "from statsmodels.stats.oneway import anova_generic", - "from statsmodels.stats.multitest import multipletests", - "", - RESULT_INIT, - "cohorts = " + JSON.stringify(t.cohorts), - "MIN_N = " + t.minN, - ...(cat - ? pyTable("data", "Per cohort, entity and called value: how many " + t.unit + ", from the assays:values queries summed over each cohort's studies (NA and blank left out)", - ["cohort", "entity", "value", "n"], t.counts.map((r) => [r.cohort, r.entity, r.value, r.n]), file) - : pyTable("data", "Per cohort and entity: n, sum and sum of squares of the numeric values, from the assays:moments queries summed over each cohort's studies", - ["cohort", "entity", "n", "sx", "sxx"], t.moments.map((r) => [r.cohort, r.entity, r.n, r.sx, r.sxx]), file)), - "rows = []", - ...(cat ? [ - "data[\"value\"] = data[\"value\"].astype(str)", - "for entity, g in data.groupby(\"entity\"):", - " table = g.pivot_table(index=\"cohort\", columns=\"value\", values=\"n\", aggfunc=\"sum\", fill_value=0).reindex(cohorts, fill_value=0)", - " if table.shape[1] < 2 or (table.sum(axis=1) < MIN_N).any():", - " continue", - " table = table.loc[:, table.sum(axis=0) > 0]", - " rows.append((entity, chi2_contingency(table.to_numpy(), correction=False).pvalue))", - ] : [ - "data[\"mean\"] = data[\"sx\"] / data[\"n\"]", - "data[\"var\"] = ((data[\"sxx\"] - data[\"sx\"] ** 2 / data[\"n\"]) / (data[\"n\"] - 1)).clip(lower=0) # rounding can put a constant entity's just below 0", - "for entity, g in data.groupby(\"entity\"):", - " g = g.set_index(\"cohort\").reindex(cohorts)", - " if g[\"n\"].isna().any() or (g[\"n\"] < MIN_N).any() or (g[\"var\"] <= 1e-12 * (1 + g[\"mean\"] ** 2)).all():", - " continue", - ...(two ? [ - " a, b = g.iloc[0], g.iloc[1]", - " p = ttest_ind_from_stats(a[\"mean\"], np.sqrt(a[\"var\"]), a[\"n\"], b[\"mean\"], np.sqrt(b[\"var\"]), b[\"n\"], equal_var=False).pvalue", - ] : [ - " g = g[(g[\"n\"] >= 2) & (g[\"var\"] > 0)] # a cohort with under 2 values or no variance says nothing", - " p = anova_generic(g[\"mean\"].to_numpy(), g[\"var\"].to_numpy(), g[\"n\"].to_numpy(), use_var=\"unequal\", welch_correction=True).pvalue if len(g) >= 2 else 1.0", - ]), - " rows.append((entity, p))", - ]), - "res = pd.DataFrame(rows, columns=[\"entity\", \"p\"])", - "res[\"q\"] = multipletests(res[\"p\"], method=\"fdr_bh\")[1]", - "res = res.sort_values([\"p\", \"entity\"])", - "print(len(res), \"entities tested\")", - "print(res.head(30).to_string(index=False))", - "for r in res.itertuples():", - " RESULT[f\"assays:{r.entity}:p\"], RESULT[f\"assays:{r.entity}:q\"] = r.p, r.q", - "RESULT[\"assays:tested\"] = len(res)", - ].join("\n") + "\n"; - return { - steps, - implementation: { functions: [cat ? "chiSquareTest" : two ? "welchT" : "welchAnovaTest", "varianceFromMoments", "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: (cat ? "The chi-squared test matches scipy " + VERIFIED.chi2.version + " chi2_contingency" - : "Welch's t and ANOVA from moments match scipy " + VERIFIED.welch.version + " ttest_ind(equal_var=False) and statsmodels " + VERIFIED.anova.version + " " + VERIFIED.anova.fn) + - " and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/moments-golden.test.ts, test/tables-golden.test.ts).", - python: withResult(python), - check: { "assays:tested": { v: t.tested }, ...checks("assays:", t.results.slice(0, 40), (r) => r.entity, [["p", "p", { rel: 1e-6, abs: 1e-300 }], ["q", "q", { rel: 1e-6, abs: 1e-300 }]]) }, - }; -} - -// ---- composition: parts of a whole per case, each cohort against the others pooled -------------- - -export interface CompositionTrace { - source: CompositionSource; sourceName: string; cohorts: string[]; unit: "samples" | "patients"; normalize: boolean; eps: number; - perSite: { cohort: string; study: string; n: number }[]; - /** per cohort, summed over its studies: n, per part Σp and Σp², Σ and Σ² of entropy, cases left out */ - sums: { cohort: string; n: number; sx: number[]; sxx: number[]; hx: number; hxx: number; incomplete: number; invalid: number }[]; - /** what the view reported, for the script check */ - cells: CompositionCell[]; - two: boolean; -} - -export function compositionMethod(t: CompositionTrace): MethodRecord { - const one = t.unit.replace(/s$/, ""), parts = t.source.parts, k = parts.length; - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Sum each part over the cohort's " + t.unit, - detail: "The composition:moments query above: each " + one + "'s " + k + " parts read and put in part order inside the database; a " + one + - " with every part, none below zero and a sum above zero is kept" + (t.normalize ? ", its parts divided by their sum" : "") + - "; then over those " + t.unit + " n, Σp and Σp² per part, and Σ and Σ² of each one's Shannon entropy. No case's values leave the database in this query.", - scope: s.cohort + " · " + s.study, rowsOut: s.n, - })); - if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's sums across its studies", - detail: "The sums add exactly; there is no batch correction between studies." }); - if (t.cohorts.length >= 2) steps.push({ kind: "statistic", title: "Each cohort against the others pooled, per part; then Benjamini–Hochberg", - detail: "The other cohorts' sums are added into one group. Effect: log2((mean + " + t.eps + ") / (others' mean + " + t.eps + ")). Test: Welch's unequal-variance t " + - "from each side's n, mean and variance (Σp² − (Σp)²/n)/(n − 1), two-sided; not tested when either side has under 2 " + t.unit + " or neither has any spread. " + - (t.two ? "With two cohorts each part's two cells are one comparison, corrected for once. " : "") + "q across every comparison tested.", - params: [["comparisons", String(t.cells.filter((c) => c.p != null && (!t.two || c.cohort === t.cohorts[0])).length)]] }); - const file = "composition_moments.csv"; - const python = [ - "# Recompute with scipy " + VERIFIED.welch.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against.", - "import io", - "import numpy as np", - "import pandas as pd", - "from scipy.stats import ttest_ind_from_stats", - "from statsmodels.stats.multitest import multipletests", - "", - RESULT_INIT, - "cohorts = " + JSON.stringify(t.cohorts), - "parts = " + JSON.stringify(parts), - "EPS = " + t.eps, - "TWO = " + (t.two ? "True" : "False"), - ...pyTable("data", "Per cohort and part: how many " + t.unit + " have every part, and the sum and sum of squares of this part's value over them, from the composition:moments queries summed over each cohort's studies", - ["cohort", "part", "n", "sx", "sxx"], t.sums.flatMap((s) => parts.map((p, j) => [s.cohort, p, s.n, s.sx[j], s.sxx[j]])), file), - "def stats(n, sx, sxx):", - " mean = sx / n if n else 0.0", - " var = max(0.0, (sxx - sx ** 2 / n) / (n - 1)) if n >= 2 else 0.0", - " return mean, var", - "rows = []", - "for cohort in cohorts:", - " for part in parts:", - " a = data[(data[\"cohort\"] == cohort) & (data[\"part\"] == part)].iloc[0]", - " mean, var = stats(a[\"n\"], a[\"sx\"], a[\"sxx\"])", - " RESULT[f\"composition:{cohort}|{part}:mean\"] = mean", - " if len(cohorts) < 2:", - " continue", - " rest = data[(data[\"cohort\"] != cohort) & (data[\"part\"] == part)]", - " rn, rsx, rsxx = rest[\"n\"].sum(), rest[\"sx\"].sum(), rest[\"sxx\"].sum()", - " rmean, rvar = stats(rn, rsx, rsxx)", - " log2 = np.log2((mean + EPS) / (rmean + EPS))", - " RESULT[f\"composition:{cohort}|{part}:log2\"] = log2", - " if a[\"n\"] >= 2 and rn >= 2 and var + rvar > 0:", - " p = ttest_ind_from_stats(mean, np.sqrt(var), a[\"n\"], rmean, np.sqrt(rvar), rn, equal_var=False).pvalue", - " rows.append((cohort, part, p))", - "res = pd.DataFrame(rows, columns=[\"cohort\", \"part\", \"p\"])", - "tested = res[res[\"cohort\"] == cohorts[0]] if TWO else res # two cohorts: one comparison per part", - "if len(tested):", - " q = dict(zip(tested[\"part\"] + \"|\" + tested[\"cohort\"], multipletests(tested[\"p\"], method=\"fdr_bh\")[1]))", - " for r in res.itertuples():", - " RESULT[f\"composition:{r.cohort}|{r.part}:p\"] = r.p", - " RESULT[f\"composition:{r.cohort}|{r.part}:q\"] = q[r.part + \"|\" + (cohorts[0] if TWO else r.cohort)]", - "print(res.assign(q=[RESULT.get(f\"composition:{c}|{p}:q\") for c, p in zip(res[\"cohort\"], res[\"part\"])]).sort_values(\"p\").head(30).to_string(index=False))", - "RESULT[\"composition:tested\"] = len(tested)", - ].join("\n") + "\n"; - const tol = { rel: 1e-6, abs: 1e-9 }, ptol = { rel: 1e-6, abs: 1e-300 }; - return { - steps, - implementation: { functions: ["welchT", "varianceFromMoments", "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "Welch's t from moments matches scipy " + VERIFIED.welch.version + " ttest_ind(equal_var=False) and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/moments-golden.test.ts).", - python: withResult(python), - check: { - "composition:tested": { v: t.cells.filter((c) => c.p != null && (!t.two || c.cohort === t.cohorts[0])).length }, - ...checks("composition:", t.cells as unknown as Record[], (c) => c.cohort + "|" + c.part, [["mean", "mean", tol], ["log2", "log2", tol], ["p", "p", ptol], ["q", "q", ptol]]) - }, - }; -} - -// ---- correlates: Pearson and Spearman from sums, Fisher-z across studies, BH ------------------ - -export interface CorrelatesTrace { - gene: string; profileType: string; otherProfile: string; cohort: string; - minN: number; multi: boolean; tested: number; untested: number; - perSite: { study: string; genes: number; patients: number }[]; - /** per study and gene: the six sums on values, and the same six on average ranks */ - sums: { study: string; gene: string; n: number; sx: number; sy: number; sxy: number; sxx: number; syy: number; - rsx: number; rsy: number; rsxy: number; rsxx: number; rsyy: number }[]; - /** what the view reported, for the script check */ - results: { gene: string; rho: number; rhoP: number; q: number; r: number; p: number }[]; -} - -export function correlatesMethod(t: CorrelatesTrace): MethodRecord { - const same = t.profileType === t.otherProfile; - const steps: MethodStep[] = t.perSite.map((s) => ({ - kind: "fetch" as const, title: "Sum every gene's values and ranks against " + t.gene + "'s", - detail: "The correlates:moments query above. Per patient, " + t.gene + " on “" + t.profileType + "” and every gene on “" + t.otherProfile + - "” (a patient's samples averaged); per gene, over the patients with both: n, Σx, Σy, Σxy, Σx², Σy², and the same six over each value's average rank among those patients (ties share the mean of their ranks). Sums only.", - scope: t.cohort + " · " + s.study, rowsIn: s.patients, rowsOut: s.genes, - })); - steps.push({ kind: "statistic", title: "Pearson's r and Spearman's ρ per gene", - detail: "r from the sums: (nΣxy − ΣxΣy) / √((nΣx² − (Σx)²)(nΣy² − (Σy)²)); ρ is the same formula on the rank sums. Each p from t = r√((n − 2)/(1 − r²)) on n − 2 df, as scipy's pearsonr and spearmanr compute it." + - (t.multi ? " Across studies, r pools the sums (exact); ρ is each study's, combined by Fisher z with weight n − 3 and a normal p, because ranks do not add." : "") + - (same ? " " + t.gene + " against itself is left out." : "") + " A gene with fewer than " + t.minN + " patients, or no spread, is not tested.", - params: [["tested", t.tested.toLocaleString("en-US")], ["not tested", t.untested.toLocaleString("en-US")]] }); - steps.push({ kind: "statistic", title: "Benjamini–Hochberg over ρ", detail: "q-values over every gene tested, from ρ's p." }); - const python = [ - "# Recompute with numpy, scipy " + VERIFIED.pearson.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against.", - "import io", - "import numpy as np", - "import pandas as pd", - "from scipy.stats import t as student_t, norm", - "from statsmodels.stats.multitest import multipletests", - "", - RESULT_INIT, - "MIN_N = " + t.minN, - "QUERY = " + py(t.gene) + (same ? " # left out: a gene against itself" : ""), - ...pyTable("sums", "Per study and gene: the six sums on values and on average ranks, from the correlates:moments queries", - ["study", "gene", "n", "sx", "sy", "sxy", "sxx", "syy", "rsx", "rsy", "rsxy", "rsxx", "rsyy"], - t.sums.map((r) => [r.study, r.gene, r.n, r.sx, r.sy, r.sxy, r.sxx, r.syy, r.rsx, r.rsy, r.rsxy, r.rsxx, r.rsyy]), "correlates_sums.csv"), - "", - "def corr(n, sx, sy, sxy, sxx, syy):", - " den = np.sqrt((n * sxx - sx ** 2) * (n * syy - sy ** 2))", - " with np.errstate(invalid=\"ignore\", divide=\"ignore\"):", - " return np.where(den > 0, (n * sxy - sx * sy) / den, np.nan).clip(-1, 1)", - "", - "def t_p(r, n):", - " with np.errstate(invalid=\"ignore\", divide=\"ignore\"):", - " tt = r * np.sqrt((n - 2) / (1 - r ** 2))", - " return np.where(np.abs(r) >= 1, 0.0, 2 * student_t.sf(np.abs(tt), n - 2))", - "", - "sums[\"rho_s\"] = corr(sums.n, sums.rsx, sums.rsy, sums.rsxy, sums.rsxx, sums.rsyy)", - "g = sums.groupby(\"gene\")[[\"n\", \"sx\", \"sy\", \"sxy\", \"sxx\", \"syy\"]].sum()", - "g[\"r\"] = corr(g.n, g.sx, g.sy, g.sxy, g.sxx, g.syy)", - "g[\"p\"] = t_p(g.r, g.n)", - ...(t.multi ? [ - "# rho: each study's own, combined by Fisher z with weight n - 3 (|rho| = 1 or n < 4 carries no z)", - "ok = (sums.n >= 4) & (sums.rho_s.abs() < 1) & sums.rho_s.notna()", - "s = sums[ok].assign(w=lambda d: d.n - 3, z=lambda d: np.arctanh(d.rho_s))", - "m = s.assign(wz=s.w * s.z).groupby(\"gene\")[[\"w\", \"wz\"]].sum()", - "z = m.wz / m.w", - "g[\"rho\"] = np.tanh(z).reindex(g.index)", - "g[\"rho_p\"] = pd.Series(2 * norm.sf(np.abs(z) * np.sqrt(m.w)), index=m.index).reindex(g.index)", - ] : [ - "g[\"rho\"] = sums.set_index(\"gene\").rho_s.reindex(g.index)", - "g[\"rho_p\"] = t_p(g.rho, g.n)", - ]), - "g = g[(g.n >= MIN_N) & g.rho.notna() & g.r.notna()" + (same ? " & (g.index != QUERY)" : "") + "].copy()", - "g[\"q\"] = multipletests(g.rho_p, method=\"fdr_bh\")[1]", - "print(len(g), \"genes tested\")", - "print(g.sort_values(\"rho\", ascending=False)[[\"n\", \"rho\", \"rho_p\", \"q\", \"r\", \"p\"]].head(15))", - "print(g.sort_values(\"rho\")[[\"n\", \"rho\", \"rho_p\", \"q\", \"r\", \"p\"]].head(15))", - "RESULT[\"correlates:tested\"] = len(g)", - "for gene, x in g.iterrows():", - " for f in (\"rho\", \"rho_p\", \"q\", \"r\", \"p\"):", - " RESULT[f\"correlates:{gene}:{f}\"] = x[f]", - ].join("\n") + "\n"; - // ρ and r are reported to 4 decimals; their p-values come from the unrounded coefficient - const pTol = { rel: 1e-6, abs: 1e-300 }; - return { - steps, - implementation: { functions: ["pearsonFromMoments", ...(t.multi ? ["fisherZMeta"] : []), "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, - verification: "Pearson's r from sums matches scipy " + VERIFIED.pearson.version + " pearsonr (test/moments-golden.test.ts); ρ is the same formula on average ranks, which is how scipy's spearmanr defines it — checked against spearmanr on raw values for TCGA breast cancer ESR1 (FOXA1 ρ 0.7478, PSAT1 −0.6732). Benjamini–Hochberg matches statsmodels multipletests (test/tables-golden.test.ts).", - python: withResult(python), - check: { "correlates:tested": { v: t.tested }, - ...checks("correlates:", t.results.slice(0, 80), (r) => r.gene, [["rho", "rho", { abs: 5e-5 + 1e-12 }], ["rhoP", "rho_p", pTol], ["q", "q", pTol], ["r", "r", { abs: 5e-5 + 1e-12 }], ["p", "p", pTol]]) }, - }; -} - // ---- table views: listings, nothing computed ---------------------------------------------------- -type TableLists = { cohort: string; study: string; total: number; all: number }[]; +export type TableLists = { cohort: string; study: string; total: number; all: number }[]; /** The steps every table view shares after its count: what the query keeps, its order, the page. */ -function tableSteps(t: { label: string; what: string; queryText: string; order: string; shown: number; lists: TableLists; instances: number; excluded: { study: string }[]; source: string }): MethodStep[] { +export function tableSteps(t: { label: string; what: string; queryText: string; order: string; shown: number; lists: TableLists; instances: number; excluded: { study: string }[]; source: string }): MethodStep[] { return [{ kind: "fetch" as const, title: "Count the " + t.what + " per cohort and study" + (t.queryText ? ", and those the query keeps" : ""), detail: "The " + t.label + ":counts query above: " + t.source + (t.queryText ? " The query keeps " + t.queryText + "." : ""), @@ -2145,228 +244,3 @@ function tableSteps(t: { label: string; what: string; queryText: string; order: }]; } -export function tableMethod(t: { unit: "sample" | "patient"; cohorts: string[]; columns: number; query: unknown; queryText: string; sortName: string | null; shown: number; - lists: TableLists; excluded: { study: string; reason: string; patients: number }[]; instances: number }): MethodRecord { - const desc = !!(t.query as { sort?: { desc?: boolean } })?.sort?.desc; - const steps = tableSteps({ label: "table", what: t.unit + "s", queryText: t.queryText, shown: t.shown, lists: t.lists, instances: t.instances, excluded: t.excluded, - source: "every " + t.unit + " of each cohort's patients" + (t.unit === "sample" ? " (from the study's sample list, so a sample with no sample attribute still has a row)" : "") + - ", each with the value of each of the " + t.columns + " attributes as recorded — blank where none was.", - order: (t.sortName ? "by " + t.sortName + (desc ? " descending" : "") + " with missing values last, then " : "by ") + "patient and sample id" }); - return { steps, implementation: { functions: ["clinicalRowSource", "pageSql", "countsSql"], file: "lib/clinical-table.ts, lib/table-query.ts", commit: BUILD_COMMIT }, - verification: "A listing: no statistic is computed, so there is no script — the queries are the numbers." }; -} - -export function mutationsMethod(t: { gene: string; cohorts: string[]; oncokbPin: string | null; queryText: string; sortName: string | null; desc: boolean; shown: number; - lists: TableLists; excluded: { study: string; reason: string; patients: number }[]; instances: number }): MethodRecord { - const steps = tableSteps({ label: "mutations", what: t.gene + " mutation calls", queryText: t.queryText, shown: t.shown, lists: t.lists, instances: t.instances, excluded: t.excluded, - source: "every called " + t.gene + " mutation in each cohort's samples (uncalled calls left out), with the sample's CANCER_TYPE_DETAILED and MUTATION_COUNT where recorded" + - (t.oncokbPin ? " and the OncoKB " + t.oncokbPin + " call for its protein change" : "") + "; allele frequency = variant / (variant + reference) reads.", - order: (t.sortName ? "by " + t.sortName + (t.desc ? " descending" : "") + ", missing last" : "drivers first, then by codon") + ", then sample" }); - return { steps, implementation: { functions: ["mutationRowSource", "pageSql", "countsSql"], file: "lib/mutation-table.ts, lib/table-query.ts", commit: BUILD_COMMIT }, - verification: "A listing: no statistic is computed, so there is no script — the queries are the numbers." }; -} - -export function alterationsMethod(t: { cohorts: string[]; oncokbPin: string | null; queryText: string; sortName: string | null; desc: boolean; shown: number; - lists: TableLists; excluded: { study: string; reason: string; patients: number }[]; instances: number; - patient: { study: string; id: string } | null; tables: { name: string; total: number; all: number; queryText: string }[] }): MethodRecord { - const steps = tableSteps({ label: "alterations", what: "patients", queryText: t.queryText, shown: t.shown, lists: t.lists, instances: t.instances, excluded: t.excluded, - source: "every patient of each cohort, with their sample count and how many rows each of the three tables lists for them (a mutation, a gene's deep copy-number call, a structural variant — each counted once however many samples carry it).", - order: t.sortName ? "by " + t.sortName + (t.desc ? " descending" : "") + " with missing values last, then patient id" : "patients with any alteration first, then by patient id" }); - if (t.patient) steps.push({ - kind: "fetch" as const, title: "Read " + t.patient.id + "'s three tables", - detail: "The alterations:mutations, alterations:cna and alterations:sv queries above, each paged, searched, filtered and sorted in the database like any table view. " + - "Mutations: every called mutation of the patient's samples (uncalled calls left out), one row per variant with the samples carrying it, the highest allele frequency among them " + - "(variant / (variant + reference) reads) with that sample's reads, and the gene's deep copy-number calls in the patient (Copy #). " + - "Copy-number alterations: the deep calls (AMP, HOMDEL), one row per gene and call. Structural variants: one row per event (both genes and breakpoints, class, event info), the highest read counts among its samples. " + - "Cohort % is the share of the study's samples profiled for the gene (by their gene panel) with any mutation in it, or with the same copy-number call" + - (t.oncokbPin ? "; the annotation is the OncoKB " + t.oncokbPin + " call for the protein change, or for AMPLIFICATION / DELETION" : "") + ".", - params: t.tables.map((x) => [x.name, (x.queryText ? x.total.toLocaleString("en-US") + " of " : "") + x.all.toLocaleString("en-US") + " rows" + (x.queryText ? " (kept: " + x.queryText + ")" : "")] as [string, string]), - }); - return { steps, implementation: { functions: ["runAlterations", "alterationPatientRowSource", "patientMutationsSource", "patientCnaSource", "patientSvSource", "pageSql", "countsSql", "rankSql"], - file: "lib/views/alterations.ts, lib/patient-alterations.ts, lib/table-query.ts", commit: BUILD_COMMIT }, - verification: "A listing: no statistic is computed, so there is no script — the queries are the numbers." }; -} - -// ---- riskmodel ------------------------------------------------------------------------------ - -export interface RiskmodelTrace { - study: string; cohort: string; cohortPatients: number; rowsReturned: number; - r: import("./types.ts").RiskmodelResult; - lambdas: number; folds: number; testShare: number; - /** Table 1's numeric rows: each group's value distribution, for the script */ - table1Histograms: Table1Histogram[]; -} - -/** - * The risk model's steps. No script recomputes it: the ensemble draws thousands of fits from a - * seeded generator, and the fit itself is what test/coxnet-golden.test.ts pins against - * scikit-survival and lifelines. The record names that test instead. - */ -export function riskmodelMethod(t: RiskmodelTrace): MethodRecord { - const r = t.r; - const out: [string, string][] = [["left out: no mutation panel on record", String(r.notProfiled)]]; - if (r.strata) out.push(["left out: no " + r.strata, String(r.noStratum)]); - if (r.covariates.length) out.push(["left out: missing a covariate", String(r.noCovariate)]); - if (r.anchoredTo) out.push(["left out: no " + r.anchoredTo + " event", String(r.noAnchor)], ["left out: anchor after the study's day 0", String(r.anchorAfterStart)], - ["left out: no follow-up after entry", String(r.noFollowUp)]); - const steps: MethodStep[] = [ - { kind: "fetch", title: "One row per patient", scope: t.cohort + " · " + t.study, rowsIn: t.cohortPatients, rowsOut: t.rowsReturned, - detail: "The riskmodel:rows query above: each patient's survival time and event" + (r.anchoredTo ? " (from their " + r.anchoredTo + " event, with that event's offset to the study's day 0 as their entry time)" : "") + - ", the panels their samples were sequenced on for mutations, the genes " + (r.measure === "driver" ? "with an OncoKB driver mutation" : "mutated") + " in them" + - (r.strata ? ", their " + r.strata : "") + (r.covariates.length ? ", their covariate values" : "") + - ", and their half of the engine's fixed held-out split. Patient-level: the rows never leave this server's engine, and no identifier is returned." }, - { kind: "transform", title: "Patients, candidate genes and covariates", - detail: "A gene is a candidate only if every patient's panels cover it, so no patient is counted as wild-type for a gene they were never tested for, " + - "and only if at least " + r.minMutated + " patients have it mutated." + - (r.covariates.length ? " Covariates enter as they are when every value is a number, else as one indicator per value against the commonest; they are never penalised." : ""), - params: [["genes covered for every patient", String(r.genesCovered)], ["candidates", String(r.candidates)], ...out] }, - { kind: "statistic", title: "Elastic-net Cox, " + r.runs + " random splits", - detail: (r.validation ? "Only the discovery half (" + r.validation.discovery + " patients) is used. " : "") + - "Each run holds out a random " + Math.round(t.testShare * 100) + "% of the patients; on the rest, the penalty is chosen from a path of " + t.lambdas + - " by " + t.folds + "-fold cross-validated partial likelihood (Verweij & van Houwelingen), and the model at that penalty scores the held-out patients" + - (r.validation ? " and every patient of the validation half" : "") + ". Genes are scaled to unit variance before fitting; Breslow ties" + - (r.strata ? "; a separate baseline hazard per " + r.strata : "") + (r.anchoredTo ? "; delayed entry" : "") + - ". A patient's risk score is the mean of the scores they got while held out (OncoCast's ensemble, Martin et al.)" + - (r.validation ? "; a validation patient's is the mean over every run" : "") + ".", - params: [["l1 share of the penalty (alpha)", String(r.alpha)], ["runs", String(r.runs)], ["seed", String(r.seed)], - ["test-set C-index, median (IQR)", r.cIndex.median.toFixed(3) + " (" + r.cIndex.q1.toFixed(3) + "–" + r.cIndex.q3.toFixed(3) + ")"], - ...(r.validation ? [["C-index on the validation half", r.validation.cIndex.toFixed(3)] as [string, string]] : []), - ["never held out, so unscored", String(r.neverHeldOut)], - ...r.covariates.map((c) => [c.name, "HR " + c.hr.toFixed(3) + " (mean over runs)"] as [string, string])] }, - { kind: "statistic", title: "Risk groups, their survival and hazard ratios", - detail: (r.cutsChosen ? "Cuts at the quantiles the view names (" + r.cutsChosen.join(", ") + ")" : "Cuts at equal quantiles") + " of the " + - (r.validation ? "discovery half's held-out scores, applied to the validation half" : "held-out scores") + - ", each moved to the nearer edge of any run of tied scores so equal scores share a group" + - (r.cutsChosen ? " — the quantiles are the view's; the computation does not use the outcome to place them, but whoever chose them may have." : " — no outcome is used to place them.") + " Each group is drawn as a Kaplan–Meier curve" + - (r.anchoredTo ? " with delayed entry" : "") + " and read at 12 and 36 months; the groups are compared with a " + (r.strata ? "stratified " : "") + - "log-rank test, and a Cox model of the outcome on the group indicators gives each group's hazard ratio against the lowest (Newton–Raphson, full information matrix, 95% Wald intervals). " + - "A second Cox model on the standardised score alone gives the hazard ratio per standard deviation. " + - "The score is also shown on OncoCast's 0–10 scale, (score − min) / (max − min) × 10, min and max the lowest and highest " + - (r.validation ? "held-out score in the discovery half (so a validation patient can fall outside 0–10)" : "held-out score") + - "; the scale is linear, so it moves no patient between groups and changes no number above.", - params: [["0 on the 0–10 scale (raw score)", r.scale.min.toFixed(4)], ["10 on the 0–10 scale (raw score)", r.scale.max.toFixed(4)], - ...r.groups.map((g) => [g.name, g.curve.n.toLocaleString() + " patients, " + g.curve.events.toLocaleString() + " events, median " + - (g.curve.median == null ? "not reached" : g.curve.median.toFixed(1) + " months") + (g.hr != null ? ", HR " + g.hr.toFixed(2) : "")] as [string, string])] }, - ]; - const fitVerification = "lib/coxnet.ts's path is checked against scikit-survival's CoxnetSurvivalAnalysis, its delayed-entry, stratified fits (with standard errors) against statsmodels' PHReg and its C-index against lifelines, by test/coxnet-golden.test.ts (fixtures from python/golden/coxnet.py)."; - const implementation = { functions: ["coxnetEnsemble", "coxnetPath", "cvLambda", "concordance", "coxFit"], file: "lib/coxnet.ts", commit: BUILD_COMMIT }; - if (!r.table1) return { steps, implementation, verification: fitVerification }; - const t1 = table1Record(r.table1, t.table1Histograms); - return { steps: [...steps, ...t1.steps], - implementation: { ...implementation, functions: [...implementation.functions, "buildTable1", "chiSquareTest", "kruskalWallis", "quantilesFromHistogram", "benjaminiHochberg"], - file: implementation.file + ", lib/views/riskmodel-table1.ts, lib/stats.ts" }, - verification: fitVerification + " The script below recomputes Table 1's tests and q-values from its counts (the model itself it does not refit); χ² matches scipy " + VERIFIED.chi2.version + - " chi2_contingency, Kruskal–Wallis and quantiles scipy/numpy, Benjamini–Hochberg statsmodels multipletests (test/tables-golden.test.ts, test/moments-golden.test.ts).", - python: withResult(t1.python), check: t1.check }; -} - -/** Table 1's steps, and the script that recomputes its tests from its counts. */ -function table1Record(t: import("./types.ts").RiskTable1, histograms: Table1Histogram[]) { - const fmtP = (p: number | null) => p == null ? "—" : pFmt(p); - const shown = t.rows.filter((x) => x.kind !== "skipped"); - const groups = t.groups.map((g) => g.name); - const steps: MethodStep[] = [ - { kind: "fetch", title: "Table 1: the groups' patients again, with their clinical values", - detail: "The riskmodel:table1 query above is the riskmodel:rows query with Table 1's attributes added: for each, the patient's value (a sample-level attribute: their first sample's), " + - "case and whitespace folded as everywhere in this app and shown in its commonest spelling, and how many values their samples hold. Its rows are kept exactly as the model's were and must match " + - "the model's patients one by one — survival time, entry and event — or the view is refused, so each group's column describes the patients its curve drew" + - (t.reportedOn === "validation half" ? " (the validation half)" : "") + ". riskmodel:table1-attributes gives each attribute's type (NUMBER is numeric), riskmodel:panels each mutation panel's genes. " + - "Patient-level: the rows never leave this server's engine; only the counts below do.", - params: t.groups.map((g) => [g.name, g.n.toLocaleString("en-US") + " patients"] as [string, string]) }, - { kind: "transform", title: "Table 1's rows", - detail: (t.defaults.attributes - ? "Attributes by the default rule: the model's stratum and covariates, then the study's attributes in its priority order, leaving out survival endpoints' columns (…_MONTHS, …_STATUS), " + - "considering the first " + TABLE1_CONSIDERED + "; of those, an attribute is left out if no patient here has a value, if it takes one value among them, or if it is categorical with more than " + - TABLE1_DEFAULT_MAX_VALUES + " values (free text, ids); the first " + TABLE1_DEFAULT_ATTRIBUTES + " that remain are shown." - : "Attributes as the view names them; a categorical one with more than " + TABLE1_MAX_VALUES + " values among these patients is set aside.") + " " + - (t.defaults.genes ? "Genes: the " + t.rows.filter((x) => x.kind === "gene").length + " the model kept in the most runs." : "Genes as the view names them.") + " " + - "A gene is counted as mutated the way the model counts it (" + (t.measure === "driver" ? "OncoKB drivers" : "any mutation") + "), and only among the patients whose mutation panels cover it " + - "(a whole-exome patient covers every gene): a patient whose panels do not is not profiled for it, never unaltered, and is counted as such in each group. " + - "A row the groups differ in by construction is marked: a gene the model kept in any run and a covariate are \"in the model\" (the score is built from them), the endpoint's own columns are \"outcome\" (the groups are cut to differ in survival); the model's stratum is marked \"stratum\" but is a description, since the score has no stratum term.", - params: [...t.omitted.map((o) => ["left out: " + o.name, o.why] as [string, string]), - ...t.rows.filter((x) => x.kind === "skipped").map((x) => ["set aside: " + x.name, (x as { skipped: string }).skipped] as [string, string])] }, - { kind: "statistic", title: "Each row tested across the groups", - detail: "A categorical attribute: Pearson χ² on the group × value table, no continuity correction, an all-zero row or column dropped first, patients with no value left out. " + - "A gene: Pearson χ² on the group × (mutated, not mutated) table of its profiled patients. A numeric attribute: Kruskal–Wallis on each group's values, ranks averaged within ties and tie-corrected; medians and quartiles interpolating linearly between ranks. " + - "The smallest expected count is reported, since χ² is poor below about 5.", - params: shown.map((x) => [table1RowKey(x).replace(/^attribute:/, ""), x.p == null ? "not tested: " + (x as { untested?: string }).untested - : (x.test === "chi-squared" ? "χ² " : "H ") + x.statistic + " on " + x.df + " df · p " + fmtP(x.p) + (x.role ? " · " + x.role : "")] as [string, string]) }, - { kind: "statistic", title: "Benjamini–Hochberg across Table 1's open rows", - detail: "q-values over the " + t.tested + " tested rows that are not in the model and not its outcome. Those rows differ between the groups by construction, so they get a p-value but no q-value and do not enter the correction, where their p-values would shrink every other row's q." }, - ]; - const tested = shown.filter((x) => x.p != null); - const cat = tested.filter((x) => x.kind === "cat") as Extract[]; - const genes = tested.filter((x) => x.kind === "gene") as Extract[]; - const numKeys = new Set(tested.filter((x) => x.kind === "num").map(table1RowKey)); - const hist = histograms.filter((h) => numKeys.has(h.row)); - const family = tested.filter((x) => x.q != null).map(table1RowKey); - const python = [ - "# Recompute Table 1's tests from its counts: scipy " + VERIFIED.chi2.version + " and statsmodels " + VERIFIED.bh.version + " are the versions our code is tested against.", - "import io", - "import numpy as np", - "import pandas as pd", - "from scipy.stats import chi2_contingency, kruskal", - "from statsmodels.stats.multitest import multipletests", - "", - "groups = " + JSON.stringify(groups), - RESULT_INIT, - "", - "def chi2_p(table):", - " table = table[table.sum(axis=1) > 0][:, table.sum(axis=0) > 0] # drop an all-zero row or column", - " return chi2_contingency(table, correction=False).pvalue", - ...(cat.length ? ["", - ...pyTable("categorical", "Categorical rows: patients per group (by index into groups) and value (by index into the row's values)", ["row", "group", "level", "n"], - cat.flatMap((x) => x.levels.flatMap((l, li) => l.counts.map((n, gi) => [table1RowKey(x), gi, li, n]))), "table1_categorical.csv"), - "for row, g in categorical.groupby(\"row\", sort=False):", - " table = g.pivot_table(index=\"group\", columns=\"level\", values=\"n\", aggfunc=\"sum\", fill_value=0).to_numpy()", - " RESULT[f\"table1:{row}:p\"] = chi2_p(table)", - " print(f\"{row}: chi-squared p={RESULT[f'table1:{row}:p']:.4g}\")"] : []), - ...(genes.length ? ["", - ...pyTable("genes", "Gene rows: per group (by index into groups), patients with the gene mutated and patients whose panels cover it", ["row", "group", "altered", "profiled"], - genes.flatMap((x) => x.perGroup.map((c, gi) => [table1RowKey(x), gi, c.altered, c.profiled])), "table1_genes.csv"), - "for row, g in genes.groupby(\"row\", sort=False):", - " table = np.column_stack([g[\"altered\"], g[\"profiled\"] - g[\"altered\"]])", - " RESULT[f\"table1:{row}:p\"] = chi2_p(table)", - " print(f\"{row}: \" + \", \".join(f\"{a}/{p}\" for a, p in zip(g[\"altered\"], g[\"profiled\"])) + f\" mutated; chi-squared p={RESULT[f'table1:{row}:p']:.4g}\")"] : []), - ...(hist.length ? ["", - ...pyTable("numeric", "Numeric rows: each group's values, as (value, patients)", ["row", "group", "value", "n"], hist.map((h) => [h.row, h.group, h.value, h.n]), "table1_numeric.csv"), - "for row, g in numeric.groupby(\"row\", sort=False):", - " values = {grp: np.repeat(h[\"value\"].to_numpy(), h[\"n\"].to_numpy()) for grp, h in g.groupby(\"group\", sort=False)}", - " for grp, v in values.items():", - " for name, q in ((\"median\", 50), (\"q1\", 25), (\"q3\", 75)):", - " RESULT[f\"table1:{row}:{grp}:{name}\"] = np.percentile(v, q)", - " RESULT[f\"table1:{row}:p\"] = kruskal(*values.values()).pvalue", - " print(f\"{row}: Kruskal-Wallis p={RESULT[f'table1:{row}:p']:.4g}\")"] : []), - "", - "# q-values over the open rows only: those in the model or its outcome differ by construction", - "family = " + JSON.stringify(family), - "if family:", - " for row, qv in zip(family, multipletests([RESULT[f\"table1:{r}:p\"] for r in family], method=\"fdr_bh\")[1]):", - " RESULT[f\"table1:{row}:q\"] = qv", - ].join("\n") + "\n"; - const medians = tested.filter((x) => x.kind === "num").flatMap((x) => (x as Extract).perGroup - .map((c, gi) => ({ key: table1RowKey(x) + ":" + groups[gi], ...c })).filter((c) => c.n > 0)); - const check = { - ...checks("table1:", tested.map((x) => ({ key: table1RowKey(x), p: x.p, ...(x.q != null ? { q: x.q } : {}) })), (x) => x.key, [["p", "p"], ["q", "q"]]), - // medians and quartiles are reported to 3 decimals - ...checks("table1:", medians, (m) => m.key, [["median", "median", { abs: 5e-4 + 1e-9 }], ["q1", "q1", { abs: 5e-4 + 1e-9 }], ["q3", "q3", { abs: 5e-4 + 1e-9 }]]), - }; - return { steps, python, check }; -} - -export function timelineMethod(t: { cohorts: string[]; queryText: string; sortName: string | null; desc: boolean; shown: number; - lists: TableLists; excluded: { study: string; reason: string; patients: number }[]; instances: number; - patient: { study: string; id: string; events: number; samples: number } | null }): MethodRecord { - const steps = tableSteps({ label: "timeline", what: "patients", queryText: t.queryText, shown: t.shown, lists: t.lists, instances: t.instances, excluded: t.excluded, - source: "every patient of each cohort, with their sample count and, from the study's timeline, the number of events, the first and last event day (a range counted to its stop) and the event types — a patient with no timeline is listed with none.", - order: t.sortName ? "by " + t.sortName + (t.desc ? " descending" : "") + " with missing values last, then patient id" : "patients with events first, then by patient id" }); - if (t.patient) steps.push({ - kind: "fetch" as const, title: "Read " + t.patient.id + "'s timeline", - detail: "The timeline:events and timeline:samples queries above: every clinical event recorded for " + t.patient.id + " in " + t.patient.study + - " with all its attributes, and the patient's samples with their SAMPLE_TYPE. Events are drawn as stored, in days from diagnosis; tracks group them by event type (case-folded) and nest them by attribute as cBioPortal's patient view does.", - params: [["events", t.patient.events.toLocaleString("en-US")], ["samples", t.patient.samples.toLocaleString("en-US")]], - }); - return { steps, implementation: { functions: ["runTimeline", "timelinePatientRowSource", "patientEventsSql", "buildTracks", "pageSql", "countsSql", "rankSql"], file: "lib/views/timeline.ts, lib/patient-timeline.ts, lib/timeline-tracks.ts, lib/table-query.ts", commit: BUILD_COMMIT }, - verification: "A listing and one patient's record: no statistic is computed, so there is no script — the queries are the numbers." }; -} - diff --git a/lib/next-cache.ts b/lib/next-cache.ts index 10d018d..5827501 100644 --- a/lib/next-cache.ts +++ b/lib/next-cache.ts @@ -39,8 +39,9 @@ import { runAnalysis } from "./analysis.ts"; import { buildContract } from "./oncoprint.ts"; import { hasCustomData } from "./custom-data.ts"; import { hasIdSets } from "./id-sets.ts"; -import { riskFitDirect, type RiskFit, type RiskFitArgs } from "./views/riskmodel.ts"; -import type { AnalysisDoc, AnalysisResult, Contract, OncoprintSpec } from "./types.ts"; +import { riskFitDirect, type RiskFit, type RiskFitArgs } from "./views/riskmodel/run.ts"; +import type { AnalysisDoc, AnalysisResult, Contract } from "./types.ts"; +import type { OncoprintSpec } from "./views/oncoprint/types.ts"; type Json = unknown; diff --git a/lib/oncoprint-assemble.ts b/lib/oncoprint-assemble.ts index 47ede6c..547af0a 100644 --- a/lib/oncoprint-assemble.ts +++ b/lib/oncoprint-assemble.ts @@ -9,7 +9,8 @@ import { heatmapTrackLabel, profiledTrackLabel, PROFILED_TYPES, SAMPLES_TRACK, STUDY_TRACK, type ProfiledType } from "./profile-names.ts"; import { normalizeGeneTracks, type GeneTrackSpec } from "./gene-tracks.ts"; import { STYLE, encodeNumeric } from "./oncoprint-style.ts"; -import type { Contract, Track, GroupRange, ZReference, ResolvedDisplay, DisplaySpec, SortKeySpec } from "./types.ts"; +import type { Contract, Track, GroupRange, ResolvedDisplay, DisplaySpec } from "./types.ts"; +import type { ZReference, SortKeySpec } from "./views/oncoprint/types.ts"; // gene-track cell bitmask: one definition, the style's export const BITS = STYLE.bits; @@ -89,7 +90,6 @@ export function normalizeDisplay(d: DisplaySpec | undefined): ResolvedDisplay { }; } - export interface AssembleInput { /** * The rows, in order: a gene symbol, or `{label?, genes}` for a merged row. `normalizeGeneTracks` diff --git a/lib/oncoprint-detail.ts b/lib/oncoprint-detail.ts index bb2ffff..0682cf5 100644 --- a/lib/oncoprint-detail.ts +++ b/lib/oncoprint-detail.ts @@ -7,7 +7,7 @@ import { chQueryRows, STUDY } from "./ch.ts"; import { sq } from "./compiler.ts"; import { buildAnnotationIndex } from "./annotation.ts"; -import type { OncoprintSpec } from "./types.ts"; +import type { OncoprintSpec } from "./views/oncoprint/types.ts"; export interface ColumnDetail { column: string; diff --git a/lib/oncoprint.ts b/lib/oncoprint.ts index e1a9f90..a0ef30a 100644 --- a/lib/oncoprint.ts +++ b/lib/oncoprint.ts @@ -6,7 +6,7 @@ // panel-aware track frequencies, group ranges. The renderer does zero data logic. import { chQuery, chQueryRows, DB, STUDY, PRIMARY, type ChConn } from "./ch.ts"; import { compileCohort, sq } from "./compiler.ts"; -import type { CohortDef, Contract, OncoprintSpec, HeatmapSpec, ChRow } from "./types.ts"; +import type { CohortDef, Contract, ChRow } from "./types.ts"; // Internal-only: analysis.js passes an evaluated cohort's SQL as a subquery via this // Symbol key. JSON.parse cannot produce Symbol keys, so client specs can never inject @@ -28,6 +28,7 @@ import { assembleContract, normalizeDisplay, normalizeSort, type AssembleInput, import { normalizeGeneTracks, toGeneList } from "./gene-tracks.ts"; import { clinicalTable, assertCustomDataPresent } from "./custom-data.ts"; import { finiteSql } from "./sql-number.ts"; +import type { OncoprintSpec, HeatmapSpec } from "./views/oncoprint/types.ts"; export { normalizeDisplay, normalizeSort } from "./oncoprint-assemble.ts"; const GENE_RE = /^[A-Z0-9][A-Z0-9.-]{0,19}$/; diff --git a/lib/result-text.ts b/lib/result-text.ts index a530d1b..db4a97e 100644 --- a/lib/result-text.ts +++ b/lib/result-text.ts @@ -2,12 +2,14 @@ // the author's notes, the way a model should read it. One renderer for every agent-facing // surface — the MCP run_analysis tool, the analysis page's WebMCP tools — so they cannot // describe the same result differently. Pure and browser-safe. -import { isDrawn, type AnalysisDoc, type AnalysisResult, type ViewResult, type AssaysEntity, type CorrelatesGene } from "./types.ts"; +import { isDrawn, type AnalysisDoc, type AnalysisResult, type ViewResult } from "./types.ts"; import { lollipopSummaryText } from "./lollipop-svg.ts"; -import { riskSpan } from "./figures/riskmodel.ts"; -import { txMedianText } from "./figures/txendpoints.ts"; -import { MS_TRANSITIONS, msRateText } from "./figures/multistate.ts"; -import { rmstText, rmstDiffText, rmstRatioText } from "./figures/rmst.ts"; +import { riskSpan } from "./views/riskmodel/figure.ts"; +import { txMedianText } from "./views/txendpoints/figure.ts"; +import { MS_TRANSITIONS, msRateText } from "./views/multistate/figure.ts"; +import { rmstText, rmstDiffText, rmstRatioText } from "./views/rmst/figure.ts"; +import type { AssaysEntity } from "./views/assays/types.ts"; +import type { CorrelatesGene } from "./views/correlates/types.ts"; /** a timeline's events listed in text (MCP); the figure draws every one */ const TIMELINE_TEXT_EVENTS = 400; diff --git a/lib/types.ts b/lib/types.ts index f2418cf..fdff1e0 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -398,38 +398,6 @@ import type { GeneTrackSpec } from "./gene-tracks.ts"; import type { TableQuery, ColumnValues } from "./table-query.ts"; export type { TableQuery, ColumnValues, ColumnFilter, TextFilterOp } from "./table-query.ts"; export type { GeneTrackSpec }; - -export interface CompareView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; - kind: "compare"; genes: string[]; cohorts?: string[]; splitBy?: SplitBy; -} -export interface SurvivalView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; - kind: "survival"; cohorts?: string[]; splitBy?: SplitBy; - /** endpoint prefix: OS (default), PFS, DFS, DSS… reads _MONTHS/_STATUS */ - endpoint?: string; - /** - * Override the _MONTHS/_STATUS naming convention with the exact clinical - * attribute names, for a study whose outcome columns don't follow it (some do not — e.g. - * PFS_MONTHS_FIRST_LINE_TX/PROGRESSION_STATUS). Both must be given together; `endpoint`, if - * also given, is then just a display label, not something column names are derived from. - */ - monthsAttribute?: string; statusAttribute?: string; - /** - * Re-anchor the clock to a timeline event instead of the study's day 0. - * Delayed entry is applied automatically — without it, anchoring earlier than - * day 0 manufactures immortal time. - */ - anchor?: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; - /** administratively censor everyone still at risk past this month; truncates the plot too */ - cutoffMonths?: number; - /** x-axis tick spacing in months (default: 12, or 24 past 60mo of plotted range) */ - tickMonths?: number; - /** draw 1 - S(t), cumulative incidence, instead of survival (default "survival") */ - display?: "survival" | "incidence"; -} /** * A Cox covariate is exactly a plot axis: clinical, molecular, or a gene's alteration * status — plus, only here, an optional declared reference level for a categorical @@ -441,508 +409,6 @@ export interface SurvivalView { * silently doing nothing. */ export type CoxCovariate = PlotAxis & { reference?: string }; -/** - * Multivariate Cox proportional-hazards regression: several covariates at once, each - * reported as a hazard ratio with a confidence interval (drawn as a forest plot), rather than - * KM's one categorical split. Fit from real per-patient rows within each LOCAL study — unlike - * survival/compare, this does not pool aggregate counts across remote peers, so a participant - * without patient-level access simply cannot contribute to it (see CoxResult.excludedRemote). - */ -export interface CoxView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; - kind: "cox"; cohorts?: string[]; splitBy?: SplitBy; - /** endpoint prefix: OS (default), PFS, DFS, DSS… reads _MONTHS/_STATUS */ - endpoint?: string; - /** same escape hatch as SurvivalView.monthsAttribute/statusAttribute, for the same reason */ - monthsAttribute?: string; statusAttribute?: string; - /** one or more covariates to fit simultaneously; each becomes one or more forest-plot rows */ - covariates: CoxCovariate[]; - /** fit separate baseline hazards per study rather than pooling them (default false) */ - stratifyByStudy?: boolean; - /** - * Fit each named cohort as its own separate Cox model — its own coefficients, its own chosen - * reference levels, its own N/events — instead of pooling every cohort's patients into one - * fit (default false). Unlike `stratifyByStudy` (one shared model, a per-study baseline - * hazard via Cox's `stratum`), this produces N independent models: the right tool when the - * covariate EFFECT itself, not just the baseline hazard, might differ between cohorts (e.g. - * a gene's hazard ratio in NSCLC vs in CRC). A no-op when only one cohort is named. - */ - stratifyByCohort?: boolean; - /** administratively censor everyone still at risk past this month, same semantics as SurvivalView */ - cutoffMonths?: number; -} -export interface CoxCoefficient { - /** covariate name, or ": " for a categorical level */ - label: string; - /** the baseline level this coefficient is measured against, for a categorical covariate */ - reference?: string; - /** this coefficient's own level (the non-reference side), for a categorical covariate */ - level?: string; - /** which entry of the view's `covariates` array this came from — lets the UI group a - * categorical covariate's coefficients back together to offer its observed levels as a - * reference-level picker, without re-deriving them from raw rows */ - axisIndex?: number; - beta: number; se: number; hr: number; ciLow: number; ciHigh: number; p: number; -} -export interface CoxResult { - coefficients: CoxCoefficient[]; - n: number; events: number; - /** studies whose patients were left out of the fit for lacking patient-level access */ - excludedRemote?: { study: string; patients: number }[]; - /** - * Present only when the view asked for `stratifyByCohort` (and named 2+ cohorts): one - * independent Cox fit per cohort, in place of the single pooled fit above (`coefficients` - * here is then empty; `n`/`events` are the sum across cohorts, for the header only). Each - * cohort's `coefficients` chose its OWN reference levels from what IT observed, so the same - * covariate can read against a different reference in different cohorts — real information, - * not an inconsistency to paper over. - */ - byCohort?: { cohort: string; coefficients: CoxCoefficient[]; n: number; events: number; - excludedRemote?: { study: string; patients: number }[]; error?: string }[]; -} -/** - * Multivariate logistic regression: a BINARY outcome (not time-to-event) regressed on several - * covariates at once, reported as odds ratios with a forest plot — the same shape as `cox`, but - * for "did X happen" rather than "when did X happen". Reuses the exact same covariate/dummy- - * coding machinery as `cox` (buildCoxColumns): a categorical covariate is dummy-coded the same - * way, with the same reference-level rules (a mutated-vs-not axis defaults to "Not mutated"; - * others default to the most frequent level; `reference` overrides either). Fit from real - * per-patient rows within each LOCAL study, same boundary as `cox`. - */ -export interface LogisticView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; - kind: "logistic"; cohorts?: string[]; splitBy?: SplitBy; - /** the binary outcome, as a plot axis (clinical, molecular, or alteration status) */ - outcome: PlotAxis; - /** - * Which observed value of a CATEGORICAL outcome counts as the event (1) — every other observed - * value is collapsed into "not event" (0), and which values got collapsed is disclosed in the - * caveat rather than left implicit. Required for a categorical outcome; refused (not guessed) - * if omitted, or if it names a value the outcome never actually takes. Not used, and must be - * omitted, for a NUMERIC outcome — that one must already be coded exactly 0/1, refused - * otherwise (a continuous number silently coerced to 0/1 would be a guess about a threshold - * nobody asked for). - */ - outcomeEvent?: string; - /** one or more covariates to fit simultaneously; each becomes one or more forest-plot rows */ - covariates: CoxCovariate[]; -} -export interface LogisticCoefficient { - /** covariate name, or ": " for a categorical level */ - label: string; - /** the baseline level this coefficient is measured against, for a categorical covariate */ - reference?: string; - /** this coefficient's own level (the non-reference side), for a categorical covariate */ - level?: string; - axisIndex?: number; - beta: number; se: number; or: number; ciLow: number; ciHigh: number; p: number; -} -export interface LogisticResult { - coefficients: LogisticCoefficient[]; - n: number; events: number; - /** a categorical outcome's other observed values, collapsed into "not event" — named so that - * is a disclosed choice, not a silent one */ - outcomeOther?: string[]; - /** studies whose patients were left out of the fit for lacking patient-level access */ - excludedRemote?: { study: string; patients: number }[]; -} -/** - * Pan-cancer forest: one exposure's hazard ratio within each stratum of the cohort (a cancer type, - * a study, or a cohort), each from its own Cox fit, pooled by a DerSimonian–Laird random-effects - * meta-analysis (and the fixed-effect estimate beside it). Fit from per-patient rows within each - * local study, the same boundary as `cox`. - */ -export interface ForestView { - note?: string; - kind: "forest"; cohorts?: string[]; - /** the exposure, as a plot axis: a gene's alteration (not profiled is left out, never unaltered), a numeric or categorical attribute */ - exposure: PlotAxis; - /** a categorical exposure's level counted as exposed (default for an alteration axis: any alteration) */ - exposed?: string; - /** a numeric exposure: exposed when the value is at least this */ - threshold?: number; - /** endpoint prefix (default OS), and the cox view's escape hatch for other column names */ - endpoint?: string; monthsAttribute?: string; statusAttribute?: string; - /** what a row is: a categorical attribute (default CANCER_TYPE), "study", or "cohort" (the view's cohorts) */ - strata?: string; - /** adjust each stratum's fit for these (default none) */ - covariates?: CoxCovariate[]; - /** a stratum with fewer patients or events than these is pooled into "Other" (defaults 30 and 10) */ - minPatients?: number; minEvents?: number; - /** at most this many strata are drawn, the largest; the rest pool into "Other" (default 30) */ - maxStrata?: number; - /** what happens to the small strata: pooled into one "Other" row (default) or left out, listed */ - small?: "pool" | "drop"; -} -export interface ForestRow { - label: string; n: number; events: number; exposed: number; exposedEvents: number; - beta: number; se: number; hr: number; ciLow: number; ciHigh: number; p: number; - /** the row's share of the random-effects weight */ - weight: number; - /** an "Other" row: the strata it pools */ - pooled?: string[]; -} -export interface ForestPooled { hr: number; ciLow: number; ciHigh: number; p: number; beta: number; se: number } -export interface ForestResult { - exposure: string; exposedLabel: string; endpoint: string; strata: string; covariates: string[]; - rows: ForestRow[]; - /** strata that are not rows, and why */ - left: { label: string; n: number; events: number; reason: string }[]; - /** pooled estimates; null with fewer than two rows */ - fixed: ForestPooled | null; random: ForestPooled | null; - tau2: number; q: number; qDf: number; qP: number; i2: number; - /** patients with a time, the exposure and the stratum (and every covariate) */ - n: number; events: number; - notProfiled: number; missing: number; - excludedRemote?: { study: string; patients: number }[]; -} -/** - * Covariate-adjusted alteration enrichment between two cohorts: for every gene mutated in at least - * a minimum share of the patients profiled for it, a logistic regression of altered on cohort and - * covariates, reported as the adjusted odds ratio for the first cohort beside the raw one. Fit from - * per-patient rows inside each local study (patient-level participants only). - */ -export interface AdjustedView { - note?: string; - kind: "adjusted"; cohorts?: string[]; - /** clinical attributes adjusted for (default none): a number as recorded, a category against its commonest value */ - covariates?: string[]; - /** count every called mutation (default), or only OncoKB drivers */ - measure?: "any" | "driver"; - /** genes tested: mutated in at least this share of the patients profiled for them, both cohorts together (default 0.05) */ - minShare?: number; - /** and in at least this many patients (default 10) */ - minAltered?: number; - /** genes named on the volcano (default 12) */ - label?: number; -} -export interface AdjustedGene { - gene: string; - /** per cohort, in the view's order: patients altered, and patients profiled for the gene with every covariate */ - altered: [number, number]; profiled: [number, number]; - /** unadjusted odds ratio (null with an empty cell) and its two-sided Fisher p */ - rawOr: number | null; fisherP: number; - /** the cohort term's adjusted odds ratio, Wald 95% CI and p; q is Benjamini–Hochberg over the genes fit */ - or: number; ciLow: number; ciHigh: number; p: number; q: number; - /** a covariate category that would separate was merged into its reference for this gene */ - merged?: boolean; -} -export interface AdjustedResult { - groups: [string, string]; covariates: string[]; measure: "any" | "driver"; minShare: number; minAltered: number; - /** per cohort: patients with a mutation panel, and of those with every covariate (the ones fit) */ - patients: { cohort: string; withPanel: number; used: number }[]; - /** genes fit, by adjusted p */ - rows: AdjustedGene[]; - /** genes that passed the share threshold but could not be fit, and why */ - failed: { gene: string; reason: string }[]; - significant: number; - labels: string[]; - excludedRemote?: { study: string; patients: number }[]; -} -/** - * Clinical actionability from the version-pinned OncoKB store: each sequenced patient's highest - * sensitive therapeutic level (1, 2, 3A, 3B, 4) and any resistance level (R1, R2) over their - * alterations, as shares per cohort (optionally per group of an attribute), and for patients with - * a sample on each side of `pairBy` the concordance of their highest level between the two. Counts only. - */ -export interface ActionabilityView { - note?: string; - kind: "actionability"; cohorts?: string[]; - /** split each cohort by a categorical attribute (e.g. CANCER_TYPE); default none */ - groupBy?: string; - /** with groupBy, the largest groups drawn (default 8); the rest are pooled into a disclosed "Other" */ - top?: number; - /** the two sides compared for concordance (default SAMPLE_TYPE Primary against Metastasis); null turns it off */ - pairBy?: { attribute: string; a: string[]; b: string[] } | null; -} -/** patients per highest level: [1, 2, 3A, 3B, 4, none], and with a resistance level [R1, R2] */ -export interface ActionLevels { sequenced: number; levels: number[]; resistance: number[] } -export interface ActionabilityPanel extends ActionLevels { - cohort: string; - /** the cohort's patients, sequenced or not */ - patients: number; - groups?: (ActionLevels & { label: string })[]; - /** groups past `top`, pooled into the last group */ - pooled?: string[]; - /** patients with a sequenced sample on each side: table[a][b] over the six highest levels, and how many differ */ - concordance?: { pairs: number; table: number[][]; discordant: number }; -} -export interface ActionabilityResult { - pin: string; levels: string[]; groupBy: string | null; top: number; - pairBy: { attribute: string; a: string[]; b: string[] } | null; - panels: ActionabilityPanel[]; - /** the alteration types the store has levels for at this pin */ - storeTypes: string[]; - excluded?: { study: string; reason: string }[]; -} -/** - * Driver-negative analysis: one cohort split into patients with an OncoKB driver in a canonical - * driver list, patients with none who were profiled for every listed gene, and those not fully - * profiled (neither); then every other gene's alteration frequency compared between the first two. - * Counts only. - */ -export interface DrivernegativeView { - note?: string; - kind: "drivernegative"; cohorts?: string[]; - /** the canonical drivers (default the NSCLC set: EGFR, KRAS, ALK, ROS1, BRAF, MET, RET, ERBB2, NTRK1–3) */ - drivers?: string[]; - /** what is compared between the groups (default mutation; sv counts either fusion partner) */ - alteration?: "mutation" | "sv" | "cna"; - /** count every alteration (default), or only OncoKB drivers */ - measure?: "any" | "driver"; - /** genes altered in fewer patients than this, both groups together, are not tested (default 5) */ - minAltered?: number; - /** genes named on the volcano (default 12) */ - label?: number; -} -export interface DrivernegativeGene { - gene: string; cytoband?: string; - /** [driver-negative, driver-positive] patients altered, and profiled for the gene */ - altered: [number, number]; profiled: [number, number]; - log2Ratio: number | null; p: number; q: number; -} -export interface DrivernegativeResult { - drivers: string[]; alteration: string; measure: "any" | "driver"; minAltered: number; - /** the cohort's patients, those sequenced for mutations, and the three groups of those */ - patients: number; sequenced: number; positive: number; negative: number; unprofiled: number; - tested: number; untested: number; significant: number; - /** the most significant genes (≤300) */ - rows: DrivernegativeGene[]; - labels: string[]; - excluded?: { study: string; reason: string }[]; -} -/** - * Metastatic organotropism: for each organ a cohort's patients metastasised to and each gene, - * whether the gene's alteration goes with metastasis there, stratified by cancer type - * (Cochran–Mantel–Haenszel); with dated sites on the timeline, the cumulative incidence of - * metastasis to one site by one gene's status, death competing. Counts only. - */ -export interface OrganotropismView { - note?: string; - kind: "organotropism"; cohorts?: string[]; - /** where the sites come from (default auto: per-organ flags, else the timeline, else a biopsy-site attribute) */ - source?: "auto" | "flags" | "timeline" | "sample"; - /** flags: the per-organ Yes/No attributes' prefix (default DMETS_DX_, MSK-MET's) */ - flagPrefix?: string; - /** timeline: the event type and key whose value names a site (default Diagnosis / TUMOR_SITE) */ - timeline?: { eventType?: string; key?: string }; - /** sample: the biopsy-site attribute of metastasis samples (default METASTATIC_SITE), mapped to organs by our disclosed rules */ - siteAttribute?: string; - /** only these sites (labels as the result names them); default every site with enough patients */ - sites?: string[]; - /** the genes (at most 20); default the `top` most altered in the cohort */ - genes?: string[]; - /** how many most-altered genes when `genes` is not given (default 12, at most 20) */ - top?: number; - /** what counts as altered (default mutation) and whether only OncoKB drivers count (default driver) */ - alteration?: "mutation" | "cna" | "amp" | "homdel" | "sv"; - measure?: "any" | "driver"; - /** the stratifying covariate (default CANCER_TYPE); null for none */ - stratifyBy?: string | null; - /** a cell is tested only with at least this many altered patients and this many with the site (default 5) */ - minPatients?: number; - /** timeline source: the cumulative-incidence panel's site and gene (default the grid's most significant cell); null for none */ - incidence?: { site?: string; gene?: string } | null; -} -export interface OrganotropismSite { site: string; label: string; evaluable: number; withSite: number } -export interface OrganotropismCell { - site: string; gene: string; - /** patients evaluable for the site and profiled for the gene, pooled over strata: with the site (and of them altered), without it (and altered) */ - withSite: number; withAltered: number; without: number; withoutAltered: number; - /** Mantel–Haenszel common odds ratio over the strata, its log2, CMH chi-squared p and BH q; null when not tested */ - or: number | null; log2Or: number | null; p: number | null; q: number | null; strata: number; -} -export interface OrganotropismIncidence { - site: string; gene: string; - curves: IncidenceCurve[]; - horizons: { months: number; cells: { cohort: string; f1: number; lo: number; hi: number; atRisk: number }[] }[]; - gray?: import("./stats.ts").GrayTest; - /** patients whose first site event came after their last follow-up (the clock check) */ - late: number; records: number; -} -export interface OrganotropismResult { - source: "flags" | "timeline" | "sample"; sourceLabel: string; - alteration: string; measure: "any" | "driver"; stratifyBy: string | null; minPatients: number; - patients: number; - sites: OrganotropismSite[]; - genes: { gene: string; altered?: number }[]; - /** every (site, gene) cell, tested or not */ - cells: OrganotropismCell[]; - tested: number; significant: number; - /** sample source: the biopsy-site values counted as "Other", with their patients */ - otherValues?: { value: string; patients: number }[]; - incidence?: OrganotropismIncidence; - /** why there is no incidence panel */ - noIncidence?: string; - excluded?: { study: string; reason: string }[]; -} -/** - * A published molecular classifier applied per patient as derived subtypes — the endometrial - * classification (TCGA 2013 / ProMisE / WHO 2020): POLE-ultramutated, else MMR-deficient (MSI-high), - * else p53-abnormal, else no specific molecular profile, with "not classifiable" where an input is - * missing. Per cohort: subtype shares, subtype by an attribute, and OS by subtype. Counts only. - */ -export interface MolsubtypeView { - note?: string; - kind: "molsubtype"; cohorts?: string[]; - /** the classifier (only "endometrial" so far) */ - classifier?: "endometrial"; - /** the MSI call: a categorical attribute's MSI-high and stable values, or a numeric score's cut-offs (default MSI_TYPE Instable / Stable, else MSI_SCORE ≥ 10 / < 3) */ - msi?: { attribute?: string; high?: string[]; stable?: string[]; threshold?: number; stableBelow?: number }; - /** an abnormal p53: an OncoKB-driver TP53 mutation (default) or any protein-changing TP53 mutation */ - p53?: "driver" | "nonsynonymous"; - /** a patient attribute to cross the subtypes with (e.g. ANCESTRY_LABEL, STAGE_HIGHEST_RECORDED) */ - by?: string; - /** groups of `by` drawn (largest first; default 8), the rest pooled into a named Other */ - top?: number; - /** draw OS by subtype (default true) */ - survival?: boolean; -} -export interface MolsubtypeCounts { counts: number[]; classified: number; notClassifiable: number; why: Record } -export interface MolsubtypePanel extends MolsubtypeCounts { - cohort: string; patients: number; - groups?: (MolsubtypeCounts & { label: string })[]; - /** cohort's classified patients by group: chi-squared over group × subtype */ - byTest?: import("./stats.ts").ChiSquareTest; - pooled?: string[]; -} -export interface MolsubtypeResult { - classifier: string; classifierLabel: string; - /** the subtypes in hierarchy order */ - subtypes: string[]; - msi: string; p53: string; poleHotspots: string[]; - by: string | null; - panels: MolsubtypePanel[]; - /** classified patients' subtype shares between cohorts (two or more) */ - cohortTest?: import("./stats.ts").ChiSquareTest; - /** OS by subtype, per cohort (classified patients with OS_MONTHS) */ - survival?: { cohort: string; curves: (Omit & { subtype: string })[]; logrank: LogRankK | null; noTest?: string }[]; - noSurvival?: string; - excluded?: { study: string; reason: string }[]; -} -/** - * Germline carriers and their somatic second hits: per gene, the patients with a germline call, - * their share of the patients sequenced for the gene, and how often the tumour carries a second, - * somatic hit in the same gene, against the somatic rate in non-carriers; optionally OS by carrier - * status for one gene. Patient-level counts only. - */ -export interface GermlineView { - note?: string; - kind: "germline"; cohorts?: string[]; - /** the genes (at most 60); default every gene with at least minCarriers carriers in a cohort */ - genes?: string[]; - minCarriers?: number; - /** draw OS for one gene's carriers against non-carriers */ - survival?: { gene: string }; -} -export interface GermlineCounts { - /** patients sequenced for the gene (a mutation panel listing it, or WES), carriers among them, and carriers whose panel does not list it (left out) */ - sequenced: number; carriers: number; outsidePanel: number; - carrierSomatic: number; carrierCnaProfiled: number; carrierDeletion: number; carrierEither: number; - noncarriers: number; noncarrierSomatic: number; noncarrierCnaProfiled: number; noncarrierDeletion: number; noncarrierEither: number; -} -export interface GermlineGene extends GermlineCounts { - gene: string; - /** Fisher's exact test of carriers' second-hit rate against non-carriers' somatic rate, and BH q */ - p: number | null; q: number | null; -} -export interface GermlineResult { - genes: string[]; minCarriers: number; chosen?: string; - panels: { cohort: string; patients: number; genes: GermlineGene[] }[]; - survival?: { cohort: string; gene: string; curves: (Omit & { group: string })[]; logrank: LogRankK | null; noTest?: string }[]; - /** what was done about loss of heterozygosity */ - loh: string; - excluded?: { study: string; reason: string }[]; -} -/** - * A plot: two axes over the same cases. The shape — scatter, box, contingency — follows from - * what the axes are, exactly as it does in the portal, rather than being asked for. - */ -export interface PlotView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; - kind: "plot"; x: PlotAxis; y: PlotAxis; cohorts?: string[]; - /** what a point is (default "sample"); patient mode combines a patient's samples (mean, common value or "Multiple", any alteration) */ - unit?: "patient" | "sample"; - /** the grid a scatter is binned onto (default 240 x 160) */ - bins?: [number, number]; - /** - * Every case is a point when there are at most POINTS_CAP (10,000) of them and the reader may see cases - * (default true); false draws the density alone at any size. - */ - allPoints?: boolean; - /** - * Subsets of the plotted cohort's cases drawn in colour over the plot, in order (at most 4): the - * outliers (past the cap: a box's values beyond its whiskers, a scatter's sparse cells), the - * extremes on an axis, the cases furthest from the fitted line, or a cohort's cases. Default - * [{rule:"outliers"}]; [] draws none. A case in two layers is drawn by the first. Every layer - * shares one POINTS_CAP budget; a cohort layer past it is drawn as density cells or boxes. - */ - points?: PointLayerSpec[]; - /** - * Colour the values by a category, as the portal's "Color samples by" does: `colorBy` fills - * each point, `ringBy` rings it, so a case that is both amplified and mutated stays legible. - * Above the points cap the fill splits the density instead. - */ - colorBy?: PlotAxis; - ringBy?: PlotAxis; -} -export interface CooccurrenceView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; kind: "cooccurrence"; genes: string[]; cohorts?: string[] } -export interface ClinicalView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; kind: "clinical"; attributes: string[]; cohorts?: string[]; splitBy?: SplitBy } -export interface CoexpressionView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; kind: "coexpression"; genes: string[]; profileType: string; cohorts?: string[] } -export interface HotspotsView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; kind: "hotspots"; genes: string[]; cohorts?: string[]; minCount?: number; - /** - * The cells of the Driver/VUS × alteration class grid left out of the plot ("missense.vus", - * "truncating.driver", …): the grid beside it selects them. Applied where the figure is drawn, - * so a change re-runs nothing; the counts in the grid stay whole. - */ - hide?: string[] } -/** - * The clinical course: what fraction of the still-observable cohort is in each - * event state, over time relative to an anchor. - * - * `anchor` is optional for a single study (its own day 0 is a consistent - * baseline) but REQUIRED once more than one study participates: day 0 means - * different things in different studies — birth in one, first sequencing in - * another — and the database records nowhere which it is, so aligning on raw - * day 0 across studies compares incomparable clocks. - */ -export interface CourseView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; - kind: "course"; cohorts?: string[]; splitBy?: SplitBy; - /** canonical event names to measure; case is folded (lib/event-names.ts) */ - eventTypes: string[]; - anchor?: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; - /** bin width in months (default 3) */ - binMonths?: number; - /** window in months relative to the anchor (default -12 to 60) */ - fromMonths?: number; toMonths?: number; -} -export interface HeatmapSpec { - profileType: string; genes?: string[]; - /** - * "federation" z-scores the drawn values against a reference distribution - * pooled from EVERY participating study — including instances that disclose - * no patients and so contribute no columns. Default "cohort" leaves values - * as the profile stores them. - */ - reference?: "cohort" | "federation"; -} -/** Pooled reference distribution for one (profileType, gene), from per-site moments. */ -export interface ZReference { mean: number; sd: number; n: number; studies: string[] } -/** A sort key: "genomic" (staircase), "id", or a track label; "-label" or {dir:"desc"} descends. */ -export type SortKeySpec = string | { track: string; dir?: "asc" | "desc" }; /** * How the figure is drawn. Every viewing option lives here rather than in browser * state, so a shared link, the server SVG, the CLI and the Python package all @@ -979,490 +445,6 @@ export interface DisplaySpec { } export type ResolvedDisplay = Required> & Pick; -export interface OncoprintView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; - /** rows: a gene symbol, or {label?, genes} for one merged row over several genes */ - kind: "oncoprint"; genes: GeneTrackSpec[]; cohorts?: string[]; splitBy?: SplitBy; - clinical?: string[]; heatmap?: HeatmapSpec[]; sort?: SortKeySpec[]; display?: DisplaySpec; - /** what a column is (default "patient"); "sample" draws every sample of the cohort's patients */ - unit?: "patient" | "sample"; - /** mRNA / protein evidence drawn into the gene cells (see OncoprintSpec.overlays) */ - overlays?: { kind: "mrna" | "protein"; profileType: string; threshold?: number }[]; - /** - * Document cohorts drawn as binary rows: a column is filled when that patient is in the - * cohort. Any cohort definition can be a row, which is what this engine has that the portal - * does not. Single-study and patient-level, like the definitions themselves. - */ - derivedCohorts?: string[]; -} -/** - * Treatment sequences: what follows what, as lines of therapy. - * - * `exclude` drops label values that are not therapies. It is a parameter rather - * than an engine rule because such markers are not structurally distinguishable - * from real treatments — in MSK-CHORD the SUBTYPE `Prior Medications to MSK` - * carries no duration, but neither do `Radiation Therapy` or `Investigational`. - * Putting the choice in the document keeps it visible to whoever reads the result. - */ -export interface SequenceView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; - kind: "sequence"; cohorts?: string[]; - /** event type carrying therapies (default: treatment) */ - eventType?: string; - /** attribute key labelling each therapy (default: SUBTYPE) */ - labelKey?: string; - exclude?: string[]; - /** days with no therapy that begin a new line (default 90) */ - lineGap?: number; - /** lines to follow (default 3) */ - maxSteps?: number; - /** distinct labels kept before the rest fold into "other" (default 8) */ - topN?: number; - /** which treatment records form lines: "systemic" (default), "all", or class names — lib/treatment-lines.ts */ - lineClasses?: "systemic" | "all" | string[]; -} -/** - * Cohort attrition: for each cohort, the patients remaining after each successive clause of its - * `all`, CONSORT-style — so a reader sees which criterion removed whom before trusting any - * downstream number. Counts only; every participant can answer. - */ -export interface AttritionView { - /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ - note?: string; - kind: "attrition"; cohorts?: string[]; -} -export interface AttritionStep { - /** the clause, in the same plain language as the cohort readback */ - label: string; - /** patients left after this clause and every clause above it */ - n: number; - /** removed by this clause alone, given the ones above it */ - excluded: number; - /** n as a share of the study base, one decimal */ - pctOfBase: number | null; -} -export interface AttritionPanel { cohort: string; base: number; steps: AttritionStep[]; studies: string[] } -/** - * Genome-wide copy-number frequency: the share of each cohort's segmented samples gained or lost - * in every bin along the genome, from the copy-number SEGMENTS (not the per-gene calls), with a - * per-bin Fisher test when exactly two cohorts are drawn. Counts only. - */ -export interface GenomeView { - note?: string; - kind: "genome"; cohorts?: string[]; - /** bin width in megabases, 1-50 (default 2) */ - binMb?: number; - /** |log2 segment mean| at or past which a bin is gained / lost (default 0.2) */ - threshold?: number; - /** draw chromosome X too (default false: its log ratio mostly records sex) */ - includeX?: boolean; -} -export interface GenomeSeries { - cohort: string; - /** the cohort's samples, and how many of them have any segment at all */ - samples: number; segmented: number; - /** per bin, aligned with GenomeResult.bins */ - profiled: number[]; gain: number[]; loss: number[]; -} -export interface GenomeResult { - build: "hg19" | "hg38"; binMb: number; threshold: number; includeX: boolean; - /** every bin, in genome order: its chromosome and start (bp) */ - bins: { chrom: string; start: number }[]; - series: GenomeSeries[]; - /** exactly two cohorts: Benjamini–Hochberg q per bin (null where either cohort has no sample) */ - test?: { gainQ: (number | null)[]; lossQ: (number | null)[]; significantGain: number; significantLoss: number }; - /** studies left out because they could not answer (a remote peer, or no segments at all) */ - excluded?: { study: string; reason: string }[]; -} -/** - * Oncogenic signalling pathways: for each pathway (the ten of Sanchez-Vega et al. 2018, and/or - * the document's own gene sets), the share of each cohort with at least one member altered — - * over the union of the members' panels, the oncoprint's merged-row rule — and its most-altered - * member genes, each with its own panel-aware denominator. Counts only. - */ -export interface PathwaysView { - note?: string; - kind: "pathways"; cohorts?: string[]; - /** which of the ten canonical pathways to draw (default: all ten) */ - pathways?: string[]; - /** the document's own gene sets, drawn after the canonical ones: ≤4 sets of ≤40 genes */ - sets?: { name: string; genes: string[] }[]; - /** count OncoKB drivers only (default, as the paper curated oncogenic events) or any alteration */ - measure?: "driver" | "any"; - /** member genes drawn per pathway, the rest pooled and counted (default 6, max 12) */ - topGenes?: number; -} -export interface PathwayCell { cohort: string; profiled: number; altered: number; pct: number | null } -export interface PathwayRow { - name: string; source: "Sanchez-Vega 2018" | "document"; - /** member genes, and how many were profiled in at least one cohort */ - genes: number; profiledGenes: number; - cells: PathwayCell[]; - /** two cohorts: two-sided Fisher p, and BH q across the pathways drawn */ - p?: number; q?: number; - top: { gene: string; role: "OG" | "TSG" | null; cells: PathwayCell[]; q?: number }[]; - /** member genes altered somewhere but not among `top` — pooled, never dropped */ - otherAltered: number; - /** member genes no cohort was profiled for */ - notProfiled: string[]; -} -export interface PathwaysResult { - measure: "driver" | "any"; rows: PathwayRow[]; - excluded?: { study: string; reason: string }[]; -} -/** - * Differential expression between exactly two cohorts on one molecular profile: every gene's - * Welch t-test from per-cohort moments (n, Σv, Σv² — additive, so exact across sites), drawn as - * a volcano, and preranked GSEA of that t ranking against MSigDB gene sets. - */ -export interface DiffexprView { - note?: string; - kind: "diffexpr"; cohorts?: string[]; - /** the profile to compare on (mRNA z-scores, RNA-seq, protein, methylation…) */ - profileType: string; - /** compare log2(v + 1) rather than v; defaults to true for a non-z-score RNA-seq profile */ - log2?: boolean; - /** MSigDB sets to test (≤12 names); default every HALLMARK_ set. [] skips enrichment */ - geneSets?: string[]; - /** genes labelled on the volcano (default 12, max 40) */ - label?: number; -} -export interface DiffexprGene { gene: string; nA: number; nB: number; meanA: number; meanB: number; effect: number; t: number; p: number; q: number } -export interface DiffexprResult { - profileType: string; log2: boolean; - /** what `effect` is: a difference of means on the compared scale */ - effectLabel: string; - /** cohorts in order: effect = first − second */ - groups: [string, string]; - tested: number; - /** genes left out for fewer than 3 patients in a cohort, or no variance in either */ - skipped: number; - up: number; down: number; - /** genes at q < 0.05, and the labelled ones, drawn individually (≤1,500, the most significant) */ - points: DiffexprGene[]; - /** every other tested gene, binned on the axes below: [x bin, y bin, count, 0 not significant | 1 higher in the first cohort | 2 in the second] */ - grid: { xMax: number; yMax: number; nx: number; ny: number; cells: [number, number, number, 0 | 1 | 2][] }; - /** genes q < 0.05 not drawn individually because of the cap */ - pooledSignificant: number; - labels: string[]; - gsea?: { ranking: string; permutations: number; sets: (import("./stats.ts").GseaResult & { q: number })[]; skipped: string[] }; - excluded?: { study: string; reason: string }[]; -} -/** - * Paired samples: among patients with a sample of each of two kinds (by default primary and - * metastasis), gene by gene, how often an alteration is on both, only the first or only the - * second — counted only where BOTH sides were profiled for the gene — with an exact McNemar test - * of whether discordance runs one way. Counts only. - * - * Without `genes` it scans: every gene altered in some paired patient is tested, and the `top` - * most discordant are drawn — the way to ask "what is acquired after treatment" without - * guessing the genes first. - */ -export interface PairSides { attribute: string; a: string | string[]; b: string | string[] } -export interface PairedView { - note?: string; - kind: "paired"; cohorts?: string[]; - /** the genes to test; omitted or empty scans every gene altered in a paired patient */ - genes?: string[]; - /** with no `genes`: how many of the most discordant genes to draw (default 20, at most 40) */ - top?: number; - /** - * a sample-level attribute and the values on each side (default SAMPLE_TYPE: Primary / Metastasis). - * `studies` gives a study its own attribute and values — studies label the same two kinds of - * sample differently (PRE-TX / POST-TX, pre-treatment / post-treatment) — and the counts are - * pooled across them; a study not listed uses the attribute and values at the top. - */ - pairBy?: Partial & { - studies?: Record; - /** what to call each side in the figure and text (default: its values) */ - labels?: { a: string; b: string }; - }; - /** count OncoKB drivers only (default) or any alteration */ - measure?: "driver" | "any"; -} -export interface PairedGene { - gene: string; - /** patients with each side profiled for this gene */ - pairs: number; both: number; aOnly: number; bOnly: number; neither: number; - /** both ÷ (both + aOnly + bOnly): of the patients altered anywhere, altered on both sides */ - concordance: number | null; - /** exact McNemar p on aOnly vs bOnly, and BH q across the genes of this cohort */ - p: number; q: number; -} -export interface PairedPanel { - cohort: string; patients: number; aSamples: number; bSamples: number; genes: PairedGene[]; - /** a scan: the genes with at least one discordant patient, which q is taken across (only `genes` are drawn) */ - tested?: number; -} -export interface PairedResult { - /** the sides as each answering study resolved them; `labels` names them in the figure and text */ - pairBy: { attribute: string; a: string[]; b: string[] }; - perStudy?: Record; - labels: { a: string; b: string }; - /** present when the view scanned rather than testing named genes */ - scan?: { top: number }; - measure: "driver" | "any"; - panels: PairedPanel[]; - excluded?: { study: string; reason: string }[]; -} -/** - * Alterations acquired under therapy: patients with a dated sample on or before a treatment start - * and another after it, per gene, altered after but not before (acquired), before but not after - * (lost), on both (shared) or neither — among patients whose two samples' panels both cover the gene. - */ -export interface AcquiredView { - note?: string; - kind: "acquired"; cohorts?: string[]; - /** the treatment start: the FIRST event of this type, with key = value when given (default Treatment, any) */ - anchor?: { eventType: string; key?: string; value?: string }; - /** the genes to test (at most 30); omitted or empty scans every gene altered in at least `minPost` after-samples */ - genes?: string[]; - /** a scan: the fewest patients altered after treatment for a gene to be tested (default 3) */ - minPost?: number; - /** a scan: how many genes to draw, most significant first (default 20, at most 40) */ - top?: number; - /** the before-sample is the last on or before the start, at most this many days before it (default: any) */ - preWindowDays?: number; - /** the after-sample is the first at least this many days after the start (default 30) */ - minGapDays?: number; - /** the timeline event types that date a sample (default Sample acquisition, Sequencing, Specimen) and the attribute naming it (default SAMPLE_ID) */ - sampleEvents?: string[]; sampleKey?: string; - /** any alteration (default) or OncoKB oncogenic only */ - measure?: "driver" | "any"; -} -/** what the clonal-haematopoiesis filter removed from a cohort's plasma calls, and the headline numbers without it */ -export interface CtdnaChPanel { - /** plasma mutation calls, those with matched-normal counts loaded, removed by gene / by the normal / by either, and the patients with a removed call */ - calls: number; withNormal: number; byGene: number; byNormal: number; removed: number; patients: number; - /** per gene, the calls and patients removed */ - genes: { gene: string; calls: number; patients: number; byGene: number; byNormal: number }[]; - /** without the filter */ - before: { - detected: number; detectedLow: number | null; detectedHigh: number | null; - genes: { gene: string; both: number; plasmaOnly: number; ppa: number | null }[]; - survival: { curves: { label: string; n: number; events: number; median: number | null }[]; p: number | null } | null; - }; -} -export interface AcquiredGene { - gene: string; - /** patients whose before- and after-sample both cover the gene */ - pairs: number; acquired: number; lost: number; shared: number; neither: number; - /** exact McNemar p on acquired vs lost, and BH q across the genes tested in this cohort */ - p: number; q: number; -} -export interface AcquiredPanel { - cohort: string; - /** the funnel: anchored, with a dated sample, with one before, with one after, with both */ - anchored: number; dated: number; withPre: number; withPost: number; patients: number; - /** among the pairs: median days from the before-sample to the start, from the start to the after-sample, and the pairs' spacing (quartiles) */ - preDays: number | null; postDays: number | null; gapDays: { q1: number; median: number; q3: number } | null; - genes: AcquiredGene[]; - /** a scan: the genes tested (altered after in at least minPost patients), which q is taken across */ - tested?: number; -} -export interface AcquiredResult { - anchoredTo: string; - preWindowDays: number | null; minGapDays: number; - sampleEvents: string[]; sampleKey: string; - measure: "driver" | "any"; - scan?: { top: number; minPost: number }; - panels: AcquiredPanel[]; - excluded?: { study: string; reason: string }[]; -} -/** - * ctDNA against tissue: patients with a plasma (cfDNA) sample and a tissue sample, per gene covered - * by both, altered in both, tissue only, plasma only or neither; plasma's positive percent - * agreement with tissue as the reference; the share of plasma patients with any alteration - * detected; and overall survival by ctDNA detected or not. - */ -export interface CtdnaView { - note?: string; - kind: "ctdna"; cohorts?: string[]; - /** the genes (at most 30); omitted, the `top` (default 15, at most 30) most altered in the paired patients */ - genes?: string[]; top?: number; - /** the sample attribute classing samples (default SAMPLE_CLASS) and its plasma (default cfDNA, ctDNA) and tissue (default Tumor) values */ - attribute?: string; plasma?: string[]; tissue?: string[]; - /** samples on these panels count as plasma whatever the attribute says (e.g. ["ACCESS129"]) */ - plasmaPanels?: string[]; - /** keep only samples the timeline dates within this many days of a sample of the other kind (default: no dates needed) */ - withinDays?: number; - sampleEvents?: string[]; sampleKey?: string; - /** any alteration (default) or OncoKB oncogenic only */ - measure?: "driver" | "any"; - /** - * leave out plasma mutation calls likely from clonal haematopoiesis: true for the defaults, or - * `genes` (default the canonical CH genes; [] for none) and `normal` (default true: calls whose - * matched white-cell sample has at least `normalMinAlt`, default 2, alternate reads at an allele - * fraction at least `normalMinRatio`, default 0.3, of the plasma's) - */ - chFilter?: boolean | { genes?: string[]; normal?: boolean; normalMinAlt?: number; normalMinRatio?: number }; -} -export interface CtdnaGene { - gene: string; - /** patients whose plasma and tissue samples both cover the gene */ - pairs: number; both: number; tissueOnly: number; plasmaOnly: number; neither: number; - /** positive percent agreement: both ÷ altered in tissue, with its 95% Wilson interval (null when tissue has none) */ - ppa: number | null; ppaLow: number | null; ppaHigh: number | null; - /** (both + neither) ÷ pairs */ - concordance: number | null; -} -export interface CtdnaPanel { - cohort: string; - /** patients with a plasma sample, a tissue sample, both; samples of each kind among those with both */ - plasmaPatients: number; tissuePatients: number; patients: number; plasmaSamples: number; tissueSamples: number; - /** with withinDays: before the date rule, patients with plasma and tissue; and the classed samples the timeline dated */ - undated?: { plasmaPatients: number; tissuePatients: number; datedSamples: number }; - /** of the plasma patients, those with any (driver) alteration called in plasma, with a 95% Wilson interval */ - detected: number; detectedLow: number | null; detectedHigh: number | null; - genes: CtdnaGene[]; - /** overall survival of the plasma patients by ctDNA detected or not; null with why when not drawn */ - survival: { curves: (Omit & { label: string })[]; logrank: LogRank | null; missing: number } | null; - survivalWhy?: string; - /** with chFilter */ - ch?: CtdnaChPanel; -} -export interface CtdnaResult { - attribute: string; plasma: string[]; tissue: string[]; plasmaPanels: string[]; - withinDays: number | null; - measure: "driver" | "any"; - /** present when the genes were chosen as the most altered */ - top?: number; - panels: CtdnaPanel[]; - excluded?: { study: string; reason: string }[]; - /** with chFilter: the rule applied */ - chFilter?: { genes: string[]; normalMinAlt: number | null; normalMinRatio: number }; -} -/** - * Mutual exclusivity and co-occurrence against each tumour's own alteration burden (DISCOVER, - * Canisius et al. 2016): per sample and gene a background probability fitted from the sample and - * gene marginals of the whole altered-gene matrix, so that heavily altered tumours are expected to - * share alterations; each pair's co-altered count tested against its Poisson-binomial null. - */ -export interface ExclusivityView { - note?: string; - kind: "exclusivity"; cohorts?: string[]; - /** 2 to 20 genes */ - genes: string[]; - /** estimate the background within each value of this sample attribute (e.g. CANCER_TYPE) */ - stratifyBy?: string; - /** any alteration (default) or OncoKB oncogenic only */ - measure?: "driver" | "any"; -} -export interface ExclusivityPair { - a: string; b: string; - /** samples profiled for both genes, those altered in both, and the background's expected number */ - profiled: number; both: number; expected: number; - /** DISCOVER: lower tail (exclusivity) and upper tail (co-occurrence), BH q in each direction */ - pExclusive: number; qExclusive: number; pCooccur: number; qCooccur: number; - /** false when the tails came from the refined normal approximation, not the exact distribution */ - exact: boolean; - /** the plain two-sided Fisher test on the same samples' 2×2, BH across the pairs */ - onlyA: number; onlyB: number; neither: number; fisherP: number; fisherQ: number; log2Odds: number | null; -} -export interface ExclusivityResult { - cohort: string; genes: string[]; - stratifyBy: string | null; strata: { name: string; samples: number }[]; - measure: "driver" | "any"; - /** samples with a panel, the genes in the background matrix, and how the fit ended */ - samples: number; matrixGenes: number; iterations: number; converged: boolean; maxResidual: number; - pairs: ExclusivityPair[]; - /** pairs no sample was profiled for both genes of */ - unassessable: string[]; - excluded?: { study: string; reason: string }[]; -} -/** - * Genomic instability from copy-number segments (hg19): per sample, the fraction of the genome - * altered and arm-level gains and losses, whose count is the aneuploidy score (Taylor et al. 2018); - * per cohort their distributions, a rank test between cohorts, and each arm's gain/loss frequency. - */ -export interface InstabilityView { - note?: string; - kind: "instability"; cohorts?: string[]; - /** |log2 segment mean| at or past which a segment is gained or lost (default 0.2) */ - threshold?: number; - /** an arm is called when more than this share of its length is past the threshold (default 0.5) */ - armShare?: number; - /** score chromosome X too (default false: its log ratio mostly records sex) */ - includeX?: boolean; -} -export interface InstabilitySpread { p5: number | null; q1: number | null; median: number | null; q3: number | null; p95: number | null; mean: number | null } -export interface InstabilityPanel { - cohort: string; - /** the cohort's samples, those with segments, and those with every scored arm measured */ - samples: number; segmented: number; allArms: number; - /** samples by aneuploidy score (index = arms altered), and by FGA in 1-point bins (index = percent) */ - score: number[]; fga: number[]; - scoreSpread: InstabilitySpread; fgaSpread: InstabilitySpread; - /** per arm (in `arms` order): the samples that measured it, and those gaining or losing it */ - arms: { arm: string; measured: number; gain: number; loss: number }[]; -} -export interface InstabilityResult { - build: "hg19"; threshold: number; armShare: number; includeX: boolean; - arms: string[]; - panels: InstabilityPanel[]; - /** two cohorts: Mann–Whitney; more: Kruskal–Wallis — on the aneuploidy score and on FGA */ - test: { name: "Mann–Whitney U" | "Kruskal–Wallis"; scoreP: number; fgaP: number } | null; - excluded?: { study: string; reason: string }[]; -} -/** - * Clonality from FACETS allele-specific copy number: for each gene, whether the cohort's - * mutation calls are clonal or subclonal, how often the mutated locus has lost its other allele, - * and their cancer-cell fractions; per cohort, whole-genome doubling, purity and ploidy. Only - * studies with allele-specific copy number can answer. Counts only. - */ -export interface ClonalityView { - note?: string; - kind: "clonality"; cohorts?: string[]; - genes: string[]; - /** OncoKB driver mutations only (default) or every mutation call */ - measure?: "driver" | "any"; - /** use only samples whose FACETS_QC is Pass (default true) */ - qc?: boolean; -} -export interface ClonalityGene { - gene: string; - /** mutation calls with a FACETS estimate, and the samples they are in */ - calls: number; samples: number; - clonal: number; subclonal: number; indeterminate: number; uncalled: number; - /** calls where the locus has no minor allele, of calls where the minor copy number is known */ - loh: number; lohKnown: number; - /** cancer-cell fraction, ten bins of 0.1 */ - ccf: number[]; - /** two cohorts: Fisher on clonal vs subclonal, BH across genes */ - p?: number; q?: number; -} -export interface ClonalityPanel { - cohort: string; samples: number; ascnSamples: number; qcPass: number; qcFail: number; - wgd: { yes: number; no: number }; - /** FACETS purity in 0.05 bins over 0–1, ploidy in 0.25 bins over 0–8 */ - purity: number[]; ploidy: number[]; - genes: ClonalityGene[]; -} -export interface ClonalityResult { - measure: "driver" | "any"; qc: boolean; - panels: ClonalityPanel[]; - /** two cohorts: Fisher on whole-genome doubling */ - wgdP?: number; - excluded?: { study: string; reason: string }[]; -} -/** - * Cumulative incidence of a timeline event with death as a competing risk (Aalen–Johansen, with - * the survival view's left truncation), Gray's test between cohorts, and — beside it — what - * 1 − Kaplan–Meier would have claimed, which treats the dead as still at risk and so overstates - * incidence whenever death is common. Counts only: (entry, time, cause) tallies per site. - */ -export interface IncidenceView { - note?: string; - kind: "incidence"; cohorts?: string[]; - /** the event of interest: its first occurrence after the anchor */ - event: { eventType: string; attrs?: { key: string; value: string }[]; label?: string }; - /** the clock's start (default: the study's day 0), as in the survival view */ - anchor?: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; - /** months at which incidence is read off and compared (default [12, 24, 60]) */ - horizons?: number[]; -} export interface IncidenceCurve { cohort: string; n: number; events: number; competing: number; censored: number; @@ -1472,736 +454,6 @@ export interface IncidenceCurve { /** kept, but their first event was recorded after their last follow-up */ late: number; } -export interface IncidenceResult { - eventLabel: string; anchoredTo?: string; - curves: IncidenceCurve[]; - horizons: { months: number; cells: { cohort: string; f1: number; lo: number; hi: number; naive: number; atRisk: number }[] }[]; - gray?: import("./stats.ts").GrayTest; - excludedStudies?: { study: string; reason: string }[]; -} -/** - * A lab value's course from a treatment start: % change from each patient's baseline over time - * (median and interquartile band per bin), the best response and the share responding (e.g. - * PSA50, PCWG3), and — from a participant entitled to patient-level values — a waterfall. - */ -/** - * A risk model in the manner of OncoCast (Martin et al., github.com/AxelitoMartin/OncoCast): an - * elastic-net Cox model of one cohort's survival on the genes mutated in it, fitted `runs` times - * on random two-thirds of the patients and scored on the held-out third. Each patient's risk score - * is the mean of the scores they got while held out; the patients are then cut into risk groups at - * quantiles of that score (no outcome is used to place the cuts) and drawn as Kaplan–Meier curves. - * Patient-level studies only: a model of individual patients needs their rows. - */ -export interface RiskmodelView { - note?: string; - kind: "riskmodel"; cohorts?: string[]; - endpoint?: string; monthsAttribute?: string; statusAttribute?: string; - /** "any" mutation (default) or OncoKB drivers only */ - measure?: "any" | "driver"; - /** only genes mutated in at least this many patients are candidates (default 30) */ - minMutated?: number; - /** a clinical attribute to stratify by (each stratum its own baseline hazard), e.g. CANCER_TYPE */ - strata?: string; - /** l1 share of the penalty: 1 lasso, 0 ridge (default 0.5, elastic net) */ - alpha?: number; - /** random splits (default 20, at most 100) */ - runs?: number; - /** risk groups, cut at equal quantiles of the score (default 3, 2 to 5) */ - groups?: number; - /** instead of `groups`: the quantiles of the score to cut at, increasing, each strictly between 0 and 1 - * (1 to 4 of them, so 2 to 5 groups) — e.g. [0.8] for the top fifth against the rest, OncoCast's `cuts` */ - cuts?: number[]; - seed?: number; - /** clinical attributes always in the model, never penalised (at most 6): numbers as they are, - * categories as indicators against the commonest value */ - covariates?: string[]; - /** start each patient's clock at a timeline event (as the survival view), with delayed entry */ - anchor?: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; - /** train on the discovery half of the engine's fixed split only, and report on the validation half */ - validate?: boolean; - /** the volcano names the genes kept in at least this share of runs (0 to 1, default 0.5); drawn in - * the browser from the whole result, so changing it re-runs nothing */ - labelAbove?: number; - /** Table 1: the risk groups' clinical and genomic makeup, tested across groups. Present, the view - * computes it; a list left out is the default (the study's leading attributes; the genes the - * model kept most often) */ - describe?: { attributes?: string[]; genes?: string[] }; - /** external validation: another cohort of the document, scored with the fitted ensemble's mean - * coefficients and cut at the training risk-group thresholds */ - validateOn?: string; -} - -/** A risk model scored on another cohort it never saw (RiskmodelView.validateOn). */ -export interface RiskExternal { - cohort: string; study: string; - /** patients scored, their events, and those left out with why */ - n: number; events: number; - cohortPatients: number; rowsReturned: number; - notProfiled: number; noStratum: number; noAnchor: number; anchorAfterStart: number; noFollowUp: number; - /** patients whose panels do not cover every gene the model weighs: never scored as wild-type */ - notCovered: number; - /** the model's genes with a non-zero ensemble coefficient, which every scored patient's panels cover */ - modelGenes: number; - /** Harrell's C-index of the score (within strata), with a seeded bootstrap 95% interval */ - cIndex: number; cIndexLo: number; cIndexHi: number; bootstrap: number; - /** the training thresholds (raw scores) the groups are cut at */ - cuts: number[]; - groups: { name: string; curve: Omit; hr: number | null; ciLow: number | null; ciHigh: number | null; p: number | null }[]; - logrank: LogRankK | null; -} - -/** Why a Table 1 row differs between the risk groups by construction, or is the model's stratum. */ -export type RiskTable1Role = "in the model" | "outcome" | "stratum" | null; - -interface RiskTable1Test { - /** null when the row cannot be tested (the reason in `untested`) */ - test: "chi-squared" | "Kruskal-Wallis" | null; statistic: number | null; df: number | null; p: number | null; - /** Benjamini–Hochberg across the tested rows that are not in the model or the outcome; null for those */ - q: number | null; - untested?: string; -} - -export type RiskTable1Row = - | ({ kind: "cat"; name: string; per: "patient" | "sample"; role: RiskTable1Role; - /** every value recorded among the groups' patients, commonest first, with its count per group */ - levels: { value: string; counts: number[] }[]; - /** per group, patients with no value recorded */ - missing: number[]; - /** patients whose samples hold more than one value (counted under their first sample's) */ - multiValued: number; minExpected: number | null } & RiskTable1Test) - | ({ kind: "num"; name: string; per: "patient" | "sample"; role: RiskTable1Role; - perGroup: { n: number; median: number | null; q1: number | null; q3: number | null; missing: number }[]; - multiValued: number } & RiskTable1Test) - | ({ kind: "gene"; name: string; role: RiskTable1Role; - /** per group: patients with the gene mutated, patients whose panels cover it, and those whose do not */ - perGroup: { altered: number; profiled: number; notProfiled: number }[]; minExpected: number | null } & RiskTable1Test) - | { kind: "skipped"; name: string; what: "attribute" | "gene"; skipped: string }; - -export interface RiskTable1 { - /** the patients described: the same as the groups' curves */ - reportedOn: "held-out scores" | "validation half"; - groups: { name: string; n: number }[]; - /** the gene rows count mutations as the model does */ - measure: "any" | "driver"; - /** what the table holds, as a document would name it — what "Add to document" writes */ - describe: { attributes: string[]; genes: string[] }; - /** the default lists were used (no list named in the view) */ - defaults: { attributes: boolean; genes: boolean }; - rows: RiskTable1Row[]; - /** attributes the default rule considered and left out, with why */ - omitted: { name: string; why: string }[]; - /** rows in the Benjamini–Hochberg family */ - tested: number; -} - -export interface RiskmodelResult { - study: string; - /** patients modelled, their events, and those left out with why */ - n: number; events: number; - notProfiled: number; noStratum: number; neverHeldOut: number; - noCovariate: number; noAnchor: number; anchorAfterStart: number; noFollowUp: number; - /** "Treatment (FIRST)" etc. when the clock starts at a timeline event */ - anchoredTo: string | null; - /** the always-in covariates as fitted columns (a category's indicator per non-reference level), with - * the share of runs they ended non-zero in (always 1 unless no variation) and their mean coefficient */ - covariates: { name: string; meanCoef: number; hr: number }[]; - /** with `validate`: the patients trained on (discovery) and reported on (validation) */ - validation: { discovery: number; validation: number; validationEvents: number; cIndex: number } | null; - /** what the groups, table and refit below are computed on */ - reportedOn: "held-out scores" | "validation half"; - /** genes every patient was sequenced for (mutations), and of those the candidates */ - genesCovered: number; candidates: number; minMutated: number; - strata: string | null; strataLevels: number; - alpha: number; runs: number; seed: number; measure: "any" | "driver"; - /** test-set C-index across runs */ - cIndex: { median: number; q1: number; q3: number; values: number[] }; - /** candidates kept in at least one run, by how often: share of runs, mean coefficient where kept, patients mutated */ - features: { gene: string; selected: number; meanCoef: number; hr: number; mutated: number }[]; - groups: { name: string; from: number | null; to: number | null; curve: Omit; - /** Kaplan-Meier survival at 12 and 36 months; null past the group's follow-up */ - at12: number | null; at36: number | null; - /** hazard ratio against the lowest-risk group (null for that group), 95% Wald interval, p */ - hr: number | null; ciLow: number | null; ciHigh: number | null; p: number | null }[]; - logrank: LogRankK | null; - /** a Cox model of the outcome on the risk score alone (per standard deviation of the score) */ - refit: { hr: number; ciLow: number; ciHigh: number; p: number } | null; - /** the scores' distribution, for the histogram: bin edges and counts (raw scores) */ - histogram: { edges: number[]; counts: number[] }; - /** OncoCast's 0–10 risk score: (raw − min) / (max − min) × 10, min and max the lowest and highest of - * the held-out scores the cuts are placed on (with `validate`, the discovery half's — so a - * validation patient can fall below 0 or above 10) */ - scale: { min: number; max: number }; - /** the volcano's label threshold, as the view asked (default 0.5) */ - labelAbove: number; - /** the quantiles the view chose to cut at; null when the groups are `groups` equal quantiles */ - cutsChosen: number[] | null; - /** the held-out score (raw) at each whole percentile 0…100 of the scores the cuts are placed on, - * the rank a cut at that quantile starts from — what the risk-groups dialog places its handles by */ - percentiles: number[]; - /** with `describe`: Table 1, the groups' makeup */ - table1?: RiskTable1; - /** with `validateOn`: the model scored on that cohort */ - external?: RiskExternal; -} - -export interface TxendpointsView { - note?: string; - kind: "txendpoints"; cohorts?: string[]; - /** the clock's start: a treatment record (event type, key = value), its first occurrence unless LAST */ - anchor: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; - /** ttnt time to next treatment (default), tot time on treatment, rwpfs real-world progression-free survival */ - endpoint?: "ttnt" | "tot" | "rwpfs"; - /** treatment lines (lib/treatment-lines.ts): days without therapy that start a new line (default 90), and which records form lines */ - lineGap?: number; lineClasses?: "systemic" | "all" | string[]; - /** rwpfs: the progression event (default event type Diagnosis, key PROGRESSION, values Y / Yes), and an optional probability floor */ - progression?: { eventType?: string; key?: string; values?: string[]; probabilityKey?: string; minProbability?: number }; - /** tot: a living patient whose stop lies within this many days of the last follow-up is censored as still on treatment (default 30) */ - ongoingDays?: number; - /** administrative censoring, months */ - cutoffMonths?: number; -} -export interface TxendpointsCurve extends Omit { - cohort: string; - /** the median's 95% interval (Greenwood, log scale), null where the band never reaches 0.5 */ - medianLo: number | null; medianHi: number | null; - /** the cohort's patients, those anchored, and those left out with why */ - cohortPatients: number; anchored: number; - excluded: { noAnchor: number; noFollowup: number; noStop: number }; - /** patients whose timeline runs past their last follow-up (the clock check) */ - late: number; -} -export interface TxendpointsResult { - endpoint: "ttnt" | "tot" | "rwpfs"; endpointLabel: string; anchoredTo: string; - lineGap: number; lineClasses: string; - progression?: { eventType: string; key: string; values: string[]; probabilityKey?: string; minProbability?: number }; - ongoingDays?: number; cutoffMonths?: number; - curves: TxendpointsCurve[]; - /** k-group log-rank over the cohorts, stratified by study where several answer */ - logrank: LogRankK | null; - /** why there is no test */ - noTest?: string; - excludedStudies?: { study: string; reason: string }[]; -} - -export interface MultistateView { - note?: string; - kind: "multistate"; cohorts?: string[]; - /** the progression event (default event type Diagnosis, key PROGRESSION, values Y / Yes), and an optional probability floor */ - progression?: { eventType?: string; key?: string; values?: string[]; probabilityKey?: string; minProbability?: number }; - /** patients with a progression on or before day 0: start in the progression state (include, default) or are left out (exclude) */ - prevalent?: "include" | "exclude"; - /** months at which the state occupation is tabled (default [12, 24, 60]) */ - horizons?: number[]; - /** administrative censoring, months */ - cutoffMonths?: number; -} -/** a transition of the illness–death model: 01 start → progression, 02 start → death, 12 progression → death */ -export type MultistateTransitionKey = "01" | "02" | "12"; -export interface MultistateRate { - key: MultistateTransitionKey; events: number; personMonths: number; - /** events per 100 person-months in the state left, with its 95% interval (log scale, Poisson) */ - rate: number; lo: number | null; hi: number | null; -} -export interface MultistateCohort { - cohort: string; - /** the cohort's patients; those in the model (n), and of them those starting in the progression state */ - cohortPatients: number; n: number; prevalent: number; - excluded: { noFollowup: number; prevalent: number }; - /** in the model: patients with no record of the progression key at all, a progression only on or after the end of follow-up, a Sequencing event on day 0, a timeline past follow-up */ - notAssessed: number; afterEnd: number; seq0: number; late: number; - /** censored in the start and in the progression state */ - censored: { start: number; progression: number }; - rates: MultistateRate[]; - /** Aalen–Johansen state occupation: p0 alive without progression, p1 alive after progression, p2 dead */ - occupation: { t: number; p0: number; p1: number; p2: number }[]; - /** Nelson–Aalen cumulative hazard per transition, with its 95% interval (log scale) */ - cumhaz: Record; - /** curves are drawn to here: the last time at least 10 of the cohort's patients are still followed */ - drawnTo: number; - /** occupation at each horizon, with the number still followed; null past drawnTo */ - atHorizon: { months: number; p0: number | null; p1: number | null; p2: number | null; atRisk: number }[]; -} -export interface MultistateCox { - key: MultistateTransitionKey; - /** each cohort's hazard ratio against the first (a Cox model per transition, stratified by study) */ - rows: { cohort: string; hr: number; lo: number; hi: number; p: number }[]; - n: number; events: number; - /** why it was not fitted */ - noFit?: string; -} -export interface MultistateResult { - progression: { eventType: string; key: string; values: string[]; probabilityKey?: string; minProbability?: number }; - prevalent: "include" | "exclude"; horizons: number[]; cutoffMonths?: number; - /** the figures' time axis end: the latest cohort's drawnTo */ - tMax: number; - /** "first sequencing" when day 0 is a Sequencing event for most patients, else "the timeline's day 0" */ - origin: string; - cohorts: MultistateCohort[]; - /** reference cohort for the hazard ratios */ - reference: string; - cox: MultistateCox[]; - excludedStudies?: { study: string; reason: string }[]; -} - -export interface RmstView { - note?: string; - kind: "rmst"; cohorts?: string[]; - /** the endpoint (default OS), its _MONTHS/_STATUS columns or monthsAttribute/statusAttribute */ - endpoint?: string; monthsAttribute?: string; statusAttribute?: string; - /** start the clock at a treatment record instead of day 0 (overall survival from it; txendpoints' anchor and clock check) */ - anchor?: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; - /** the restriction time, months; default the largest time at which every cohort still has 10 at risk */ - tau?: number; - /** landmarks, months: each keeps the patients still followed then and restarts the clock there (default [0]) */ - landmarkMonths?: number[]; -} -export interface RmstCurve extends Omit { - cohort: string; - /** restricted mean survival time to tau, its standard error and 95% interval, months */ - rmst: number; se: number; lo: number; hi: number; - /** the number at risk just before tau */ - atRiskTau: number; - /** patients dropped at the landmark: died or censored before it */ - beforeLandmark: number; -} -export interface RmstComparison { - cohort: string; vs: string; - /** RMST difference (cohort − vs) and ratio (cohort / vs), each with a 95% interval and a two-sided p */ - diff: number; diffLo: number; diffHi: number; diffP: number; - ratio: number; ratioLo: number; ratioHi: number; ratioP: number; -} -export interface RmstPanel { - landmark: number; tau: number; - /** whether tau was set in the view or is the default (every cohort ≥ 10 at risk) */ - tauSet: boolean; - curves: RmstCurve[]; - comparisons: RmstComparison[]; -} -export interface RmstResult { - endpointLabel: string; anchoredTo?: string; - panels: RmstPanel[]; - /** per cohort: its patients, and those without follow-up (or without the anchor) */ - cohorts: { cohort: string; cohortPatients: number; n: number; noFollowup: number; noAnchor: number }[]; - excludedStudies?: { study: string; reason: string }[]; -} - -export interface PredictiveView { - note?: string; - kind: "predictive"; cohorts?: string[]; - /** the exposure: patients with a timeline event of this type (and key = value) are exposed, the rest unexposed */ - exposure: { eventType: string; key?: string; value?: string }; - /** the candidate biomarkers (at most 20); each is fitted alone, over the patients profiled for it */ - genes: string[]; - endpoint?: string; monthsAttribute?: string; statusAttribute?: string; - /** "any" mutation (default) or OncoKB drivers only */ - measure?: "any" | "driver"; - /** clinical attributes adjusted for (at most 6): numbers as they are, categories as indicators against the commonest level */ - covariates?: string[]; - /** a gene is fitted only with at least this many mutated patients in each exposure group (default 5) */ - minMutated?: number; -} -export interface PredictiveEffect { hr: number; ciLow: number; ciHigh: number; p: number } -export interface PredictiveGene { - gene: string; - /** patients profiled for the gene (and with every covariate), by exposure, and those of them mutated */ - exposed: number; unexposed: number; mutatedExposed: number; mutatedUnexposed: number; events: number; - /** the gene's hazard ratio among the exposed and among the unexposed, and their ratio (the interaction); null when not fitted */ - inExposed: PredictiveEffect | null; inUnexposed: PredictiveEffect | null; interaction: PredictiveEffect | null; - /** Benjamini–Hochberg over the fitted genes' interaction p */ - q: number | null; - /** why the gene was not fitted */ - skipped?: string; -} -export interface PredictiveResult { - exposure: string; endpoint: string; measure: "any" | "driver"; - /** patients with a survival time and every covariate, and how many of them are exposed */ - n: number; exposed: number; events: number; - /** patients left out: no mutation panel on record, a covariate missing */ - notProfiled: number; noCovariate: number; - covariates: string[]; minMutated: number; - /** the fits' baseline hazard is stratified by these (cohort and study, when several) */ - strata: number; - genes: PredictiveGene[]; -} - -export interface CutpointView { - note?: string; - kind: "cutpoint"; cohorts?: string[]; - /** the numeric marker (default TMB_NONSYNONYMOUS): the patient's value, or a sample attribute's value on the patient's first sample */ - attribute?: string; - endpoint?: string; monthsAttribute?: string; statusAttribute?: string; - /** permutations of the marker for the corrected p (default 1000, 100 to 5000), and their seed (default 1) */ - permutations?: number; seed?: number; - /** a categorical attribute: a cut-point per stratum (its largest values, at most 8, each with at least minStratum patients) */ - stratifyBy?: string; minStratum?: number; -} -export interface CutpointScan { - /** patients with a survival time and a marker value, and their events */ - n: number; events: number; - /** the chosen cut: high is marker > cut */ - cut: number; - /** the share of patients at or below it */ - lowShare: number; - /** the maximally selected standardized log-rank statistic (z², chi-squared scale) at the cut, and its permutation-corrected p */ - maxStat: number; permP: number; - /** the ordinary log-rank test of the two groups at the cut, as if the cut had been fixed in advance */ - naiveChi2: number; naiveP: number; - /** the 95th percentile of the permutation distribution of the maximum: the statistic a cut must pass to be significant at 0.05 */ - critical95: number; - /** high against low, Cox */ - hr: { hr: number; ciLow: number; ciHigh: number; p: number } | null; - low: { n: number; events: number; median: number | null }; high: { n: number; events: number; median: number | null }; - /** candidate cuts scanned and the statistic at each */ - candidates: { cut: number; stat: number }[]; -} -export interface CutpointResult extends CutpointScan { - attribute: string; endpoint: string; permutations: number; seed: number; - /** cohort patients with a survival time but no marker value */ - noValue: number; - curves: { low: Omit; high: Omit }; - strata?: { - attribute: string; minStratum: number; - rows: ({ stratum: string } & Omit)[]; - /** strata not scanned: too small, past the eighth, or no value */ - omitted: { values: number; patients: number }; - }; -} - -/** - * HLA class I genotype and HLA loss of heterozygosity against outcome (Chowell et al. 2018; - * McGranahan et al. 2017): per cohort, the share of fully typed patients homozygous at 0, 1 or - * 2+ of HLA-A, -B and -C, the share with HLA LOH in a tumour sample, and overall survival by - * homozygous-at-any-locus against fully heterozygous and by HLA LOH against none. - */ -export interface HlaView { - note?: string; - kind: "hla"; cohorts?: string[]; - /** fields of the allele name compared: 1 (allele group, A*02), 2 (protein, A*02:01; default) or "full" */ - resolution?: 1 | 2 | "full"; - /** per locus, the attributes holding its two alleles ([first, second]) or one holding both ("A*02:01/A*24:02"); default: the layouts MSK-IMPACT and the melanoma/NSCLC immunotherapy studies use */ - alleles?: { A?: string[]; B?: string[]; C?: string[] }; - /** sample attributes calling HLA LOH (default HLA_A1_LOH … HLA_C2_LOH, HLA_LOH) and the values that mean loss (default Loss, Yes, LOH, True) */ - lohAttributes?: string[]; lohValues?: string[]; - endpoint?: string; monthsAttribute?: string; statusAttribute?: string; - /** clinical attributes the adjusted Cox fit adds (at most 4; numbers as they are, categories against the commonest) */ - covariates?: string[]; -} -export interface HlaHr { hr: number; ciLow: number; ciHigh: number; p: number; n: number; events: number } -export interface HlaSplit { - /** the two groups' OS curves (exposed first), the log-rank test (stratified by study when pooled) and Cox HRs of exposed against not */ - curves: (Omit & { label: string })[]; - logrank: LogRank | null; - hr: HlaHr | null; - /** with covariates: the HR adjusted for them, on the patients with every covariate */ - adjusted: HlaHr | null; -} -export interface HlaPanel { - cohort: string; - /** cohort patients, patients with any class I allele recorded, and those typed at all three loci */ - patients: number; anyTyped: number; typed: number; - /** fully typed patients homozygous at 0, 1, 2 and 3 loci */ - homozygous: [number, number, number, number]; - /** per locus (A, B, C): patients typed there and homozygous there */ - loci: { locus: "A" | "B" | "C"; typed: number; homozygous: number }[]; - /** typed at fewer allele-name fields than the resolution asks (compared at what they have) */ - coarser: number; - /** patients with an HLA LOH call in some sample, and those with a loss */ - lohEvaluated: number; loh: number; - /** OS by homozygous at ≥ 1 locus vs none, and by HLA LOH vs none; null with why when not drawn */ - byHomozygosity: HlaSplit | null; byHomozygosityWhy?: string; - byLoh: HlaSplit | null; byLohWhy?: string; - /** patients without the endpoint, and (with covariates) without a covariate, left out of the fits */ - noOutcome: number; noCovariate: number; -} -export interface HlaResult { - resolution: 1 | 2 | "full"; endpoint: string; covariates: string[]; - panels: HlaPanel[]; -} - -export interface LabsView { - note?: string; - kind: "labs"; cohorts?: string[]; - /** the test, as its event attribute names it (e.g. "PSA", "CEA") */ - test: string; - /** where lab results live (defaults: event type Lab_Test, the test in TEST, the value in RESULT) */ - eventType?: string; testKey?: string; resultKey?: string; - /** the clock's start: the treatment whose effect is read */ - anchor: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; - /** baseline is the last value this many days before, or on, the anchor (default 90) */ - baselineDays?: number; - /** the window, in weeks around the anchor (default −12 to 52), and its bin width (default 4) */ - fromWeeks?: number; toWeeks?: number; binWeeks?: number; - /** a responder's best fall from baseline, in percent (default 50, PCWG3's PSA50) */ - responseDrop?: number; -} -export interface LabsPanel { - cohort: string; - /** the denominators: anchored, with a baseline, with a positive baseline, with a value after the anchor */ - anchored: number; baseline: number; positiveBaseline: number; evaluable: number; - /** per bin: its start in weeks, the patients measured, and the quartiles of % change */ - course: { week: number; n: number; q1: number | null; median: number | null; q3: number | null }[]; - /** best % change after the anchor, in 2-point bins centred on −100, −98, … +300 (the last holds everything above) */ - best: number[]; - responders: number; - /** each patient's best % change, sorted — present only from patient-level participants */ - waterfall?: number[]; - /** patients whose best could not be drawn as a bar (an aggregate-only participant's) */ - waterfallWithheld?: number; - /** evaluable patients whose bars were cut by the waterfall's bar limit (drawn ones are the lowest) */ - waterfallCapped?: number; -} -export interface LabsResult { - test: string; anchoredTo: string; responseDrop: number; - fromWeeks: number; toWeeks: number; binWeeks: number; baselineDays: number; - panels: LabsPanel[]; - /** two cohorts: Fisher on responders */ - responseP?: number; - excluded?: { study: string; reason: string }[]; -} -/** - * Cancer Types Summary (cBioPortal's results-view tab): for a gene list, the share of the cohort's - * samples altered in at least one of the genes, per cancer type (or any sample attribute, or per - * study), split by kind of alteration. Each group's denominator is its samples profiled for at least - * one of the genes. Counts only. - */ -export interface CancertypesView { - note?: string; - kind: "cancertypes"; cohorts?: string[]; - genes: GeneTrackSpec[]; - /** a sample attribute to group by, or "study" (default "CANCER_TYPE") */ - groupBy?: string; - /** count OncoKB drivers only, or any alteration (default) */ - measure?: "driver" | "any"; - /** groups with fewer profiled samples than this are pooled into one "other" bar (default 10) */ - minSamples?: number; - /** draw percentages (default) or counts */ - show?: "frequency" | "count"; - /** count patients (default, as the portal does — a patient counts in each group a sample of theirs is in) or samples */ - unit?: "patient" | "sample"; -} -export interface CancertypesGroup { - key: string; label: string; - /** the group's units (patients or samples), profiled or not */ - samples: number; - profiled: number; altered: number; - mutation: number; amp: number; homdel: number; sv: number; multiple: number; - /** units profiled for each data type at all (any gene) — the portal's "+" rows */ - profiledFor?: { mutation: number; cna: number; sv: number }; -} -export interface CancertypesPanel { - cohort: string; samples: number; profiled: number; altered: number; - groups: CancertypesGroup[]; - /** groups under the size floor, pooled into one — counted, never dropped */ - pooled?: CancertypesGroup & { count: number }; - /** the same, one gene at a time (the portal's per-gene tabs) */ - perGene?: { gene: string; groups: CancertypesGroup[]; pooled?: CancertypesGroup & { count: number } }[]; -} -export interface CancertypesResult { - groupBy: string; measure: "driver" | "any"; genes: string[]; minSamples: number; show: "frequency" | "count"; - unit: "patient" | "sample"; - panels: CancertypesPanel[]; - excluded?: { study: string; reason: string }[]; -} -/** - * Overlap (cBioPortal's group-comparison Overlap tab): how the cohorts' patients intersect, as an - * UpSet plot — every combination of cohorts with how many patients are in exactly those. Counts only. - */ -export interface OverlapView { - note?: string; - kind: "overlap"; cohorts?: string[]; -} -export interface OverlapResult { - /** the cohorts, in bit order, with each one's own size in patients and in their samples */ - sets: { name: string; size: number; samples?: number }[]; - /** patients (and their samples) in exactly the named cohorts and no other, largest first */ - intersections: { members: string[]; n: number; samples?: number }[]; - /** patients in at least one cohort */ - union: number; - excluded?: { study: string; reason: string }[]; -} -/** - * Genomic-alteration enrichment (cBioPortal's group-comparison Genomic Alterations tab): every gene's - * frequency of one alteration type in each cohort, counted in samples over the samples profiled for - * that gene, with a test per gene and Benjamini–Hochberg across all genes tested. Counts only. - */ -export interface EnrichmentView { - note?: string; - kind: "enrichment"; cohorts?: string[]; - /** which alteration (default mutation); "cna" is amplification or deep deletion */ - alteration?: "mutation" | "cna" | "amp" | "homdel" | "sv"; - /** count OncoKB drivers only, or any alteration (default) */ - measure?: "driver" | "any"; - /** genes altered in fewer samples than this, across all cohorts, are not tested (default 5) */ - minAltered?: number; - /** genes named on the volcano (default 12) */ - label?: number; -} -export interface EnrichmentGene { - gene: string; - /** in the study's reference genome, when known */ - cytoband?: string; - cells: { cohort: string; altered: number; profiled: number; pct: number | null }[]; - /** two cohorts: log2 of the first cohort's altered share over the second's; null when either is zero */ - log2Ratio?: number | null; - p: number; q: number; - /** the cohort with the highest altered share */ - enrichedIn: string; -} -export interface EnrichmentResult { - alteration: string; measure: "driver" | "any"; groups: string[]; - tested: number; untested: number; significant: number; minAltered: number; - test: string; - /** the most significant genes (≤300), the rest summarised in `grid` */ - rows: EnrichmentGene[]; - /** two cohorts: every other tested gene binned on the volcano's axes, [x bin, y bin, count] */ - grid?: { xMax: number; yMax: number; nx: number; ny: number; cells: [number, number, number][] }; - labels: string[]; - excluded?: { study: string; reason: string }[]; -} -/** - * Generic-assay comparison (cBioPortal's group-comparison tabs for mutational signatures, arm-level - * copy number, treatment response and every other generic assay): every entity of one generic-assay - * profile compared between cohorts. Numeric profiles by Welch's t (two cohorts) or Welch's ANOVA; - * categorical ones by chi-squared on values × cohorts; Benjamini–Hochberg across the entities. - * Counts and moments only. - */ -export interface AssaysView { - note?: string; - kind: "assays"; cohorts?: string[]; - /** the generic-assay profile, as the genericAssay clause names it (e.g. mutational_signatures_contribution_v2) */ - profileType: string; - /** an entity with fewer measured samples than this in any cohort is not tested (default 3) */ - minN?: number; - /** entities named on the volcano (default 12) */ - label?: number; -} -export interface AssaysEntity { - entity: string; name: string; - /** numeric: n, mean and SD per cohort; categorical: n and the count of each value */ - cells: { cohort: string; n: number; mean?: number | null; sd?: number | null; values?: Record }[]; - /** numeric, two cohorts: first cohort's mean minus the second's */ - diff?: number; - statistic: number; p: number; q: number; - /** numeric: the cohort with the highest mean; categorical: the cohort and value most over-represented */ - higherIn: string; - value?: string; -} -export interface AssaysResult { - profileType: string; profileName: string; assayType: string; - datatype: "numeric" | "categorical"; - /** what one value describes */ - unit: "samples" | "patients"; - groups: string[]; - tested: number; untested: number; significant: number; minN: number; - test: string; - /** categorical: every value seen, in order of frequency; missing ones (NA, blank) are counted apart */ - values?: string[]; - /** values left out: non-numeric entries of a numeric profile (a limit like ">8", NA), or NA/blank in a categorical one */ - missing: number; - /** the most significant entities (≤300), the rest summarised in `grid` */ - rows: AssaysEntity[]; - grid?: { xMax: number; yMax: number; nx: number; ny: number; cells: [number, number, number][] }; - labels: string[]; - excluded?: { study: string; reason: string }[]; -} -/** - * Composition: what each case is made of — cell types, cell states, signature contributions — as - * parts of a whole, compared between cohorts (the stacked-bar panels of single-cell studies). The - * parts are every entity of one generic-assay profile, or a list of numeric clinical attributes - * (custom: uploads included). Each cohort's mean composition and every (cohort, part) enrichment - * against the view's other cohorts come from sums computed in the database; the per-case bars are - * drawn only where a participant may show cases. - */ -export interface CompositionView { - note?: string; - kind: "composition"; cohorts?: string[]; - /** a generic-assay profile whose entities are the parts (e.g. cell_type_relative_counts) */ - profileType?: string; - /** with profileType: only these of its entities, in this order */ - entities?: string[]; - /** numeric clinical attributes that are the parts, in this order (2–16); custom: names allowed */ - attributes?: string[]; - /** attributes only: one bar per patient (default) or per sample */ - unit?: "patient" | "sample"; - /** divide each case's parts by their sum, so every bar is a whole (default true) */ - normalize?: boolean; - /** the part cases are ordered by, highest first (default the first part) */ - sort?: string; -} -export interface CompositionPart { - /** the entity or attribute it reads */ - id: string; - /** how the figure names it: the entity's name, or the attribute with the parts' shared prefix taken off */ - name: string; -} -export interface CompositionCell { - cohort: string; part: string; - n: number; mean: number; sd: number | null; - /** this cohort against the view's other cohorts pooled: log2 of the ratio of means (each + 0.001) */ - log2?: number; - /** Welch's t on the same split, and Benjamini–Hochberg across every (cohort, part) tested */ - p?: number; q?: number; -} -export interface CompositionResult { - source: "genericAssay" | "clinical"; - /** the profile, or "clinical attributes" */ - sourceName: string; - unit: "samples" | "patients"; - normalize: boolean; - parts: CompositionPart[]; - sort: string; - /** per cohort: cases with every part, cases left out (some part missing, or a part below zero, or all zero) */ - groups: { cohort: string; n: number; incomplete: number; invalid: number; - /** Shannon entropy (natural log) of the normalised parts, mean and SD over the cohort's cases */ - entropy: { mean: number; sd: number | null } | null; - /** the cases' own compositions, sorted by `sort`, highest first; absent when they may not be shown */ - cases?: number[][]; - /** why `cases` is absent */ - casesWhy?: string; - }[]; - cells: CompositionCell[]; - tested: number; significant: number; - excluded?: { study: string; reason: string }[]; -} -/** - * Genome-wide co-expression (cBioPortal's results-view Co-expression tab): one gene's expression - * against every gene's in a profile, over one cohort's patients — Spearman's ρ (ranked by it, as - * the portal ranks) and Pearson's r, each with a p, and Benjamini–Hochberg over ρ. Sums only. - */ -export interface CorrelatesView { - note?: string; - kind: "correlates"; cohorts?: string[]; - /** the query gene */ - gene: string; - /** the query gene's profile */ - profileType: string; - /** the profile every other gene is read from (default: the same one) — e.g. protein against mRNA */ - otherProfile?: string; - /** genes listed in each direction (default 100, at most 250) */ - show?: number; - /** a gene with fewer patients having both values is not tested (default 10) */ - minN?: number; -} -export interface CorrelatesGene { - gene: string; n: number; - /** in the study's reference genome, when known */ - cytoband?: string; - /** Spearman's ρ with its p (the t approximation scipy uses, or the Fisher-z meta p across studies) and BH q */ - rho: number | null; rhoP: number; q: number; - /** Pearson's r and its p, from the pooled sums */ - r: number | null; p: number; -} -export interface CorrelatesResult { - gene: string; profileType: string; otherProfile: string; cohort: string; - tested: number; untested: number; significant: number; minN: number; - /** "rank" when ρ is one study's; "meta" when ρ combines several studies' by Fisher z */ - rhoFrom: "rank" | "meta"; - /** the most positively and most negatively correlated genes, by ρ */ - positive: CorrelatesGene[]; negative: CorrelatesGene[]; - /** ρ of every gene tested, in bins of 0.05 from −1 to 1: [bin low edge, count] */ - histogram: [number, number][]; - excluded?: { study: string; reason: string }[]; -} /** * What every table view's result shares (lib/table-query.ts): every column (the download's), the * ones shown, the query as applied, one page of the rows the query keeps — searched, filtered and @@ -2229,60 +481,6 @@ export interface TableRows { /** the values one column takes, when the request asked for them (RunOpts.values) — what its filter offers */ values?: ColumnValues; } -/** - * The clinical data table (cBioPortal's Clinical Data tab): one row per sample — or per patient — - * of each cohort, with every clinical attribute the study records; searchable, filterable, - * sortable, paged, and downloadable whole as CSV. Its rows are patients, so only a patient-level - * participant contributes them; the rest are named with how many patients they hold. - */ -export interface TableView extends TableQuery { - note?: string; - kind: "table"; cohorts?: string[]; - /** a row per sample (default, with its patient's attributes too) or per patient */ - unit?: "sample" | "patient"; - /** older spelling of `columns`: the attribute columns shown */ - attributes?: string[]; - /** rows per page (default 25, at most 1000); every page and the download are slices of one order */ - rows?: number; -} -export interface TableResult extends TableRows { - unit: "sample" | "patient"; -} -/** - * The mutation table (cBioPortal's Mutations tab, under the lollipop): one gene's mutations in each - * cohort, a row per call with the portal's columns; searchable, filterable, sortable, paged, and - * downloadable whole. Its rows name samples, so only a patient-level participant contributes them. - */ -export interface MutationsView extends TableQuery { - note?: string; - kind: "mutations"; cohorts?: string[]; - gene: string; - /** rows per page (default 25, at most 1000); every page and the download are slices of one order */ - rows?: number; -} -export interface MutationsResult extends TableRows { - gene: string; - /** the OncoKB version the annotation column reads, or null when none is pinned */ - oncokbPin: string | null; -} -/** - * One patient's clinical timeline (cBioPortal's patient-view Timeline), with the list of the - * cohorts' patients to choose them from. The list is a table view (lib/table-query.ts: paged, - * searched, filtered, sorted, downloadable); the timeline is the chosen patient's events as tracks - * (lib/timeline-tracks.ts). Both name a patient, so only a patient-level participant contributes. - */ -export interface TimelineView extends TableQuery { - note?: string; - kind: "timeline"; cohorts?: string[]; - /** the patient whose timeline is drawn; with none, the list's first row */ - patient?: { study: string; id: string }; - /** track uids drawn collapsed (their events folded into one row), e.g. "treatment.Chemo" */ - collapsed?: string[]; - /** draw every year rather than cutting runs of four or more empty years to one mark (default false) */ - expandGaps?: boolean; - /** patients per page of the list (default 25, at most 1000) */ - rows?: number; -} /** One clinical event, as the study records it: days from diagnosis, and its attributes in order. */ export interface PatientEvent { type: string; start: number; end: number | null; attrs: [string, string][] } /** One of the patient's samples and its recorded sample type. */ @@ -2304,47 +502,17 @@ export interface TimelineResult extends TableRows { */ position?: { index: number; prev: { study: string; id: string } | null; next: { study: string; id: string } | null } | null; } -/** One of the alterations view's tables as the spec holds it: its query, and its page size. */ -export interface AlterationTableQuery extends TableQuery { - /** rows per page (default 25, at most 1000) */ - rows?: number; -} -/** - * One patient's genomic alterations as cBioPortal's patient view lists them — three tables in one - * view: Mutations, Copy Number Alterations and Structural Variants — with the list of the cohort's - * patients to choose them from. The list and each table is a table view (lib/table-query.ts: paged, - * searched, filtered, sorted, downloadable), with its own query. All of them name a patient, so only - * a patient-level participant contributes. - */ -export interface AlterationsView extends TableQuery { - note?: string; - kind: "alterations"; cohorts?: string[]; - /** the patient whose alterations are listed; with none, the list's first row */ - patient?: { study: string; id: string }; - /** patients per page of the list (default 25, at most 1000) */ - rows?: number; - /** each table's own query (the top-level columns/search/filters/sort are the patient list's) */ - mutations?: AlterationTableQuery; - cna?: AlterationTableQuery; - sv?: AlterationTableQuery; -} -export interface AlterationsResult extends TableRows { - /** the patient listed, with their samples (none on a page request), or null when the list is empty */ - patient: { study: string; id: string; samples: PatientSample[]; source?: { label: string; remote: boolean } } | null; - /** where that patient falls in the list's order, and the patients either side (as the timeline's) */ - position?: TimelineResult["position"]; - /** the patient's three tables; on a page or values request, only the one asked for */ - tables: Partial>>; - /** the OncoKB version the annotation columns read, or null when none is pinned */ - oncokbPin: string | null; -} import type { PlotAxis, PlotData } from "./plot.ts"; -import type { PointLayerSpec } from "./plot-layers.ts"; import type { ProfileInfo } from "./profile-catalog.ts"; export type { PlotAxis, PlotData }; -export type View = CompareView | SurvivalView | OncoprintView | CooccurrenceView | ClinicalView | CoexpressionView | HotspotsView | CourseView | SequenceView | PlotView | CoxView | LogisticView | ForestView | AdjustedView | ActionabilityView | DrivernegativeView | OrganotropismView | MolsubtypeView | GermlineView - | AttritionView | GenomeView | PathwaysView | DiffexprView | PairedView | ClonalityView | IncidenceView | LabsView | AcquiredView | CtdnaView | ExclusivityView | TxendpointsView | MultistateView | RmstView | PredictiveView | CutpointView | HlaView | InstabilityView - | CancertypesView | OverlapView | EnrichmentView | AssaysView | CompositionView | CorrelatesView | TableView | MutationsView | RiskmodelView | TimelineView | AlterationsView; +/** + * Every view kind's spec, by kind. lib/types.ts names no kind: each adds its own entry from its folder + * (lib/views//types.ts, `declare module "../../types.ts"`), as it does to ViewResultFields and + * ViewResultMain below. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- filled by each kind's declaration merging +export interface ViewSpecs {} +export type View = ViewSpecs[keyof ViewSpecs]; /** every view kind the document language has; a comparison against anything else is a type error */ export type ViewKind = View["kind"]; @@ -2412,25 +580,6 @@ export interface LockDrift { /** why the movement could not be measured (the frozen patients still answered) */ error?: string; } -export interface CompareCell { - cohort: string; profiled: number; any: number; driver: number; - anyPct: number | null; driverPct: number | null; - /** - * profiled/altered split by alteration type (mutation/cna/structuralVariant) — lets a reader - * (or the UI) tell "0% altered, fully assayed" apart from "0% altered because this whole type - * was never assayed for this cohort's study". `any`/`anyPct` above combine all three, matching - * the oncoprint's "any alteration" convention, and can look like a real negative result even - * when a whole type contributed zero because it was never checked. - */ - byType: { mutation: { profiled: number; altered: number }; cna: { profiled: number; altered: number }; - structuralVariant: { profiled: number; altered: number } }; -} -export interface CompareRow { - gene: string; cells: CompareCell[]; pAny?: number; pDriver?: number; - /** Benjamini–Hochberg across the genes tested in this view, computed separately for - * each of pAny/pDriver — only present once 2+ cohorts produce a p-value to correct. */ - qAny?: number; qDriver?: number; -} export interface KmPoint { t: number; s: number } export interface KmCurve { cohort: string; points: KmPoint[]; median: number | null; @@ -2447,107 +596,6 @@ export interface Cmh { /** strata that carried information */ tables: number; } -/** One gene pair, as a 2x2 among patients profiled for both, plus its inference. */ -export interface CooccurRow { - a: string; b: string; - both: number; onlyA: number; onlyB: number; neither: number; profiled: number; - log2Odds: number | null; - /** two-sided Fisher on the POOLED table */ - p: number; - /** BH-adjusted across all pairs in this view, using whichever p is primary */ - q: number; - /** present when more than one study contributed: the site-stratified answer */ - pStratified?: number; oddsRatioMH?: number | null; strata?: number; - tendency: "co-occurrence" | "mutual exclusivity" | null; -} -/** Recurrent protein changes in one gene, with the cohort's mutated total. */ -/** One drawn stem: a codon, its height (distinct patient + change pairs), and how it is coloured. */ -export interface LollipopPosition { - pos: number; n: number; color: string; impact: string; driver: boolean; - /** every change at this codon, by raw rows then name — the label and tooltip read these; each with its - * protein impact class and driver status, so a selection can leave it out (no class: a site on older code) */ - changes: { change: string; n: number; rows: number; impact?: string; driver?: boolean }[]; - /** the raw rows here by mutation type and driver status — the colour, recomputed when cells are hidden */ - types?: { mtype: string; driver: boolean; rows: number }[]; -} -export interface LollipopDomain { accession: string; name: string; start: number; end: number; color: string } -/** - * One gene's lollipop, drawn the way cBioPortal's results-page mutation tab draws it (see - * lib/lollipop.ts): one stem per codon, height = distinct (patient, protein change) pairs. - */ -export interface HotspotGene { - gene: string; - /** patients with any mutation in the gene, and distinct (patient, change) pairs */ - mutated: number; mutations: number; - positions: LollipopPosition[]; - domains: LollipopDomain[]; - proteinLength: number; - /** genome-nexus: the canonical transcript; observed: the service was unreachable, so the highest codon seen */ - lengthSource: "genome-nexus" | "observed"; transcript?: string; - /** pairs not drawn because they have no codon (or one past the protein) */ - unpositioned: number; - /** pairs a site withheld under its suppression floor */ - withheld: number; - label?: { pos: number; text: string }; - /** the commonest changes, for text summaries (chat, MCP) */ - topChanges: { change: string; pos: number | null; n: number; pct: number }[]; - /** samples with a somatic mutation in the gene, and samples in the cohort */ - somaticSamples: number; cohortSamples: number; - /** - * Mutation rows by protein impact type, putative driver vs VUS — the portal's badge tally - * beside the lollipop. Every row counts, drawn or not; `other` covers silent and unknown types. - */ - tally: Record<"missense" | "truncating" | "inframe" | "splice" | "fusion" | "other", { driver: number; vus: number }>; - /** the transcript's other identifiers, as the portal's gene summary lists them */ - ids?: { refseq?: string; ensembl?: string; ccds?: string; uniprotAccession?: string; uniprotName?: string }; -} -/** One gene pair's correlation on a continuous profile, from six sums per site. */ -export interface CoexprRow { - a: string; b: string; - /** correlation of the pooled population, from summed moments (exact) */ - r: number | null; n: number; p: number; q: number; - /** present with >1 study: per-site correlations combined by Fisher z */ - rMeta?: number | null; pMeta?: number; sites?: number; - /** per-site r and n, so a reader can see whether the sites agree */ - perStudy?: { study: string; r: number | null; n: number }[]; -} -/** One clinical attribute compared across cohorts. */ -export interface ClinicalCell { - cohort: string; n: number; - /** categorical: the most common values */ - top?: { v: string; n: number }[]; - /** numeric: computed from additive moments, never from transmitted values */ - mean?: number | null; sd?: number; min?: number | null; max?: number | null; - /** - * numeric, single-study views only: exact median/quartiles from the cohort's complete value - * histogram. Absent (not approximated) when the view spans more than one study, or when the - * attribute had more distinct values than the histogram cap allows. - */ - median?: number | null; q1?: number | null; q3?: number | null; - /** true when the attribute had more distinct values than the histogram cap allows, so median/ - * IQR (and the rank test, if this cell is one of exactly 2) were refused rather than computed - * from a partial distribution — stated, not silently absent */ - histogramTruncated?: boolean; -} -export interface ClinicalRow { - attribute: string; per: "patient" | "sample"; kind: "cat" | "num"; - cells: ClinicalCell[]; - test?: string; statistic?: number; df?: number; p?: number; q?: number; - /** Welch's ANOVA (3+ numeric cohorts) only: the denominator degrees of freedom — `df` above - * holds df1 (groups − 1), an F-test's other, non-integer, Welch-Satterthwaite-style df */ - df2?: number; - /** - * numeric, single-study: a rank-based test alongside the moment-based one (never in place of - * it) — Mann-Whitney U for exactly 2 cohorts, Kruskal-Wallis for 3+ — a check worth having when - * the attribute is skewed or has outliers the mean can mislead on. Deliberately not itself part - * of the q-value correction above, which stays Welch's t/ANOVA only. - */ - rankStatistic?: number; rankP?: number; - /** categorical only: vocabulary size, and the smallest expected cell */ - distinct?: number; minExpected?: number; - /** why this attribute was not tested — stated, never silently omitted */ - skipped?: string; -} /** * How each study behind a view answered. The distinction the whole system turns * on: an aggregate-only participant returns counts and never an identifier, so a @@ -2656,205 +704,14 @@ export interface ViewResultCommon { * kind's fields: code that reads another kind's does not compile. They are optional because a * refused, failed or waiting view carries none of them. */ -export interface ViewResultFields { - compare: { - rows?: CompareRow[]; - }; - survival: { - curves?: KmCurve[]; - /** which endpoint was used */ - endpoint?: string; - /** the event the clock was reset to */ - anchoredTo?: string; - /** delayed entry was applied */ - truncated?: boolean; - /** how long after the anchor patients became observable */ - entryMonths?: { median: number; p90: number; max: number }; - /** administrative censoring applied, echoed for the renderers */ - cutoffMonths?: number; - /** requested x-axis tick spacing, echoed for the renderers */ - tickMonths?: number; - /** which curve was drawn */ - display?: "survival" | "incidence"; - logrank?: LogRank; logrankPooled?: LogRank; - /** 3+ cohorts: the k-group generalization (tests whether the groups differ AT ALL) */ - logrankK?: LogRankK; logrankKPooled?: LogRankK; - }; - cox: { - cox?: CoxResult; - /** which endpoint was fitted */ - endpoint?: string; - }; - logistic: { - logistic?: LogisticResult; - }; - forest: { - forest?: ForestResult; - }; - adjusted: { - adjusted?: AdjustedResult; - }; - actionability: { - actionability?: ActionabilityResult; - }; - drivernegative: { - drivernegative?: DrivernegativeResult; - }; - organotropism: { - organotropism?: OrganotropismResult; - }; - molsubtype: { - molsubtype?: MolsubtypeResult; - }; - germline: { - germline?: GermlineResult; - }; - course: { - course?: CourseSeries[]; - /** bin width and window actually used, in months */ - courseAxis?: { binMonths: number; from: number; to: number }; - /** - * every event type this cohort's timeline actually holds, so the UI - * can offer the choice without a round trip. `perPatient` and `hasDurations` - * are what separate a clinical event from an administrative marker — a type - * with exactly one event per patient and no duration (Sequencing, Sample - * acquisition) is a filing record, not a course. - */ - eventOptions?: { canonical: string; label: string; patients: number; perPatient: number; hasDurations: boolean }[]; - }; - sequence: { - sequence?: SequenceResult; - }; - hotspots: { - hotspots?: HotspotGene[]; - /** the cells the view hides, as applied to its figure (`hotspots` itself stays whole, so the app can re-filter it) */ - hide?: string[]; - }; - plot: { - /** a plot's aggregated contract: bins, groups or cells — never points */ - plot?: PlotData; - }; - coexpression: { - coexpression?: CoexprRow[]; - /** a plot's aggregated contract: bins, groups or cells — never points */ - plot?: PlotData; - }; - clinical: { - clinical?: ClinicalRow[]; - }; - cooccurrence: { - pairs?: CooccurRow[]; - }; - oncoprint: { - /** - * only for `runAnalysis(doc, {oncoprint:"spec"})`: what to POST to - * /api/oncoprint to get the figure. Carries cohort DEFINITIONS, never compiled SQL. - */ - spec?: OncoprintSpec; - svg?: string; nColumns?: number; - genes?: { gene: string; driverPct: number | null; anyPct: number | null }[]; - }; - attrition: { - attrition?: AttritionPanel[]; - }; - genome: { - genome?: GenomeResult; - }; - pathways: { - pathways?: PathwaysResult; - }; - diffexpr: { - diffexpr?: DiffexprResult; - }; - paired: { - paired?: PairedResult; - }; - acquired: { - acquired?: AcquiredResult; - }; - ctdna: { - ctdna?: CtdnaResult; - }; - exclusivity: { - exclusivity?: ExclusivityResult; - }; - instability: { - instability?: InstabilityResult; - }; - clonality: { - clonality?: ClonalityResult; - }; - incidence: { - incidence?: IncidenceResult; - }; - labs: { - labs?: LabsResult; - }; - txendpoints: { - txendpoints?: TxendpointsResult; - }; - multistate: { - multistate?: MultistateResult; - }; - rmst: { - rmst?: RmstResult; - }; - predictive: { - predictive?: PredictiveResult; - }; - hla: { - hla?: HlaResult; - }; - cutpoint: { - cutpoint?: CutpointResult; - }; - riskmodel: { - riskmodel?: RiskmodelResult; - }; - cancertypes: { - cancertypes?: CancertypesResult; - }; - overlap: { - overlap?: OverlapResult; - }; - enrichment: { - enrichment?: EnrichmentResult; - }; - assays: { - assays?: AssaysResult; - }; - composition: { - composition?: CompositionResult; - }; - correlates: { - correlates?: CorrelatesResult; - }; - table: { - table?: TableResult; - }; - timeline: { - timeline?: TimelineResult; - }; - mutations: { - mutations?: MutationsResult; - }; - alterations: { - alterations?: AlterationsResult; - }; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- filled by each kind's declaration merging +export interface ViewResultFields {} /** * Each kind's main field: what every DRAWN result of that kind carries (one that neither failed * nor waits for cohorts). The oncoprint has none: it carries its figure or its spec, by request. */ -export interface ViewResultMain { - compare: "rows"; survival: "curves"; cox: "cox"; logistic: "logistic"; forest: "forest"; adjusted: "adjusted"; actionability: "actionability"; drivernegative: "drivernegative"; organotropism: "organotropism"; molsubtype: "molsubtype"; germline: "germline"; course: "course"; sequence: "sequence"; - hotspots: "hotspots"; plot: "plot"; coexpression: "coexpression"; clinical: "clinical"; cooccurrence: "pairs"; - oncoprint: never; attrition: "attrition"; genome: "genome"; pathways: "pathways"; diffexpr: "diffexpr"; - paired: "paired"; clonality: "clonality"; incidence: "incidence"; labs: "labs"; riskmodel: "riskmodel"; - cancertypes: "cancertypes"; overlap: "overlap"; enrichment: "enrichment"; assays: "assays"; - composition: "composition"; correlates: "correlates"; table: "table"; timeline: "timeline"; - mutations: "mutations"; alterations: "alterations"; acquired: "acquired"; ctdna: "ctdna"; exclusivity: "exclusivity"; txendpoints: "txendpoints"; multistate: "multistate"; rmst: "rmst"; predictive: "predictive"; cutpoint: "cutpoint"; hla: "hla"; instability: "instability"; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- filled by each kind's declaration merging +export interface ViewResultMain {} /** a result that was drawn: no error, not waiting, and its main field present */ type Drawn = { error?: undefined; empty?: undefined } & ViewResultFields[K] & { [F in ViewResultMain[K]]-?: NonNullable }; @@ -2881,56 +738,6 @@ export function isDrawn(v: R): v is Extract|" -> pooled reference, supplied by the analysis layer */ - zReference?: Record; -} - // ---------- explore --------------------------------------------------------- interface FacetBase { /** diff --git a/lib/view-svg.ts b/lib/view-svg.ts index ae14f03..895ddfb 100644 --- a/lib/view-svg.ts +++ b/lib/view-svg.ts @@ -22,16 +22,17 @@ import { filterHotspotGene } from "./lollipop.ts"; import { lollipopPanelsSvg, lollipopSummaryText } from "./lollipop-svg.ts"; export { lollipopSummaryText }; -import { isDrawn, type ViewResult, type HotspotGene, type DrawnResultOf } from "./types.ts"; +import { isDrawn, type ViewResult, type DrawnResultOf } from "./types.ts"; import { kindFigure, kindTitle } from "./figures/index.ts"; -import { survivalSvg } from "./figures/survival.ts"; +import { survivalSvg } from "./views/survival/figure.ts"; import { coxSvg, logisticSvg } from "./figures/forest.ts"; import { cooccurrenceSvg, compareSvg, coexpressionSvg, clinicalSvg } from "./figures/bars.ts"; -import { courseSvg, RISK_FLOOR } from "./figures/course.ts"; -import { sequenceSvg } from "./figures/sequence.ts"; -import { plotSvg } from "./figures/plot.ts"; +import { courseSvg, RISK_FLOOR } from "./views/course/figure.ts"; +import { sequenceSvg } from "./views/sequence/figure.ts"; +import { plotSvg } from "./views/plot/figure.ts"; export { RISK_FLOOR }; import { esc, SERIES, type SvgOpts } from "./svg-kit.ts"; +import type { HotspotGene } from "./views/hotspots/types.ts"; export { esc, SERIES, type SvgOpts }; /** The lollipop standing alone (an MCP client's image): the selection applied, the app's controls in words in the caption. */ diff --git a/lib/figures/acquired.ts b/lib/views/acquired/figure.ts similarity index 97% rename from lib/figures/acquired.ts rename to lib/views/acquired/figure.ts index 8c8fad1..da42f1e 100644 --- a/lib/figures/acquired.ts +++ b/lib/views/acquired/figure.ts @@ -3,8 +3,9 @@ import { esc, palette, frame, caption, linear, axisBottom, bottomAxisHeight, legend, label, textWidth, wrapWidth, fmtInt, pDigits, textOn, FS, SW, SERIES, type SvgOpts -} from "../svg-kit.ts"; -import type { DrawnResultOf, AcquiredGene } from "../types.ts"; +} from "../../svg-kit.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { AcquiredGene } from "./types.ts"; const PAD = 8, GUT = 14, ROW = 20, BAR = 14, MIN_BAR_W = 220; diff --git a/lib/views/acquired.ts b/lib/views/acquired/run.ts similarity index 95% rename from lib/views/acquired.ts rename to lib/views/acquired/run.ts index fd72523..77bc737 100644 --- a/lib/views/acquired.ts +++ b/lib/views/acquired/run.ts @@ -1,10 +1,12 @@ // Acquired view: alterations present after a treatment start and absent before it, within patients -import type { ViewResult, AcquiredView, AcquiredPanel, AcquiredGene } from "../types.ts"; -import { acquiredCountsSql, acquiredFunnelSql, type AcquiredSides } from "../federation-views.ts"; -import { benjaminiHochberg, mcnemarExact } from "../stats.ts"; -import { geneSymbols } from "../gene-tracks.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, plural, refuse } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { benjaminiHochberg, mcnemarExact } from "../../stats.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, plural, refuse } from "../shared.ts"; +import type { AcquiredView, AcquiredPanel, AcquiredGene } from "./types.ts"; +import { acquiredCountsSql, acquiredFunnelSql } from "./sql.ts"; +import type { AcquiredSides } from "./sql.ts"; const TOP_DEFAULT = 20, TOP_MAX = 40, GENES_MAX = 30, MIN_POST_DEFAULT = 3, MIN_GAP_DEFAULT = 30; const SAMPLE_EVENTS = ["Sample acquisition", "Sequencing", "Specimen"]; diff --git a/lib/views/acquired/sql.ts b/lib/views/acquired/sql.ts new file mode 100644 index 0000000..76a88bd --- /dev/null +++ b/lib/views/acquired/sql.ts @@ -0,0 +1,89 @@ +// The acquired view's SQL: what its runner (run.ts) asks each participant's database for. +import { sideCountsTail } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; +import { foldedEqualsSql, foldedInSql } from "../../event-names.ts"; + +/** Per gene, among patients with a sample before and after the anchor, both profiled for it: as pairedCountsSql (a = before, b = after). */ +export function acquiredCountsSql(cohortSql: string, study: string, db: string, genes: string[] | null, o: AcquiredSides, + pin: string | null, measure: "driver" | "any"): string { + return acquiredSidesCtes(cohortSql, study, db, o) + ",\n" + sideCountsTail(study, db, genes, pin, measure); +} + +/** The funnel from cohort to pairs: anchored, with a dated sample, with one before, one after, both; and the pairs' spacing in days. */ +export function acquiredFunnelSql(cohortSql: string, study: string, db: string, o: AcquiredSides): string { + const both = "n_pre > 0 AND n_post > 0"; + return ( + acquiredSidesCtes(cohortSql, study, db, o) + "\n" + + "SELECT (SELECT count() FROM anch) AS anchored, (SELECT uniqExact(pid) FROM tl) AS with_events, count() AS dated,\n" + + " countIf(n_pre > 0) AS with_pre, countIf(n_post > 0) AS with_post, countIf(" + both + ") AS paired,\n" + + " quantileExactIf(0.5)(-pre_rel, " + both + ") AS pre_median, quantileExactIf(0.5)(post_rel, " + both + ") AS post_median,\n" + + " quantileExactIf(0.25)(post_rel - pre_rel, " + both + ") AS gap_q1, quantileExactIf(0.5)(post_rel - pre_rel, " + both + ") AS gap_median,\n" + + " quantileExactIf(0.75)(post_rel - pre_rel, " + both + ") AS gap_q3\n" + + "FROM pick" + ); +} + +// --------------------------------------------------------------------------- +// acquired: each patient's last sample before a treatment start and first sample after it + +export interface AcquiredSides { + /** the treatment start: its FIRST event of this type (with key = value, when given) */ + anchor: { eventType: string; key?: string; value?: string }; + /** the timeline event types that date a sample, and the attribute naming the sample */ + sampleEvents: string[]; sampleKey: string; + /** pre: the last dated sample on or before the anchor day, at most this many days before it (null: any) */ + preWindowDays: number | null; + /** post: the first dated sample at least this many days after the anchor */ + minGapDays: number; +} + +/** + * The cohort's patients with a dated sample on each side of a treatment start, as a `ps` CTE (one + * sample per side: 'a' the last on or before the anchor, 'b' the first at least minGapDays after; + * ties on a day broken by sample id, so the pick is deterministic). A sample's day is the first + * timeline event of the given types naming it, matched to the study's samples by stable id. + */ +function acquiredSidesCtes(cohortSql: string, study: string, db: string, o: AcquiredSides): string { + const a = o.anchor; + const preCond = "rel <= 0" + (o.preWindowDays != null ? " AND rel >= " + (-Math.floor(o.preWindowDays)) : ""); + const postCond = "rel >= " + Math.floor(o.minGapDays); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "anch AS (\n" + + " SELECT p.stable_id AS pid, min(ce.start_date) AS day\n" + + " FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", a.eventType) + "\n" + + (a.key ? " AND ce.clinical_event_id IN (SELECT clinical_event_id FROM " + db + ".clinical_event_data WHERE " + + foldedEqualsSql("key", a.key) + " AND " + foldedEqualsSql("value", String(a.value ?? "")) + ")\n" : "") + + " AND p.stable_id IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "tl AS (\n" + + " SELECT p.stable_id AS pid, d.value AS sample, min(ce.start_date) AS day\n" + + " FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("d.key", o.sampleKey) + "\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedInSql("ce.event_type", o.sampleEvents) + "\n" + + " AND p.stable_id IN (SELECT pid FROM anch)\n" + + " GROUP BY pid, sample\n" + + "),\n" + + "dated AS (\n" + + " SELECT tl.pid AS pid, sd.sample_unique_id AS suid, tl.day - anch.day AS rel\n" + + " FROM tl INNER JOIN anch ON anch.pid = tl.pid\n" + + " INNER JOIN " + db + ".sample_derived sd ON sd.cancer_study_identifier = " + sq(study) + " AND sd.sample_stable_id = tl.sample AND sd.patient_stable_id = tl.pid\n" + + "),\n" + + "pick AS (\n" + + " SELECT pid, countIf(" + preCond + ") AS n_pre, countIf(" + postCond + ") AS n_post,\n" + + " argMaxIf(suid, (rel, suid), " + preCond + ") AS pre, argMinIf(suid, (rel, suid), " + postCond + ") AS post,\n" + + " maxIf(rel, " + preCond + ") AS pre_rel, minIf(rel, " + postCond + ") AS post_rel\n" + + " FROM dated GROUP BY pid\n" + + "),\n" + + "ps AS (\n" + + " SELECT pre AS suid, pid, 'a' AS side FROM pick WHERE n_pre > 0 AND n_post > 0\n" + + " UNION ALL SELECT post AS suid, pid, 'b' AS side FROM pick WHERE n_pre > 0 AND n_post > 0\n" + + ")" + ); +} diff --git a/lib/views/acquired/types.ts b/lib/views/acquired/types.ts new file mode 100644 index 0000000..49d9648 --- /dev/null +++ b/lib/views/acquired/types.ts @@ -0,0 +1,64 @@ +// The acquired view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Alterations acquired under therapy: patients with a dated sample on or before a treatment start + * and another after it, per gene, altered after but not before (acquired), before but not after + * (lost), on both (shared) or neither — among patients whose two samples' panels both cover the gene. + */ +export interface AcquiredView { + note?: string; + kind: "acquired"; cohorts?: string[]; + /** the treatment start: the FIRST event of this type, with key = value when given (default Treatment, any) */ + anchor?: { eventType: string; key?: string; value?: string }; + /** the genes to test (at most 30); omitted or empty scans every gene altered in at least `minPost` after-samples */ + genes?: string[]; + /** a scan: the fewest patients altered after treatment for a gene to be tested (default 3) */ + minPost?: number; + /** a scan: how many genes to draw, most significant first (default 20, at most 40) */ + top?: number; + /** the before-sample is the last on or before the start, at most this many days before it (default: any) */ + preWindowDays?: number; + /** the after-sample is the first at least this many days after the start (default 30) */ + minGapDays?: number; + /** the timeline event types that date a sample (default Sample acquisition, Sequencing, Specimen) and the attribute naming it (default SAMPLE_ID) */ + sampleEvents?: string[]; sampleKey?: string; + /** any alteration (default) or OncoKB oncogenic only */ + measure?: "driver" | "any"; +} +export interface AcquiredGene { + gene: string; + /** patients whose before- and after-sample both cover the gene */ + pairs: number; acquired: number; lost: number; shared: number; neither: number; + /** exact McNemar p on acquired vs lost, and BH q across the genes tested in this cohort */ + p: number; q: number; +} +export interface AcquiredPanel { + cohort: string; + /** the funnel: anchored, with a dated sample, with one before, with one after, with both */ + anchored: number; dated: number; withPre: number; withPost: number; patients: number; + /** among the pairs: median days from the before-sample to the start, from the start to the after-sample, and the pairs' spacing (quartiles) */ + preDays: number | null; postDays: number | null; gapDays: { q1: number; median: number; q3: number } | null; + genes: AcquiredGene[]; + /** a scan: the genes tested (altered after in at least minPost patients), which q is taken across */ + tested?: number; +} +export interface AcquiredResult { + anchoredTo: string; + preWindowDays: number | null; minGapDays: number; + sampleEvents: string[]; sampleKey: string; + measure: "driver" | "any"; + scan?: { top: number; minPost: number }; + panels: AcquiredPanel[]; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { acquired: AcquiredView } + interface ViewResultFields { + acquired: { + acquired?: AcquiredResult; + }; + } + interface ViewResultMain { acquired: "acquired" } +} diff --git a/lib/figures/actionability.ts b/lib/views/actionability/figure.ts similarity index 97% rename from lib/figures/actionability.ts rename to lib/views/actionability/figure.ts index d40d628..5cc6d6d 100644 --- a/lib/figures/actionability.ts +++ b/lib/views/actionability/figure.ts @@ -1,7 +1,8 @@ // Actionability figure: per cohort (and group), the share of sequenced patients at each highest OncoKB // level as one stacked bar, then each cohort's primary/metastasis concordance as a cross-table. -import type { DrawnResultOf, ActionLevels } from "../types.ts"; -import { esc, palette, frame, caption, label, textWidth, legend, linear, axisBottom, bottomAxisHeight, seriesStyle, textOn, fmtInt, fmtPct, FS, SW, OTHER, type SvgOpts } from "../svg-kit.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { esc, palette, frame, caption, label, textWidth, legend, linear, axisBottom, bottomAxisHeight, seriesStyle, textOn, fmtInt, fmtPct, FS, SW, OTHER, type SvgOpts } from "../../svg-kit.ts"; +import type { ActionLevels } from "./types.ts"; const ROW = 22, PAD = 8, GUT = 12; const levelColor = (i: number) => i < 5 ? seriesStyle(i).color : OTHER; diff --git a/lib/views/actionability.ts b/lib/views/actionability/run.ts similarity index 97% rename from lib/views/actionability.ts rename to lib/views/actionability/run.ts index 1795500..f930c1a 100644 --- a/lib/views/actionability.ts +++ b/lib/views/actionability/run.ts @@ -1,9 +1,10 @@ // Actionability view: each sequenced patient's highest OncoKB therapeutic level, per cohort (and per // group of an attribute), and its concordance between a patient's primary and metastatic samples. -import type { ViewResult, ActionabilityView, ActionabilityPanel, ActionLevels } from "../types.ts"; -import { actionLevelsSql, actionConcordanceSql, actionStoreTypesSql } from "../federation-views.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, plural, ATTR_NAME } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, plural, ATTR_NAME } from "../shared.ts"; +import type { ActionabilityView, ActionabilityPanel, ActionLevels } from "./types.ts"; +import { actionLevelsSql, actionConcordanceSql, actionStoreTypesSql } from "./sql.ts"; export const ACTION_LEVELS = ["Level 1", "Level 2", "Level 3A", "Level 3B", "Level 4", "No level"]; export const ACTION_RESISTANCE = ["R1", "R2"]; diff --git a/lib/views/actionability/sql.ts b/lib/views/actionability/sql.ts new file mode 100644 index 0000000..11baec5 --- /dev/null +++ b/lib/views/actionability/sql.ts @@ -0,0 +1,107 @@ +// The actionability view's SQL: what its runner (run.ts) asks each participant's database for. +import { pidOf } from "../../federation-views.ts"; +import { clinicalTable } from "../../custom-data.ts"; +import { sq } from "../../compiler.ts"; + +/** + * Per group (a clinical attribute's value per patient — "Multiple" when their samples disagree, + * "" when none is recorded — or one group without `groupBy`) and per pair of a patient's best + * sensitive and resistance ranks over every sequenced sample: the number of patients. Counts only. + */ +export function actionLevelsSql(cohortSql: string, study: string, db: string, pin: string, groupBy: string | null): string { + if (groupBy && !/^[A-Za-z0-9_:/.-]{1,128}$/.test(groupBy)) throw new Error("bad attribute name '" + String(groupBy).slice(0, 40) + "'"); + const pid = pidOf("patient_unique_id", study); + return ( + actionCtes(cohortSql, study, db, pin) + ",\n" + + "pl AS (\n" + + " SELECT seq.pid AS pid, min(if(lv.s = 0, 9, lv.s)) AS s, min(if(lv.r = 0, 9, lv.r)) AS r\n" + + " FROM seq LEFT JOIN lv ON lv.sid = seq.sid\n" + + " GROUP BY pid\n" + + ")" + + (groupBy ? + ",\ngrp AS (\n" + + " SELECT " + pid + " AS pid, if(uniqExact(attribute_value) > 1, 'Multiple', any(attribute_value)) AS g\n" + + " FROM " + clinicalTable(db, [groupBy]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(groupBy) + " AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")" : "") + "\n" + + "SELECT " + (groupBy ? "grp.g" : "''") + " AS grp, pl.s AS level, pl.r AS resistance, count() AS n\n" + + "FROM pl" + (groupBy ? " LEFT JOIN grp ON grp.pid = pl.pid" : "") + "\n" + + "GROUP BY grp, level, resistance" + ); +} + +/** + * Patients with a sequenced sample on each side of a sample attribute (e.g. SAMPLE_TYPE Primary + * against Metastasis, values matched case-insensitively): per pair of their best sensitive rank + * on side a and on side b, the number of patients. Counts only. + */ +export function actionConcordanceSql(cohortSql: string, study: string, db: string, pin: string, by: { attribute: string; a: string[]; b: string[] }): string { + if (!/^[A-Za-z0-9_:/.-]{1,128}$/.test(by.attribute)) throw new Error("bad attribute name '" + String(by.attribute).slice(0, 40) + "'"); + const side = (vals: string[]) => "lower(trimBoth(attribute_value)) IN (" + vals.map((v) => sq(String(v).trim().toLowerCase())).join(", ") + ")"; + return ( + actionCtes(cohortSql, study, db, pin) + ",\n" + + "st AS (\n" + + " SELECT sample_unique_id AS sid, multiIf(" + side(by.a) + ", 'a', " + side(by.b) + ", 'b', '') AS side\n" + + " FROM " + clinicalTable(db, [by.attribute]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(by.attribute) + "\n" + + " AND sample_unique_id IN (SELECT sid FROM seq)\n" + + "),\n" + + "ps AS (\n" + + " SELECT seq.pid AS pid, minIf(if(lv.s = 0, 9, lv.s), st.side = 'a') AS sa, minIf(if(lv.s = 0, 9, lv.s), st.side = 'b') AS sb,\n" + + " countIf(st.side = 'a') AS na, countIf(st.side = 'b') AS nb\n" + + " FROM seq INNER JOIN st ON st.sid = seq.sid LEFT JOIN lv ON lv.sid = seq.sid\n" + + " GROUP BY pid\n" + + " HAVING na > 0 AND nb > 0\n" + + ")\n" + + "SELECT sa AS a, sb AS b, count() AS n FROM ps GROUP BY a, b" + ); +} + +/** The alteration types the store holds a therapeutic level for at `pin` (reference data, no cohort). */ +export function actionStoreTypesSql(_study: string, _db: string, pin: string): string { + return "SELECT toString(alteration_type) AS type, count() AS n FROM oncokb_annotation.variant_annotation\n" + + "WHERE oncokb_version = " + sq(pin) + " AND (highest_sensitive_level != '' OR highest_resistance_level != '')\nGROUP BY type"; +} + +// --------------------------------------------------------------------------- +// actionability: each patient's highest OncoKB therapeutic level + +/** OncoKB's sensitive levels, best first, and the resistance levels; rank 9 is none */ +export const SENSITIVE_LEVELS = ["LEVEL_1", "LEVEL_2", "LEVEL_3A", "LEVEL_3B", "LEVEL_4"] as const; +export const RESISTANCE_LEVELS = ["LEVEL_R1", "LEVEL_R2"] as const; +export const rankOf = (col: string, levels: readonly string[]) => "multiIf(" + levels.map((l, i) => col + " = " + sq(l) + ", " + (i + 1)).join(", ") + ", 9)"; + +/** the cohort's samples sequenced for mutations, and per sample its best sensitive and resistance + * rank over its called mutations and high-level copy-number calls (OncoKB at `pin`) */ +function actionCtes(cohortSql: string, study: string, db: string, pin: string): string { + const sdPid = pidOf("sd.patient_unique_id", study); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "seq AS (\n" + + " SELECT DISTINCT sp.sample_unique_id AS sid, " + sdPid + " AS pid\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = 'MUTATION_EXTENDED'\n" + + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "lv AS (\n" + + " SELECT g.sample_unique_id AS sid, min(" + rankOf("ann.highest_sensitive_level", SENSITIVE_LEVELS) + ") AS s,\n" + + " min(" + rankOf("ann.highest_resistance_level", RESISTANCE_LEVELS) + ") AS r\n" + + " FROM " + db + ".genomic_event_derived g\n" + + // the store's rows with a level at the pin only — a few thousand of its millions — matched as driverJoin matches + " INNER JOIN (SELECT hugo_gene_symbol, alteration_type, alteration, highest_sensitive_level, highest_resistance_level\n" + + " FROM oncokb_annotation.variant_annotation\n" + + " WHERE oncokb_version = " + sq(pin) + " AND (highest_sensitive_level != '' OR highest_resistance_level != '')) ann\n" + + " ON ann.hugo_gene_symbol = g.hugo_gene_symbol\n" + + " AND ((g.variant_type = 'mutation' AND ann.alteration_type = 'MUTATION' AND ann.alteration = g.mutation_variant)\n" + + " OR (g.variant_type = 'cna' AND ann.alteration_type = 'CNA' AND ann.alteration = if(g.cna_alteration >= 2, 'AMPLIFICATION', 'DELETION'))\n" + + " OR (g.variant_type = 'structural_variant' AND ann.alteration_type = 'SV' AND ann.alteration = 'Fusion'))\n" + + " WHERE g.cancer_study_identifier = " + sq(study) + "\n" + + " AND ((g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED') OR (g.variant_type = 'cna' AND abs(g.cna_alteration) >= 2) OR g.variant_type = 'structural_variant')\n" + + " AND g.sample_unique_id IN (SELECT sid FROM seq)\n" + + " GROUP BY sid\n" + + ")" + ); +} diff --git a/lib/views/actionability/types.ts b/lib/views/actionability/types.ts new file mode 100644 index 0000000..1ebf334 --- /dev/null +++ b/lib/views/actionability/types.ts @@ -0,0 +1,49 @@ +// The actionability view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Clinical actionability from the version-pinned OncoKB store: each sequenced patient's highest + * sensitive therapeutic level (1, 2, 3A, 3B, 4) and any resistance level (R1, R2) over their + * alterations, as shares per cohort (optionally per group of an attribute), and for patients with + * a sample on each side of `pairBy` the concordance of their highest level between the two. Counts only. + */ +export interface ActionabilityView { + note?: string; + kind: "actionability"; cohorts?: string[]; + /** split each cohort by a categorical attribute (e.g. CANCER_TYPE); default none */ + groupBy?: string; + /** with groupBy, the largest groups drawn (default 8); the rest are pooled into a disclosed "Other" */ + top?: number; + /** the two sides compared for concordance (default SAMPLE_TYPE Primary against Metastasis); null turns it off */ + pairBy?: { attribute: string; a: string[]; b: string[] } | null; +} +/** patients per highest level: [1, 2, 3A, 3B, 4, none], and with a resistance level [R1, R2] */ +export interface ActionLevels { sequenced: number; levels: number[]; resistance: number[] } +export interface ActionabilityPanel extends ActionLevels { + cohort: string; + /** the cohort's patients, sequenced or not */ + patients: number; + groups?: (ActionLevels & { label: string })[]; + /** groups past `top`, pooled into the last group */ + pooled?: string[]; + /** patients with a sequenced sample on each side: table[a][b] over the six highest levels, and how many differ */ + concordance?: { pairs: number; table: number[][]; discordant: number }; +} +export interface ActionabilityResult { + pin: string; levels: string[]; groupBy: string | null; top: number; + pairBy: { attribute: string; a: string[]; b: string[] } | null; + panels: ActionabilityPanel[]; + /** the alteration types the store has levels for at this pin */ + storeTypes: string[]; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { actionability: ActionabilityView } + interface ViewResultFields { + actionability: { + actionability?: ActionabilityResult; + }; + } + interface ViewResultMain { actionability: "actionability" } +} diff --git a/lib/figures/adjusted.ts b/lib/views/adjusted/figure.ts similarity index 96% rename from lib/figures/adjusted.ts rename to lib/views/adjusted/figure.ts index 8474385..f774746 100644 --- a/lib/figures/adjusted.ts +++ b/lib/views/adjusted/figure.ts @@ -1,8 +1,8 @@ // Adjusted enrichment figure: the volcano of each gene's adjusted log2 odds ratio against −log₁₀ q, // then the top genes' table, raw beside adjusted. -import type { DrawnResultOf } from "../types.ts"; -import { volcano, volcanoPlotH, type VolcanoPoint } from "./volcano.ts"; -import { palette, frame, caption, label, textWidth, wrapWidth, seriesStyle, fmtInt, fmtPct, fmtRatio, pDigits, FS, SW, type SvgOpts } from "../svg-kit.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { volcano, volcanoPlotH, type VolcanoPoint } from "../../figures/volcano.ts"; +import { palette, frame, caption, label, textWidth, wrapWidth, seriesStyle, fmtInt, fmtPct, fmtRatio, pDigits, FS, SW, type SvgOpts } from "../../svg-kit.ts"; const TABLE_ROWS = 15; /** the smallest 1, 2 or 5 × 10^k at or above x: an axis end a reader can read off */ diff --git a/lib/views/adjusted.ts b/lib/views/adjusted/run.ts similarity index 95% rename from lib/views/adjusted.ts rename to lib/views/adjusted/run.ts index 8f8ee10..8dab8d4 100644 --- a/lib/views/adjusted.ts +++ b/lib/views/adjusted/run.ts @@ -1,14 +1,16 @@ // Adjusted view: each gene's alteration compared between two cohorts by a logistic regression of // altered ~ cohort + covariates on per-patient rows inside each local study, beside the raw odds // ratio and Fisher test. Covariates are coded by ./covariates.ts, as the cox and logistic views code them. -import { logisticRegression, fisherExact, benjaminiHochberg, LOGISTIC_MAX_ITERATIONS, type LogisticRow } from "../stats.ts"; -import type { ViewResult, AdjustedView, AdjustedGene, AdjustedResult, CoxCovariate } from "../types.ts"; -import { isPatientLevel, hasViewAggregates } from "../federation.ts"; -import { adjustedRowsSql, riskmodelPanelGenesSql } from "../federation-views.ts"; -import type { AxisKind } from "../plot.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, refuse, plural, ATTR_NAME } from "./shared.ts"; -import { buildCoxColumns } from "./covariates.ts"; +import { logisticRegression, fisherExact, benjaminiHochberg, LOGISTIC_MAX_ITERATIONS, type LogisticRow } from "../../stats.ts"; +import type { ViewResult, CoxCovariate } from "../../types.ts"; +import { isPatientLevel, hasViewAggregates } from "../../federation.ts"; +import { riskmodelPanelGenesSql } from "../../federation-views.ts"; +import type { AxisKind } from "../../plot.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, refuse, plural, ATTR_NAME } from "../shared.ts"; +import { buildCoxColumns } from "../covariates.ts"; +import type { AdjustedView, AdjustedGene, AdjustedResult } from "./types.ts"; +import { adjustedRowsSql } from "./sql.ts"; export const ADJUSTED_MAX_GENES = 200, ADJUSTED_MAX_COVARIATES = 6; /** a gene's cohort term is reported only when its log-OR's standard error is below this (larger: separation) */ diff --git a/lib/views/adjusted/sql.ts b/lib/views/adjusted/sql.ts new file mode 100644 index 0000000..063323a --- /dev/null +++ b/lib/views/adjusted/sql.ts @@ -0,0 +1,51 @@ +// The adjusted view's SQL: what its runner (run.ts) asks each participant's database for. +// adjusted: each gene's alteration between two cohorts, by logistic regression on covariates +import { pidOf } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; +import { driverJoin } from "../../federation.ts"; +import { clinicalTable } from "../../custom-data.ts"; + +/** + * One row per patient of the cohort with a mutation panel: the panels their samples were sequenced + * on, the genes mutated in them (called mutations only; OncoKB drivers only with `driverPin`), and + * each covariate's value as c0, c1, … (the patient's, or the first sample's). Values only: the + * patient id joins the CTEs and is never returned. Run only through PatientLevelResolver.viewRows. + */ +export function adjustedRowsSql(cohortSql: string, study: string, db: string, o: { covariates: string[]; driverPin?: string | null }): string { + const { covariates, driverPin = null } = o; + for (const a of covariates) if (!/^[A-Za-z0-9_:/.-]{1,128}$/.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); + const pid = pidOf("patient_unique_id", study), sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "panels AS (\n" + + " SELECT " + sdPid + " AS pid, groupUniqArray(toString(sp.gene_panel_id)) AS panels\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = 'MUTATION_EXTENDED'\n" + + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "mut AS (\n" + + " SELECT " + gPid + " AS pid, groupUniqArray(g.hugo_gene_symbol) AS genes\n" + + " FROM " + db + ".genomic_event_derived g\n" + + (driverPin ? driverJoin(driverPin) : "") + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED'\n" + + (driverPin ? " AND ann.is_driver = 1\n" : "") + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")" + + (covariates.length ? + ",\nattrs AS (\n" + + " SELECT " + pid + " AS pid,\n" + + covariates.map((a, k) => " argMinIf(attribute_value, sample_unique_id, attribute_name = " + sq(a) + ") AS a" + k).join(",\n") + "\n" + + " FROM " + clinicalTable(db, covariates) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + covariates.map(sq).join(",") + ") AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")" : "") + "\n" + + "SELECT panels.panels AS panels, mut.genes AS genes" + covariates.map((_, k) => ", attrs.a" + k + " AS c" + k).join("") + "\n" + + "FROM panels\n" + + " LEFT JOIN mut ON mut.pid = panels.pid\n" + + (covariates.length ? " LEFT JOIN attrs ON attrs.pid = panels.pid\n" : "") + ); +} diff --git a/lib/views/adjusted/types.ts b/lib/views/adjusted/types.ts new file mode 100644 index 0000000..34bbd99 --- /dev/null +++ b/lib/views/adjusted/types.ts @@ -0,0 +1,56 @@ +// The adjusted view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Covariate-adjusted alteration enrichment between two cohorts: for every gene mutated in at least + * a minimum share of the patients profiled for it, a logistic regression of altered on cohort and + * covariates, reported as the adjusted odds ratio for the first cohort beside the raw one. Fit from + * per-patient rows inside each local study (patient-level participants only). + */ +export interface AdjustedView { + note?: string; + kind: "adjusted"; cohorts?: string[]; + /** clinical attributes adjusted for (default none): a number as recorded, a category against its commonest value */ + covariates?: string[]; + /** count every called mutation (default), or only OncoKB drivers */ + measure?: "any" | "driver"; + /** genes tested: mutated in at least this share of the patients profiled for them, both cohorts together (default 0.05) */ + minShare?: number; + /** and in at least this many patients (default 10) */ + minAltered?: number; + /** genes named on the volcano (default 12) */ + label?: number; +} +export interface AdjustedGene { + gene: string; + /** per cohort, in the view's order: patients altered, and patients profiled for the gene with every covariate */ + altered: [number, number]; profiled: [number, number]; + /** unadjusted odds ratio (null with an empty cell) and its two-sided Fisher p */ + rawOr: number | null; fisherP: number; + /** the cohort term's adjusted odds ratio, Wald 95% CI and p; q is Benjamini–Hochberg over the genes fit */ + or: number; ciLow: number; ciHigh: number; p: number; q: number; + /** a covariate category that would separate was merged into its reference for this gene */ + merged?: boolean; +} +export interface AdjustedResult { + groups: [string, string]; covariates: string[]; measure: "any" | "driver"; minShare: number; minAltered: number; + /** per cohort: patients with a mutation panel, and of those with every covariate (the ones fit) */ + patients: { cohort: string; withPanel: number; used: number }[]; + /** genes fit, by adjusted p */ + rows: AdjustedGene[]; + /** genes that passed the share threshold but could not be fit, and why */ + failed: { gene: string; reason: string }[]; + significant: number; + labels: string[]; + excludedRemote?: { study: string; patients: number }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { adjusted: AdjustedView } + interface ViewResultFields { + adjusted: { + adjusted?: AdjustedResult; + }; + } + interface ViewResultMain { adjusted: "adjusted" } +} diff --git a/lib/views/alterations/method.ts b/lib/views/alterations/method.ts new file mode 100644 index 0000000..b6fe363 --- /dev/null +++ b/lib/views/alterations/method.ts @@ -0,0 +1,24 @@ +// The alterations view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type TableLists, type MethodRecord, tableSteps } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export function alterationsMethod(t: { cohorts: string[]; oncokbPin: string | null; queryText: string; sortName: string | null; desc: boolean; shown: number; + lists: TableLists; excluded: { study: string; reason: string; patients: number }[]; instances: number; + patient: { study: string; id: string } | null; tables: { name: string; total: number; all: number; queryText: string }[] }): MethodRecord { + const steps = tableSteps({ label: "alterations", what: "patients", queryText: t.queryText, shown: t.shown, lists: t.lists, instances: t.instances, excluded: t.excluded, + source: "every patient of each cohort, with their sample count and how many rows each of the three tables lists for them (a mutation, a gene's deep copy-number call, a structural variant — each counted once however many samples carry it).", + order: t.sortName ? "by " + t.sortName + (t.desc ? " descending" : "") + " with missing values last, then patient id" : "patients with any alteration first, then by patient id" }); + if (t.patient) steps.push({ + kind: "fetch" as const, title: "Read " + t.patient.id + "'s three tables", + detail: "The alterations:mutations, alterations:cna and alterations:sv queries above, each paged, searched, filtered and sorted in the database like any table view. " + + "Mutations: every called mutation of the patient's samples (uncalled calls left out), one row per variant with the samples carrying it, the highest allele frequency among them " + + "(variant / (variant + reference) reads) with that sample's reads, and the gene's deep copy-number calls in the patient (Copy #). " + + "Copy-number alterations: the deep calls (AMP, HOMDEL), one row per gene and call. Structural variants: one row per event (both genes and breakpoints, class, event info), the highest read counts among its samples. " + + "Cohort % is the share of the study's samples profiled for the gene (by their gene panel) with any mutation in it, or with the same copy-number call" + + (t.oncokbPin ? "; the annotation is the OncoKB " + t.oncokbPin + " call for the protein change, or for AMPLIFICATION / DELETION" : "") + ".", + params: t.tables.map((x) => [x.name, (x.queryText ? x.total.toLocaleString("en-US") + " of " : "") + x.all.toLocaleString("en-US") + " rows" + (x.queryText ? " (kept: " + x.queryText + ")" : "")] as [string, string]), + }); + return { steps, implementation: { functions: ["runAlterations", "alterationPatientRowSource", "patientMutationsSource", "patientCnaSource", "patientSvSource", "pageSql", "countsSql", "rankSql"], + file: "lib/views/alterations/run.ts, lib/patient-alterations.ts, lib/table-query.ts", commit: BUILD_COMMIT }, + verification: "A listing: no statistic is computed, so there is no script — the queries are the numbers." }; +} diff --git a/lib/views/alterations.ts b/lib/views/alterations/run.ts similarity index 95% rename from lib/views/alterations.ts rename to lib/views/alterations/run.ts index c0f07ba..8980138 100644 --- a/lib/views/alterations.ts +++ b/lib/views/alterations/run.ts @@ -1,20 +1,21 @@ // Alterations view: the cohort's patients, and one patient's Mutations, Copy Number Alterations and // Structural Variants tables (cBioPortal's patient view) — four table views in one, each on // lib/table-query.ts with its own query, the row sources in lib/patient-alterations.ts. -import type { ViewResult, AlterationsView, AlterationsResult, TableRows } from "../types.ts"; -import { peerRowsFor, type PatientLevelCapable, type PeerTable, type Resolver } from "../federation.ts"; -import type { TablePart } from "../mutation-table.ts"; -import { checkQuery, describeQuery, shownColumns, type TableQuery } from "../table-query.ts"; -import { alterationsMethod } from "../method.ts"; -import { PATIENT_ID } from "../patient-timeline.ts"; -import { sampleMarks } from "../timeline-tracks.ts"; +import type { ViewResult, TableRows } from "../../types.ts"; +import { peerRowsFor, type PatientLevelCapable, type PeerTable, type Resolver } from "../../federation.ts"; +import type { TablePart } from "../../mutation-table.ts"; +import { checkQuery, describeQuery, shownColumns, type TableQuery } from "../../table-query.ts"; +import { PATIENT_ID } from "../../patient-timeline.ts"; +import { sampleMarks } from "../../timeline-tracks.ts"; import { ALTERATION_TABLES, ALTERATION_TABLE_NAMES, ALTERATION_COLUMNS, ALTERATION_DEFAULT_COLUMNS, ALTERATION_PATIENT_COLUMNS, ALTERATION_PATIENT_DEFAULT_COLUMNS, alterationSource, alterationPatientRowSource, patientSamplesSource, type AlterationPart, type AlterationTable, -} from "../patient-alterations.ts"; -import type { ViewRun } from "./run.ts"; -import { TABLE_ROWS_MAX, listPosition, rowHolders, runRows, tableTail, viewQuery, type RemoteRows, type Rows } from "./rows.ts"; -import { plural, refuse } from "./shared.ts"; +} from "../../patient-alterations.ts"; +import type { ViewRun } from "../run.ts"; +import { TABLE_ROWS_MAX, listPosition, rowHolders, runRows, tableTail, viewQuery, type RemoteRows, type Rows } from "../rows.ts"; +import { plural, refuse } from "../shared.ts"; +import type { AlterationsView, AlterationsResult } from "./types.ts"; +import { alterationsMethod } from "./method.ts"; type Cell = string | number | null; diff --git a/lib/views/alterations/types.ts b/lib/views/alterations/types.ts new file mode 100644 index 0000000..384de12 --- /dev/null +++ b/lib/views/alterations/types.ts @@ -0,0 +1,48 @@ +// The alterations view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { TableRows, PatientSample, TimelineResult } from "../../types.ts"; +import type { TableQuery } from "../../table-query.ts"; + +/** One of the alterations view's tables as the spec holds it: its query, and its page size. */ +export interface AlterationTableQuery extends TableQuery { + /** rows per page (default 25, at most 1000) */ + rows?: number; +} +/** + * One patient's genomic alterations as cBioPortal's patient view lists them — three tables in one + * view: Mutations, Copy Number Alterations and Structural Variants — with the list of the cohort's + * patients to choose them from. The list and each table is a table view (lib/table-query.ts: paged, + * searched, filtered, sorted, downloadable), with its own query. All of them name a patient, so only + * a patient-level participant contributes. + */ +export interface AlterationsView extends TableQuery { + note?: string; + kind: "alterations"; cohorts?: string[]; + /** the patient whose alterations are listed; with none, the list's first row */ + patient?: { study: string; id: string }; + /** patients per page of the list (default 25, at most 1000) */ + rows?: number; + /** each table's own query (the top-level columns/search/filters/sort are the patient list's) */ + mutations?: AlterationTableQuery; + cna?: AlterationTableQuery; + sv?: AlterationTableQuery; +} +export interface AlterationsResult extends TableRows { + /** the patient listed, with their samples (none on a page request), or null when the list is empty */ + patient: { study: string; id: string; samples: PatientSample[]; source?: { label: string; remote: boolean } } | null; + /** where that patient falls in the list's order, and the patients either side (as the timeline's) */ + position?: TimelineResult["position"]; + /** the patient's three tables; on a page or values request, only the one asked for */ + tables: Partial>>; + /** the OncoKB version the annotation columns read, or null when none is pinned */ + oncokbPin: string | null; +} + +declare module "../../types.ts" { + interface ViewSpecs { alterations: AlterationsView } + interface ViewResultFields { + alterations: { + alterations?: AlterationsResult; + }; + } + interface ViewResultMain { alterations: "alterations" } +} diff --git a/lib/figures/assays.ts b/lib/views/assays/figure.ts similarity index 98% rename from lib/figures/assays.ts rename to lib/views/assays/figure.ts index cb19da9..054a824 100644 --- a/lib/figures/assays.ts +++ b/lib/views/assays/figure.ts @@ -1,13 +1,13 @@ // Assays figure: one generic-assay profile's entities compared between cohorts — the shared volcano // (two cohorts, numeric), a row per entity with each cohort's mean or mix of values, and the picked // entity per cohort (assayEntitySvg, the page's plot beside its table). -import type { ViewResultOf, DrawnResultOf } from "../types.ts"; +import type { ViewResultOf, DrawnResultOf } from "../../types.ts"; import { palette, frame, caption, label, textWidth, wrapWidth, linear, axisBottom, bottomAxisHeight, legend, seriesStyle, fmtInt, fmtNum, pDigits, esc, FS, SW, type SvgOpts, type LegendItem -} from "../svg-kit.ts"; -import { AMP, DEL } from "./common.ts"; -import { volcano, volcanoPlotH } from "./volcano.ts"; +} from "../../svg-kit.ts"; +import { AMP, DEL } from "../../figures/common.ts"; +import { volcano, volcanoPlotH } from "../../figures/volcano.ts"; type Pal = ReturnType; type AsResult = NonNullable["assays"]>; diff --git a/lib/views/assays/method.ts b/lib/views/assays/method.ts new file mode 100644 index 0000000..150d50e --- /dev/null +++ b/lib/views/assays/method.ts @@ -0,0 +1,98 @@ +// The assays view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface AssaysTrace { + profileType: string; categorical: boolean; unit: "samples" | "patients"; + cohorts: string[]; minN: number; test: string; + perSite: { cohort: string; study: string; rows: number }[]; + tested: number; untested: number; missing: number; + /** numeric: per cohort and entity, summed over its studies */ + moments: { cohort: string; entity: string; n: number; sx: number; sxx: number }[]; + /** categorical: per cohort, entity and called value, summed over its studies */ + counts: { cohort: string; entity: string; value: string; n: number }[]; + /** what the view reported, for the script check */ + results: { entity: string; p: number; q: number }[]; +} + +export function assaysMethod(t: AssaysTrace): MethodRecord { + const k = t.cohorts.length, two = k === 2, cat = t.categorical; + const one = t.unit.replace(/s$/, ""); + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: cat ? "Count each entity's values" : "Sum each entity's values", + detail: "The assays:" + (cat ? "values" : "moments") + " query above, on “" + t.profileType + "”: per entity, over the cohort's " + t.unit + + (cat ? ", how many take each value." : ", n, Σv and Σv² of the values that read as numbers, and how many did not.") + " No individual value leaves the database.", + scope: s.cohort + " · " + s.study, rowsOut: s.rows, + })); + if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's " + (cat ? "counts" : "sums") + " across its studies", + detail: "The totals add exactly; there is no batch correction between studies." }); + steps.push({ kind: "transform", title: "Leave out what cannot be tested", + detail: (cat ? "Values NA or blank (not called) are not a category: " : "Values that are not numbers: ") + t.missing.toLocaleString("en-US") + " left out. " + + "An entity with fewer than " + t.minN + " " + t.unit + " with a value in any cohort" + (cat ? ", or one value throughout," : ", or no spread in any cohort (variance ≤ 1e-12 · (1 + mean²)),") + " is not tested.", + params: [["tested", t.tested.toLocaleString("en-US")], ["not tested", t.untested.toLocaleString("en-US")]] }); + steps.push({ kind: "statistic", title: t.test + " per entity, then Benjamini–Hochberg", + detail: (cat ? "On each entity's cohort × value table, no continuity correction; an all-zero column is dropped." + : two ? "Unequal-variance t from each cohort's n, mean and variance (Σv² − (Σv)²/n)/(n − 1), Welch–Satterthwaite df, two-sided." + : "The unequal-variance F test from each cohort's n, mean and variance; a cohort with fewer than 2 values or no variance is left out of that entity's test.") + + " q across every entity tested, one value per " + one + "." }); + const file = cat ? "assays_counts.csv" : "assays_moments.csv"; + const python = [ + "# Recompute with scipy " + VERIFIED.welch.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against.", + "import io", + "import numpy as np", + "import pandas as pd", + cat ? "from scipy.stats import chi2_contingency" : two ? "from scipy.stats import ttest_ind_from_stats" : "from statsmodels.stats.oneway import anova_generic", + "from statsmodels.stats.multitest import multipletests", + "", + RESULT_INIT, + "cohorts = " + JSON.stringify(t.cohorts), + "MIN_N = " + t.minN, + ...(cat + ? pyTable("data", "Per cohort, entity and called value: how many " + t.unit + ", from the assays:values queries summed over each cohort's studies (NA and blank left out)", + ["cohort", "entity", "value", "n"], t.counts.map((r) => [r.cohort, r.entity, r.value, r.n]), file) + : pyTable("data", "Per cohort and entity: n, sum and sum of squares of the numeric values, from the assays:moments queries summed over each cohort's studies", + ["cohort", "entity", "n", "sx", "sxx"], t.moments.map((r) => [r.cohort, r.entity, r.n, r.sx, r.sxx]), file)), + "rows = []", + ...(cat ? [ + "data[\"value\"] = data[\"value\"].astype(str)", + "for entity, g in data.groupby(\"entity\"):", + " table = g.pivot_table(index=\"cohort\", columns=\"value\", values=\"n\", aggfunc=\"sum\", fill_value=0).reindex(cohorts, fill_value=0)", + " if table.shape[1] < 2 or (table.sum(axis=1) < MIN_N).any():", + " continue", + " table = table.loc[:, table.sum(axis=0) > 0]", + " rows.append((entity, chi2_contingency(table.to_numpy(), correction=False).pvalue))", + ] : [ + "data[\"mean\"] = data[\"sx\"] / data[\"n\"]", + "data[\"var\"] = ((data[\"sxx\"] - data[\"sx\"] ** 2 / data[\"n\"]) / (data[\"n\"] - 1)).clip(lower=0) # rounding can put a constant entity's just below 0", + "for entity, g in data.groupby(\"entity\"):", + " g = g.set_index(\"cohort\").reindex(cohorts)", + " if g[\"n\"].isna().any() or (g[\"n\"] < MIN_N).any() or (g[\"var\"] <= 1e-12 * (1 + g[\"mean\"] ** 2)).all():", + " continue", + ...(two ? [ + " a, b = g.iloc[0], g.iloc[1]", + " p = ttest_ind_from_stats(a[\"mean\"], np.sqrt(a[\"var\"]), a[\"n\"], b[\"mean\"], np.sqrt(b[\"var\"]), b[\"n\"], equal_var=False).pvalue", + ] : [ + " g = g[(g[\"n\"] >= 2) & (g[\"var\"] > 0)] # a cohort with under 2 values or no variance says nothing", + " p = anova_generic(g[\"mean\"].to_numpy(), g[\"var\"].to_numpy(), g[\"n\"].to_numpy(), use_var=\"unequal\", welch_correction=True).pvalue if len(g) >= 2 else 1.0", + ]), + " rows.append((entity, p))", + ]), + "res = pd.DataFrame(rows, columns=[\"entity\", \"p\"])", + "res[\"q\"] = multipletests(res[\"p\"], method=\"fdr_bh\")[1]", + "res = res.sort_values([\"p\", \"entity\"])", + "print(len(res), \"entities tested\")", + "print(res.head(30).to_string(index=False))", + "for r in res.itertuples():", + " RESULT[f\"assays:{r.entity}:p\"], RESULT[f\"assays:{r.entity}:q\"] = r.p, r.q", + "RESULT[\"assays:tested\"] = len(res)", + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: [cat ? "chiSquareTest" : two ? "welchT" : "welchAnovaTest", "varianceFromMoments", "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: (cat ? "The chi-squared test matches scipy " + VERIFIED.chi2.version + " chi2_contingency" + : "Welch's t and ANOVA from moments match scipy " + VERIFIED.welch.version + " ttest_ind(equal_var=False) and statsmodels " + VERIFIED.anova.version + " " + VERIFIED.anova.fn) + + " and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/moments-golden.test.ts, test/tables-golden.test.ts).", + python: withResult(python), + check: { "assays:tested": { v: t.tested }, ...checks("assays:", t.results.slice(0, 40), (r) => r.entity, [["p", "p", { rel: 1e-6, abs: 1e-300 }], ["q", "q", { rel: 1e-6, abs: 1e-300 }]]) }, + }; +} diff --git a/lib/views/assays.ts b/lib/views/assays/run.ts similarity index 96% rename from lib/views/assays.ts rename to lib/views/assays/run.ts index d6cbc01..646762a 100644 --- a/lib/views/assays.ts +++ b/lib/views/assays/run.ts @@ -1,10 +1,12 @@ // Assays view: every entity of one generic-assay profile, compared between cohorts -import type { ViewResult, AssaysView, AssaysEntity, AssaysResult } from "../types.ts"; -import { assayProfileSql, assayMomentsSql, assayValueCountsSql, assayEntityNamesSql } from "../federation-views.ts"; -import { chiSquareTest, benjaminiHochberg, welchT, welchAnovaTest, varianceFromMoments } from "../stats.ts"; -import { assaysMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, niceCeil, PROFILE_NAME, askEachSite } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { assayProfileSql, assayEntityNamesSql } from "../../federation-views.ts"; +import { chiSquareTest, benjaminiHochberg, welchT, welchAnovaTest, varianceFromMoments } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, niceCeil, PROFILE_NAME, askEachSite } from "../shared.ts"; +import type { AssaysView, AssaysEntity, AssaysResult } from "./types.ts"; +import { assayMomentsSql, assayValueCountsSql } from "./sql.ts"; +import { assaysMethod } from "./method.ts"; const ASSAY_ROWS = 300; /** past this many entities a profile is refused rather than compared: the per-cohort answer diff --git a/lib/views/assays/sql.ts b/lib/views/assays/sql.ts new file mode 100644 index 0000000..3f6abd4 --- /dev/null +++ b/lib/views/assays/sql.ts @@ -0,0 +1,34 @@ +// The assays view's SQL: what its runner (run.ts) asks each participant's database for. +import { sq } from "../../compiler.ts"; + +/** Per entity, over the cohort's samples (or patients, for a patient-level assay): n, Σv and Σv² of + * the values that read as numbers, and how many did not (a limit value like ">10", or NA). */ +export function assayMomentsSql(cohortSql: string, study: string, db: string, profileType: string): string { + return ( + assayRowsCte(cohortSql, study, db, profileType) + "\n" + + "SELECT entity, countIf(isFinite(toFloat64OrNull(value))) AS n,\n" + + " sumIf(toFloat64OrNull(value), isFinite(toFloat64OrNull(value))) AS sx,\n" + + " sumIf(pow(toFloat64OrNull(value), 2), isFinite(toFloat64OrNull(value))) AS sxx,\n" + + " countIf(NOT isFinite(ifNull(toFloat64OrNull(value), nan))) AS other\n" + + "FROM rows GROUP BY entity" + ); +} + +/** Per entity and value: how many of the cohort's samples (or patients) take it. Counts only. */ +export function assayValueCountsSql(cohortSql: string, study: string, db: string, profileType: string): string { + return assayRowsCte(cohortSql, study, db, profileType) + "\nSELECT entity, value, count() AS n FROM rows GROUP BY entity, value"; +} + +/** The cohort's rows of one generic-assay profile. The profile type is the table's leading sort + * key, so only that profile's rows are read; the study's own profile id then picks its slice. */ +function assayRowsCte(cohortSql: string, study: string, db: string, profileType: string): string { + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "rows AS (\n" + + " SELECT entity_stable_id AS entity, value FROM " + db + ".generic_assay_data_derived\n" + + " WHERE profile_type = " + sq(profileType) + "\n" + + " AND profile_stable_id = " + sq(study + "_" + profileType) + "\n" + + " AND patient_unique_id IN (SELECT concat(" + sq(study + "_") + ", pid) FROM cohort)\n" + + ")" + ); +} diff --git a/lib/views/assays/types.ts b/lib/views/assays/types.ts new file mode 100644 index 0000000..916b20c --- /dev/null +++ b/lib/views/assays/types.ts @@ -0,0 +1,59 @@ +// The assays view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Generic-assay comparison (cBioPortal's group-comparison tabs for mutational signatures, arm-level + * copy number, treatment response and every other generic assay): every entity of one generic-assay + * profile compared between cohorts. Numeric profiles by Welch's t (two cohorts) or Welch's ANOVA; + * categorical ones by chi-squared on values × cohorts; Benjamini–Hochberg across the entities. + * Counts and moments only. + */ +export interface AssaysView { + note?: string; + kind: "assays"; cohorts?: string[]; + /** the generic-assay profile, as the genericAssay clause names it (e.g. mutational_signatures_contribution_v2) */ + profileType: string; + /** an entity with fewer measured samples than this in any cohort is not tested (default 3) */ + minN?: number; + /** entities named on the volcano (default 12) */ + label?: number; +} +export interface AssaysEntity { + entity: string; name: string; + /** numeric: n, mean and SD per cohort; categorical: n and the count of each value */ + cells: { cohort: string; n: number; mean?: number | null; sd?: number | null; values?: Record }[]; + /** numeric, two cohorts: first cohort's mean minus the second's */ + diff?: number; + statistic: number; p: number; q: number; + /** numeric: the cohort with the highest mean; categorical: the cohort and value most over-represented */ + higherIn: string; + value?: string; +} +export interface AssaysResult { + profileType: string; profileName: string; assayType: string; + datatype: "numeric" | "categorical"; + /** what one value describes */ + unit: "samples" | "patients"; + groups: string[]; + tested: number; untested: number; significant: number; minN: number; + test: string; + /** categorical: every value seen, in order of frequency; missing ones (NA, blank) are counted apart */ + values?: string[]; + /** values left out: non-numeric entries of a numeric profile (a limit like ">8", NA), or NA/blank in a categorical one */ + missing: number; + /** the most significant entities (≤300), the rest summarised in `grid` */ + rows: AssaysEntity[]; + grid?: { xMax: number; yMax: number; nx: number; ny: number; cells: [number, number, number][] }; + labels: string[]; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { assays: AssaysView } + interface ViewResultFields { + assays: { + assays?: AssaysResult; + }; + } + interface ViewResultMain { assays: "assays" } +} diff --git a/lib/figures/attrition.ts b/lib/views/attrition/figure.ts similarity index 97% rename from lib/figures/attrition.ts rename to lib/views/attrition/figure.ts index 075f2c2..19de11a 100644 --- a/lib/figures/attrition.ts +++ b/lib/views/attrition/figure.ts @@ -3,10 +3,11 @@ // Columns share the figure's width; past as many as fit at a readable width they wrap onto a // further row of columns rather than run off the edge. Every box is as wide as its column and its // clause wraps to that width, so a long clause takes more lines, never more than the box. -import type { AttritionPanel, DrawnResultOf } from "../types.ts"; +import type { DrawnResultOf } from "../../types.ts"; import { palette, frame, caption, label, wrapWidth, ellipsize, seriesStyle, fmtInt, fmtPct, esc, FS, SW, type SvgOpts -} from "../svg-kit.ts"; +} from "../../svg-kit.ts"; +import type { AttritionPanel } from "./types.ts"; const PAD = 8, GAP = 20, MIN_COL = 200, MAX_COL = 340, ARROW = 24, LH = 14, ROW_GAP = 22; /** a clause wraps to at most this many lines; the rest is cut, the full clause kept as the box's tooltip */ diff --git a/lib/views/attrition/method.ts b/lib/views/attrition/method.ts new file mode 100644 index 0000000..2e088ba --- /dev/null +++ b/lib/views/attrition/method.ts @@ -0,0 +1,16 @@ +// The attrition view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import type { MethodRecord } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export function attritionMethod(panels: { cohort: string; base: number; steps: { label: string; n: number; excluded: number }[]; studies: string[] }[]): MethodRecord { + return { + steps: panels.map((p) => ({ + kind: "fetch" as const, title: "Count the cohort clause by clause", + detail: "One attrition query per step above, per study: the patients left after ANDing that clause onto every clause before it, in the order the cohort lists them, summed across " + + (p.studies.length === 1 ? p.studies[0] : p.studies.length + " studies (overlap not deduplicated)") + ". Nothing is computed from these counts beyond the differences between steps.", + scope: p.cohort, + params: [["every patient", p.base.toLocaleString()], ...p.steps.map((s) => [s.label, s.n.toLocaleString() + " (−" + s.excluded.toLocaleString() + ")"] as [string, string])], + })), + implementation: { functions: [], file: "lib/views/attrition/run.ts", commit: BUILD_COMMIT }, + }; +} diff --git a/lib/views/attrition.ts b/lib/views/attrition/run.ts similarity index 91% rename from lib/views/attrition.ts rename to lib/views/attrition/run.ts index 6a1c1c7..211fb98 100644 --- a/lib/views/attrition.ts +++ b/lib/views/attrition/run.ts @@ -1,9 +1,10 @@ // Attrition view: how each cohort's clauses narrow the study, one step at a time -import type { CohortDef, ViewResult, AttritionView, AttritionPanel } from "../types.ts"; -import { cohortClauses, describeClause } from "../cohort2.ts"; -import { attritionMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit } from "./shared.ts"; +import type { CohortDef, ViewResult } from "../../types.ts"; +import { cohortClauses, describeClause } from "../../cohort2.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit } from "../shared.ts"; +import type { AttritionView, AttritionPanel } from "./types.ts"; +import { attritionMethod } from "./method.ts"; const ATTRITION_COHORT_LIMIT = 6; diff --git a/lib/views/attrition/types.ts b/lib/views/attrition/types.ts new file mode 100644 index 0000000..13af25a --- /dev/null +++ b/lib/views/attrition/types.ts @@ -0,0 +1,34 @@ +// The attrition view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Cohort attrition: for each cohort, the patients remaining after each successive clause of its + * `all`, CONSORT-style — so a reader sees which criterion removed whom before trusting any + * downstream number. Counts only; every participant can answer. + */ +export interface AttritionView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; + kind: "attrition"; cohorts?: string[]; +} +export interface AttritionStep { + /** the clause, in the same plain language as the cohort readback */ + label: string; + /** patients left after this clause and every clause above it */ + n: number; + /** removed by this clause alone, given the ones above it */ + excluded: number; + /** n as a share of the study base, one decimal */ + pctOfBase: number | null; +} +export interface AttritionPanel { cohort: string; base: number; steps: AttritionStep[]; studies: string[] } + +declare module "../../types.ts" { + interface ViewSpecs { attrition: AttritionView } + interface ViewResultFields { + attrition: { + attrition?: AttritionPanel[]; + }; + } + interface ViewResultMain { attrition: "attrition" } +} diff --git a/lib/figures/cancertypes.ts b/lib/views/cancertypes/figure.ts similarity index 97% rename from lib/figures/cancertypes.ts rename to lib/views/cancertypes/figure.ts index d22e8c0..3caae54 100644 --- a/lib/figures/cancertypes.ts +++ b/lib/views/cancertypes/figure.ts @@ -2,9 +2,10 @@ import { esc, palette, frame, caption, linear, axisLeft, leftAxisWidth, bandLabels, legend, label, textWidth, fmtInt, fmtNum, fmtPct, FS, SW, type SvgOpts, type LegendItem -} from "../svg-kit.ts"; -import type { CancertypesGroup, ViewResultOf, DrawnResultOf } from "../types.ts"; -import { AMP, DEL, MULTI, MUT, SV } from "./common.ts"; +} from "../../svg-kit.ts"; +import type { ViewResultOf, DrawnResultOf } from "../../types.ts"; +import { AMP, DEL, MULTI, MUT, SV } from "../../figures/common.ts"; +import type { CancertypesGroup } from "./types.ts"; type CtResult = NonNullable["cancertypes"]>; const CT_KINDS: [keyof CancertypesGroup, string, string][] = [["mutation", "Mutation", MUT], ["amp", "Amplification", AMP], diff --git a/lib/views/cancertypes/method.ts b/lib/views/cancertypes/method.ts new file mode 100644 index 0000000..007a565 --- /dev/null +++ b/lib/views/cancertypes/method.ts @@ -0,0 +1,32 @@ +// The cancertypes view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import type { MethodRecord, MethodStep } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface CancertypesTrace { + genes: string[]; groupBy: string; measure: "driver" | "any"; oncokbPin: string | null; minSamples: number; + unit: "patient" | "sample"; + perSite: { cohort: string; study: string; groups: number; profiled: number }[]; + panels: { cohort: string; groups: number; pooled: number; profiled: number; altered: number }[]; +} + +export function cancertypesMethod(t: CancertypesTrace): MethodRecord { + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Count " + t.unit + "s per " + t.groupBy + " group", + detail: "The cancertypes queries above. Per group, for the genes together and for each gene: the " + t.unit + "s profiled for it, and of those the " + t.unit + "s with " + + (t.measure === "driver" ? "an OncoKB driver (" + t.oncokbPin + ")" : "any mutation, amplification, deep deletion or structural variant") + + ", split by kind (one kind only, or more than one); and the whole cohort's, each " + t.unit + " once." + + (t.unit === "patient" ? " A patient counts in each group one of their samples is in, profiled if any of those samples is, altered if any is." : "") + + " Per group too, how many were profiled for mutations, copy number and structural variants at all. Counts only.", + scope: s.cohort + " · " + s.study, rowsOut: s.groups, + params: [["genes", t.genes.join(", ")], ["profiled " + t.unit + "s", s.profiled.toLocaleString("en-US")]], + })); + if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) + steps.push({ kind: "transform", title: "Add each group's counts across studies", detail: "Groups are matched by their folded value; the counts add exactly." }); + for (const p of t.panels) steps.push({ + kind: "transform", title: "Pool the small groups", scope: p.cohort, + detail: p.pooled ? p.pooled + " group" + (p.pooled === 1 ? "" : "s") + " with fewer than " + t.minSamples + " profiled " + t.unit + "s drawn as one pooled bar; " + p.groups + " drawn on their own." + : "Every group has at least " + t.minSamples + " profiled " + t.unit + "s; none pooled.", + params: [["profiled", p.profiled.toLocaleString("en-US")], ["altered", p.altered.toLocaleString("en-US")]], + }); + return { steps, implementation: { functions: [], file: "lib/views/cancertypes/run.ts", commit: BUILD_COMMIT } }; +} diff --git a/lib/views/cancertypes.ts b/lib/views/cancertypes/run.ts similarity index 96% rename from lib/views/cancertypes.ts rename to lib/views/cancertypes/run.ts index e1c1654..115a8dd 100644 --- a/lib/views/cancertypes.ts +++ b/lib/views/cancertypes/run.ts @@ -1,10 +1,11 @@ // Cancer types view: a gene list's alteration frequency per cancer type -import type { ViewResult, CancertypesView, CancertypesGroup, CancertypesPanel } from "../types.ts"; -import { cancerTypesSql, cancerTypeSamplesSql } from "../federation-views.ts"; -import { geneSymbols } from "../gene-tracks.ts"; -import { cancertypesMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, ATTR_NAME, answering, plural, refuse } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, ATTR_NAME, answering, plural, refuse } from "../shared.ts"; +import type { CancertypesView, CancertypesGroup, CancertypesPanel } from "./types.ts"; +import { cancerTypesSql, cancerTypeSamplesSql } from "./sql.ts"; +import { cancertypesMethod } from "./method.ts"; const zeroGroup = (key: string, label: string): CancertypesGroup => ({ key, label, samples: 0, profiled: 0, altered: 0, mutation: 0, amp: 0, homdel: 0, sv: 0, multiple: 0 }); diff --git a/lib/views/cancertypes/sql.ts b/lib/views/cancertypes/sql.ts new file mode 100644 index 0000000..ac09095 --- /dev/null +++ b/lib/views/cancertypes/sql.ts @@ -0,0 +1,109 @@ +// The cancertypes view's SQL: what its runner (run.ts) asks each participant's database for. +// cancertypes: a gene list's alteration frequency per group of samples +import { pidOf } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; +import { clinicalValueFoldSql } from "../../event-names.ts"; +import { clinicalTable } from "../../custom-data.ts"; +import { driverJoin, ALTERED_COND } from "../../federation.ts"; + +/** + * Per group of the cohort's samples (a sample attribute's value, or the study) and per gene — plus + * the row "*" for the genes together — the units profiled for that gene and how many are altered, + * split by kind: only mutations, only amplifications, only deep deletions, only structural variants, + * or more than one kind. A unit is a SAMPLE, or a PATIENT counted in each group one of their + * samples falls in (the portal's default); a patient is profiled when any of their samples is, and + * carries the union of their samples' alterations. Counts only. + */ +export function cancerTypesSql(cohortSql: string, study: string, db: string, genes: string[], attribute: string | null, + pin: string | null, measure: "driver" | "any", unit: "sample" | "patient" = "sample"): string { + const geneList = genes.map(sq).join(","); + const drv = measure === "driver" && pin; + const fold = clinicalValueFoldSql("attribute_value"); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "samp AS (\n" + + " SELECT sample_unique_id AS suid, patient_unique_id AS puid FROM " + db + ".sample_derived\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + (attribute + ? "grp AS (\n" + + " SELECT sample_unique_id AS suid, any(" + fold + ") AS g, any(attribute_value) AS label FROM " + clinicalTable(db, [attribute]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(attribute) + "\n" + + " AND sample_unique_id IN (SELECT suid FROM samp) GROUP BY suid\n" + + "),\n" + : "grp AS (SELECT suid, " + sq(study) + " AS g, " + sq(study) + " AS label FROM samp),\n") + + "alt AS (\n" + + " SELECT g.sample_unique_id AS suid, g.hugo_gene_symbol AS gene,\n" + + " multiIf(g.variant_type = 'mutation', 'mutation', g.variant_type = 'cna' AND g.cna_alteration >= 2, 'amp',\n" + + " g.variant_type = 'cna', 'homdel', 'sv') AS t\n" + + " FROM " + db + ".genomic_event_derived g\n" + + (drv ? driverJoin(pin) : "") + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.hugo_gene_symbol IN (" + geneList + ") AND " + ALTERED_COND + "\n" + + " AND g.sample_unique_id IN (SELECT suid FROM samp)" + (drv ? " AND ann.is_driver" : "") + "\n" + + " GROUP BY suid, gene, t\n" + + "),\n" + + // a sample is profiled for a gene its panel lists, and for one it is altered in (an off-panel call is a measurement) + "cov AS (\n" + + " SELECT DISTINCT sp.sample_unique_id AS suid, pg.gene AS gene FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".gene_panel_to_gene_derived pg ON pg.gene_panel_id = sp.gene_panel_id AND pg.gene IN (" + geneList + ")\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.sample_unique_id IN (SELECT suid FROM samp)\n" + + " AND sp.alteration_type IN ('MUTATION_EXTENDED','COPY_NUMBER_ALTERATION','STRUCTURAL_VARIANT')\n" + + " UNION DISTINCT SELECT DISTINCT suid, gene FROM alt\n" + + "),\n" + + "sg AS (\n" + + " SELECT cov.suid AS suid, cov.gene AS gene, groupUniqArrayIf(alt.t, alt.t != '') AS types\n" + + " FROM cov LEFT JOIN alt ON alt.suid = cov.suid AND alt.gene = cov.gene GROUP BY suid, gene\n" + + "),\n" + + "sg2 AS (SELECT suid, gene, types FROM sg UNION ALL SELECT suid, '*' AS gene, arrayDistinct(arrayFlatten(groupArray(types))) AS types FROM sg GROUP BY suid),\n" + + "u AS (\n" + + " SELECT " + (unit === "patient" ? "samp.puid" : "sg2.suid") + " AS k, ifNull(grp.g, '') AS g, any(ifNull(grp.label, '')) AS label, sg2.gene AS gene,\n" + + " arrayDistinct(arrayFlatten(groupArray(sg2.types))) AS types\n" + + " FROM sg2 JOIN samp ON samp.suid = sg2.suid LEFT JOIN grp ON grp.suid = sg2.suid\n" + + " GROUP BY k, g, gene\n" + + "),\n" + + // the whole cohort, each unit once however many groups it is in: grp '' is reserved for it below + "uall AS (SELECT k, '\\x01all' AS g, '' AS label, gene, arrayDistinct(arrayFlatten(groupArray(types))) AS types FROM u GROUP BY k, gene),\n" + + "uu AS (SELECT k, g, label, gene, types FROM u UNION ALL SELECT k, g, label, gene, types FROM uall)\n" + + "SELECT g AS grp, gene, any(label) AS label, count() AS profiled,\n" + + " countIf(length(types) > 0) AS altered,\n" + + " countIf(types = ['mutation']) AS mutation, countIf(types = ['amp']) AS amp,\n" + + " countIf(types = ['homdel']) AS homdel, countIf(types = ['sv']) AS sv,\n" + + " countIf(length(types) > 1) AS multiple\n" + + "FROM uu GROUP BY grp, gene" + ); +} + +/** Each group's units (samples, or patients with a sample in the group), profiled or not, and how many + * were profiled for mutations, copy number and structural variants at all — the portal's "+" rows. */ +export function cancerTypeSamplesSql(cohortSql: string, study: string, db: string, attribute: string | null, unit: "sample" | "patient" = "sample"): string { + const fold = clinicalValueFoldSql("attribute_value"); + const k = unit === "patient" ? "samp.puid" : "samp.suid"; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "samp AS (\n" + + " SELECT sample_unique_id AS suid, patient_unique_id AS puid FROM " + db + ".sample_derived\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "prof AS (\n" + + " SELECT sample_unique_id AS suid, groupUniqArray(alteration_type) AS types FROM " + db + ".sample_to_gene_panel_derived\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND sample_unique_id IN (SELECT suid FROM samp) GROUP BY suid\n" + + ")\n" + + "SELECT " + (attribute ? "ifNull(grp.g, '')" : sq(study)) + " AS grp, uniqExact(" + k + ") AS samples,\n" + + " uniqExactIf(" + k + ", has(prof.types, 'MUTATION_EXTENDED')) AS mut,\n" + + " uniqExactIf(" + k + ", has(prof.types, 'COPY_NUMBER_ALTERATION')) AS cna,\n" + + " uniqExactIf(" + k + ", has(prof.types, 'STRUCTURAL_VARIANT')) AS sv\n" + + "FROM samp LEFT JOIN prof ON prof.suid = samp.suid\n" + + (attribute + ? "LEFT JOIN (\n" + + " SELECT sample_unique_id AS suid, any(" + fold + ") AS g FROM " + clinicalTable(db, [attribute]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(attribute) + "\n" + + " AND sample_unique_id IN (SELECT suid FROM samp) GROUP BY suid\n" + + ") grp ON grp.suid = samp.suid\n" + : "") + + "GROUP BY grp\n" + + // the whole cohort, each unit once + "UNION ALL SELECT '\\x01all' AS grp, uniqExact(" + k + "), uniqExactIf(" + k + ", has(prof.types, 'MUTATION_EXTENDED')),\n" + + " uniqExactIf(" + k + ", has(prof.types, 'COPY_NUMBER_ALTERATION')), uniqExactIf(" + k + ", has(prof.types, 'STRUCTURAL_VARIANT'))\n" + + "FROM samp LEFT JOIN prof ON prof.suid = samp.suid" + ); +} diff --git a/lib/views/cancertypes/types.ts b/lib/views/cancertypes/types.ts new file mode 100644 index 0000000..afbe09a --- /dev/null +++ b/lib/views/cancertypes/types.ts @@ -0,0 +1,58 @@ +// The cancertypes view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; +import type { GeneTrackSpec } from "../../gene-tracks.ts"; + +/** + * Cancer Types Summary (cBioPortal's results-view tab): for a gene list, the share of the cohort's + * samples altered in at least one of the genes, per cancer type (or any sample attribute, or per + * study), split by kind of alteration. Each group's denominator is its samples profiled for at least + * one of the genes. Counts only. + */ +export interface CancertypesView { + note?: string; + kind: "cancertypes"; cohorts?: string[]; + genes: GeneTrackSpec[]; + /** a sample attribute to group by, or "study" (default "CANCER_TYPE") */ + groupBy?: string; + /** count OncoKB drivers only, or any alteration (default) */ + measure?: "driver" | "any"; + /** groups with fewer profiled samples than this are pooled into one "other" bar (default 10) */ + minSamples?: number; + /** draw percentages (default) or counts */ + show?: "frequency" | "count"; + /** count patients (default, as the portal does — a patient counts in each group a sample of theirs is in) or samples */ + unit?: "patient" | "sample"; +} +export interface CancertypesGroup { + key: string; label: string; + /** the group's units (patients or samples), profiled or not */ + samples: number; + profiled: number; altered: number; + mutation: number; amp: number; homdel: number; sv: number; multiple: number; + /** units profiled for each data type at all (any gene) — the portal's "+" rows */ + profiledFor?: { mutation: number; cna: number; sv: number }; +} +export interface CancertypesPanel { + cohort: string; samples: number; profiled: number; altered: number; + groups: CancertypesGroup[]; + /** groups under the size floor, pooled into one — counted, never dropped */ + pooled?: CancertypesGroup & { count: number }; + /** the same, one gene at a time (the portal's per-gene tabs) */ + perGene?: { gene: string; groups: CancertypesGroup[]; pooled?: CancertypesGroup & { count: number } }[]; +} +export interface CancertypesResult { + groupBy: string; measure: "driver" | "any"; genes: string[]; minSamples: number; show: "frequency" | "count"; + unit: "patient" | "sample"; + panels: CancertypesPanel[]; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { cancertypes: CancertypesView } + interface ViewResultFields { + cancertypes: { + cancertypes?: CancertypesResult; + }; + } + interface ViewResultMain { cancertypes: "cancertypes" } +} diff --git a/lib/views/clinical/method.ts b/lib/views/clinical/method.ts new file mode 100644 index 0000000..7970e50 --- /dev/null +++ b/lib/views/clinical/method.ts @@ -0,0 +1,158 @@ +// The clinical view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, pFmt, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface ClinicalTrace { + cohorts: string[]; + singleStudy: boolean; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + attributes: { + attribute: string; per: "patient" | "sample"; kind: "cat" | "num"; skipped?: string; + test?: string; statistic?: number; df?: number; df2?: number; p?: number; q?: number; + rankStatistic?: number; rankP?: number; distinct?: number; minExpected?: number; histogramTruncated?: boolean; + }[]; + categorical: { cohort: string; study: string; attribute: string; value: string; n: number }[]; + numeric: { cohort: string; study: string; attribute: string; n: number; sum: number; sumsq: number }[]; + /** single-study only: each numeric attribute's complete (value, count) distribution per cohort */ + histograms: { cohort: string; attribute: string; value: number; n: number }[]; + /** the medians and quartiles the view reported — for the script check */ + medians: { attribute: string; cohort: string; median: number | null; q1: number | null; q3: number | null }[]; +} + +export function clinicalMethod(t: ClinicalTrace): MethodRecord { + const k = t.cohorts.length; + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Describe each attribute", + detail: "The attributeStats query above. A categorical attribute comes back as a count per value; a numeric one as n, Σx and Σx² (from which the mean and variance are exact, and which add across studies)" + + (t.singleStudy ? ", plus its complete value distribution as (value, count) pairs for the median, IQR and rank tests" : "") + + ". Patients for a patient-level attribute, samples for a sample-level one. No individual value leaves the database" + + (t.singleStudy ? " except as a count of how many share it" : "") + ".", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + })); + if (!t.singleStudy) { + steps.push({ kind: "transform", title: "Add each cohort's counts and moments across its studies", + detail: "Value counts, n, Σx and Σx² add exactly. Vocabularies are not harmonized: the same value spelled differently in two studies stays two values. Medians and rank tests need each study's full distribution in one place, so they are not computed for a view that spans studies." }); + } + const skipped = t.attributes.filter((a) => a.skipped); + if (skipped.length) { + steps.push({ kind: "transform", title: "Set aside attributes that cannot be tested", + detail: "Each is named with its reason rather than dropped.", + params: skipped.map((a) => [a.attribute, a.skipped!] as [string, string]) }); + } + const fmtP = (p?: number) => p == null ? "—" : pFmt(p); + const cat = t.attributes.filter((a) => !a.skipped && a.kind === "cat"); + const num = t.attributes.filter((a) => !a.skipped && a.kind === "num"); + if (cat.length) { + steps.push({ kind: "statistic", title: "Pearson χ² for each categorical attribute", + detail: "On the cohort × value table over the full vocabulary, with no continuity correction; an all-zero row or column is dropped first. The smallest expected count is reported because the χ² approximation is poor below about 5.", + params: cat.map((a) => [a.attribute, a.distinct + " values · χ² " + a.statistic + " on " + a.df + " df · p " + fmtP(a.p) + + " · smallest expected " + a.minExpected] as [string, string]) }); + } + if (num.length) { + steps.push({ kind: "statistic", title: k === 2 ? "Welch's t-test for each numeric attribute" : "Welch's ANOVA for each numeric attribute", + detail: (k === 2 + ? "Unequal-variance t from each cohort's n, mean and variance, with Welch–Satterthwaite degrees of freedom; two-sided." + : "The unequal-variance F test from each cohort's n, mean and variance; a cohort with fewer than 2 values or no variance is left out.") + + " Mean and variance are from the summed moments: variance = (Σx² − (Σx)²/n) / (n − 1).", + params: num.map((a) => [a.attribute, (k === 2 ? "t " + a.statistic + " on " + a.df + " df" : "F " + a.statistic + " on " + a.df + " and " + a.df2 + " df") + + " · p " + fmtP(a.p)] as [string, string]) }); + if (t.singleStudy) { + steps.push({ kind: "statistic", title: k === 2 ? "Mann–Whitney U alongside" : "Kruskal–Wallis alongside", + detail: "From each cohort's complete value distribution: ranks averaged within tied values, the variance tie-corrected" + + (k === 2 ? ", the normal approximation with a 0.5 continuity correction; two-sided" : "; H on " + (k - 1) + " df") + + ". Medians and quartiles from the same distribution, interpolating linearly between ranks. A check against skew and outliers — not part of the q-value correction.", + params: num.map((a) => [a.attribute, a.histogramTruncated ? "not computed: too many distinct values to send the distribution whole" + : a.rankStatistic == null ? "—" : (k === 2 ? "U " : "H ") + a.rankStatistic + " · p " + fmtP(a.rankP)] as [string, string]) }); + } + } + const tested = t.attributes.filter((a) => a.p != null); + steps.push({ kind: "statistic", title: "Benjamini–Hochberg across attributes", + detail: "q-values over the " + tested.length + " tested attributes, from the χ² and Welch p-values together." }); + + const hasCat = t.categorical.length > 0, hasNum = t.numeric.length > 0, hasHist = t.histograms.length > 0; + const python = [ + "# Recompute with scipy and statsmodels: scipy " + VERIFIED.welch.version + " and statsmodels " + VERIFIED.anova.version + + " are the versions our code is tested against.", + "import io", + "import numpy as np", + "import pandas as pd", + ...((names) => names.length ? ["from scipy.stats import " + names.join(", ")] : [])( + [hasCat ? "chi2_contingency" : "", hasHist ? (k === 2 ? "mannwhitneyu" : "kruskal") : "", hasNum && k === 2 ? "ttest_ind_from_stats" : ""].filter(Boolean)), + ...(hasNum && k > 2 ? ["from statsmodels.stats.oneway import anova_generic"] : []), + "from statsmodels.stats.multitest import multipletests", + "", + "cohorts = " + JSON.stringify(t.cohorts), + RESULT_INIT, + "p_values = {}", + ...(hasCat ? [ + "", + ...pyTable("categorical", "Categorical attributes: what the attributeStats query returned per cohort and study, a count per value", + ["cohort", "study", "attribute", "value", "n"], t.categorical.map((r) => [r.cohort, r.study, r.attribute, r.value, r.n]), "categorical_counts.csv"), + "categorical[\"value\"] = categorical[\"value\"].astype(str)", + "for attr, g in categorical.groupby(\"attribute\", sort=False):", + " table = g.pivot_table(index=\"cohort\", columns=\"value\", values=\"n\", aggfunc=\"sum\", fill_value=0).reindex(cohorts, fill_value=0)", + " table = table.loc[table.sum(axis=1) > 0, table.sum(axis=0) > 0] # drop an all-zero row or column", + " p = chi2_contingency(table.to_numpy(), correction=False).pvalue if min(table.shape) >= 2 else 1.0", + " p_values[attr] = p", + " print(f\"{attr}: chi-squared p={p:.4g}\")", + ] : []), + ...(hasNum ? [ + "", + ...pyTable("numeric", "Numeric attributes: n, sum and sum of squares per cohort and study", ["cohort", "study", "attribute", "n", "sum", "sumsq"], + t.numeric.map((r) => [r.cohort, r.study, r.attribute, r.n, r.sum, r.sumsq]), "numeric_moments.csv"), + "m = numeric.groupby([\"attribute\", \"cohort\"])[[\"n\", \"sum\", \"sumsq\"]].sum()", + "m[\"mean\"] = m[\"sum\"] / m[\"n\"]", + "m[\"var\"] = ((m[\"sumsq\"] - m[\"sum\"] ** 2 / m[\"n\"]) / (m[\"n\"] - 1)).clip(lower=0) # rounding can put a constant attribute's just below 0", + "for attr in numeric[\"attribute\"].unique():", + " g = m.loc[attr].reindex(cohorts)", + ...(k === 2 ? [ + " a, b = g.iloc[0], g.iloc[1]", + " p = ttest_ind_from_stats(a[\"mean\"], np.sqrt(a[\"var\"]), a[\"n\"], b[\"mean\"], np.sqrt(b[\"var\"]), b[\"n\"], equal_var=False).pvalue", + " print(f\"{attr}: Welch t p={p:.4g}\")", + ] : [ + " g = g[(g[\"n\"] >= 2) & (g[\"var\"] > 0)] # a cohort with under 2 values or no variance says nothing", + " p = anova_generic(g[\"mean\"].to_numpy(), g[\"var\"].to_numpy(), g[\"n\"].to_numpy(), use_var=\"unequal\", welch_correction=True).pvalue", + " print(f\"{attr}: Welch ANOVA p={p:.4g}\")", + ]), + " p_values[attr] = p", + ] : []), + ...(hasHist ? [ + "", + ...pyTable("histograms", "Numeric attributes, single study: each cohort's complete value distribution", ["cohort", "attribute", "value", "n"], + t.histograms.map((r) => [r.cohort, r.attribute, r.value, r.n]), "value_histograms.csv"), + "for attr, g in histograms.groupby(\"attribute\", sort=False):", + " values = [np.repeat(h[\"value\"].to_numpy(), h[\"n\"].to_numpy()) for _, h in g.groupby(\"cohort\", sort=False)]", + " if len(values) < len(cohorts):", + " continue # a cohort's distribution was too large to send whole: no rank test, as in the view", + " for c, v in zip(g[\"cohort\"].unique(), values):", + " print(f\"{attr} {c}: median={np.percentile(v, 50):.3f}, IQR={np.percentile(v, 25):.3f}-{np.percentile(v, 75):.3f}\")", + " for name, q in ((\"median\", 50), (\"q1\", 25), (\"q3\", 75)):", + " RESULT[f\"clinical:{attr}:{c}:{name}\"] = np.percentile(v, q)", + k === 2 + ? " RESULT[f\"clinical:{attr}:rank_p\"] = mannwhitneyu(*values, alternative='two-sided', use_continuity=True, method='asymptotic').pvalue" + : " RESULT[f\"clinical:{attr}:rank_p\"] = kruskal(*values).pvalue", + " print(f\"{attr}: " + (k === 2 ? "Mann-Whitney" : "Kruskal-Wallis") + " p={RESULT[f'clinical:{attr}:rank_p']:.4g}\")", + ] : []), + "", + "q = multipletests(list(p_values.values()), method=\"fdr_bh\")[1]", + "print(pd.DataFrame({\"attribute\": list(p_values), \"p\": list(p_values.values()), \"q\": q}).sort_values(\"p\").to_string(index=False))", + "for (attr, p), qv in zip(p_values.items(), q):", + " RESULT[f\"clinical:{attr}:p\"], RESULT[f\"clinical:{attr}:q\"] = p, qv", + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: [...(cat.length ? ["chiSquareTest"] : []), ...(num.length ? [k === 2 ? "welchT" : "welchAnovaTest"] : []), + ...(num.length && t.singleStudy ? [k === 2 ? "mannWhitneyU" : "kruskalWallis", "quantilesFromHistogram"] : []), "benjaminiHochberg"], + file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "χ² matches scipy " + VERIFIED.chi2.version + " chi2_contingency; Welch's t scipy ttest_ind(equal_var=False) and Welch's ANOVA statsmodels " + + VERIFIED.anova.version + " anova_generic from the same moments; Mann–Whitney, Kruskal–Wallis and quantiles scipy/numpy on the expanded distribution; " + + "Benjamini–Hochberg statsmodels multipletests — all to a relative 1e-6 (test/tables-golden.test.ts, test/moments-golden.test.ts).", + python: withResult(python), + check: { + ...checks("clinical:", t.attributes.filter((a) => !a.skipped), (a) => a.attribute, [["p", "p"], ["q", "q"], ["rankP", "rank_p"]]), + // medians and quartiles are reported to 3 decimals + ...checks("clinical:", t.medians, (m) => m.attribute + ":" + m.cohort, + [["median", "median", { abs: 5e-4 + 1e-9 }], ["q1", "q1", { abs: 5e-4 + 1e-9 }], ["q3", "q3", { abs: 5e-4 + 1e-9 }]]), + }, + }; +} diff --git a/lib/views/clinical.ts b/lib/views/clinical/run.ts similarity index 96% rename from lib/views/clinical.ts rename to lib/views/clinical/run.ts index 59f7445..cf963b0 100644 --- a/lib/views/clinical.ts +++ b/lib/views/clinical/run.ts @@ -1,12 +1,13 @@ // Clinical view: compares clinical attributes' distributions across cohorts, from each // participant's value counts and moments. -import { benjaminiHochberg, chiSquareTest, welchT, welchAnovaTest, varianceFromMoments, mannWhitneyU, kruskalWallis, quantilesFromHistogram } from "../stats.ts"; -import type { ViewResult, ClinicalView, ClinicalRow } from "../types.ts"; -import type { AttrStats } from "../federation.ts"; -import { clinicalMethod } from "../method.ts"; -import { ATTR_NAME_RE } from "../custom-data.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { overLimit } from "./shared.ts"; +import { benjaminiHochberg, chiSquareTest, welchT, welchAnovaTest, varianceFromMoments, mannWhitneyU, kruskalWallis, quantilesFromHistogram } from "../../stats.ts"; +import type { ViewResult } from "../../types.ts"; +import type { AttrStats } from "../../federation.ts"; +import { ATTR_NAME_RE } from "../../custom-data.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { overLimit } from "../shared.ts"; +import type { ClinicalView, ClinicalRow } from "./types.ts"; +import { clinicalMethod } from "./method.ts"; export async function runClinical(view: DocView, c: ViewRun): Promise { const { doc, cohorts, resolvers, vNames, vStudies, participationFor } = c; diff --git a/lib/views/clinical/types.ts b/lib/views/clinical/types.ts new file mode 100644 index 0000000..9d68abe --- /dev/null +++ b/lib/views/clinical/types.ts @@ -0,0 +1,53 @@ +// The clinical view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { SplitBy } from "../../types.ts"; + +export interface ClinicalView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; kind: "clinical"; attributes: string[]; cohorts?: string[]; splitBy?: SplitBy } +/** One clinical attribute compared across cohorts. */ +export interface ClinicalCell { + cohort: string; n: number; + /** categorical: the most common values */ + top?: { v: string; n: number }[]; + /** numeric: computed from additive moments, never from transmitted values */ + mean?: number | null; sd?: number; min?: number | null; max?: number | null; + /** + * numeric, single-study views only: exact median/quartiles from the cohort's complete value + * histogram. Absent (not approximated) when the view spans more than one study, or when the + * attribute had more distinct values than the histogram cap allows. + */ + median?: number | null; q1?: number | null; q3?: number | null; + /** true when the attribute had more distinct values than the histogram cap allows, so median/ + * IQR (and the rank test, if this cell is one of exactly 2) were refused rather than computed + * from a partial distribution — stated, not silently absent */ + histogramTruncated?: boolean; +} +export interface ClinicalRow { + attribute: string; per: "patient" | "sample"; kind: "cat" | "num"; + cells: ClinicalCell[]; + test?: string; statistic?: number; df?: number; p?: number; q?: number; + /** Welch's ANOVA (3+ numeric cohorts) only: the denominator degrees of freedom — `df` above + * holds df1 (groups − 1), an F-test's other, non-integer, Welch-Satterthwaite-style df */ + df2?: number; + /** + * numeric, single-study: a rank-based test alongside the moment-based one (never in place of + * it) — Mann-Whitney U for exactly 2 cohorts, Kruskal-Wallis for 3+ — a check worth having when + * the attribute is skewed or has outliers the mean can mislead on. Deliberately not itself part + * of the q-value correction above, which stays Welch's t/ANOVA only. + */ + rankStatistic?: number; rankP?: number; + /** categorical only: vocabulary size, and the smallest expected cell */ + distinct?: number; minExpected?: number; + /** why this attribute was not tested — stated, never silently omitted */ + skipped?: string; +} + +declare module "../../types.ts" { + interface ViewSpecs { clinical: ClinicalView } + interface ViewResultFields { + clinical: { + clinical?: ClinicalRow[]; + }; + } + interface ViewResultMain { clinical: "clinical" } +} diff --git a/lib/figures/clonality.ts b/lib/views/clonality/figure.ts similarity index 98% rename from lib/figures/clonality.ts rename to lib/views/clonality/figure.ts index 1eafcdc..cc4036c 100644 --- a/lib/figures/clonality.ts +++ b/lib/views/clonality/figure.ts @@ -2,8 +2,9 @@ import { palette, frame, caption, linear, axisBottom, axisLeft, leftAxisWidth, bottomAxisHeight, legend, label, textWidth, fmtInt, fmtPct, fmtP, pDigits, seriesStyle, FS, SW, OTHER, type SvgOpts, type LegendItem -} from "../svg-kit.ts"; -import type { ClonalityPanel, DrawnResultOf } from "../types.ts"; +} from "../../svg-kit.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { ClonalityPanel } from "./types.ts"; type Pal = ReturnType; const PAD = 8, GUT = 12, SUB = 15, BAR = 10, GROUP_GAP = 8, MIN_BAR_W = 140; diff --git a/lib/views/clonality/method.ts b/lib/views/clonality/method.ts new file mode 100644 index 0000000..1ea8dfe --- /dev/null +++ b/lib/views/clonality/method.ts @@ -0,0 +1,75 @@ +// The clonality view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, driverMeaning, type MethodStep, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface ClonalityTrace { + measure: "driver" | "any"; + oncokbPin: string | null; + qc: boolean; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + panels: { cohort: string; samples: number; ascnSamples: number; qcPass: number; qcFail: number; wgdYes: number; wgdNo: number }[]; + /** per cohort (summed over its studies) and gene: FACETS's calls */ + genes: { cohort: string; gene: string; calls: number; clonal: number; subclonal: number; indeterminate: number; loh: number; lohKnown: number }[]; + /** what the view reported (two cohorts only) — for the script check */ + results: { genes: { gene: string; p: number; q: number }[]; wgdP?: number }; +} + +export function clonalityMethod(t: ClonalityTrace): MethodRecord { + const two = t.panels.length === 2; + const what = t.measure === "driver" ? "each " + driverMeaning(t.oncokbPin).replace(/^driver: /, "") : "each mutation"; + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Count FACETS's calls per gene", + detail: "The clonality:summary, clonality:purity-ploidy and clonality:genes queries above. Per gene, " + what + + " in one sample is one call, which FACETS labels clonal, subclonal or indeterminate from its cancer-cell fraction (or leaves uncalled); loss of heterozygosity is a minor copy number of 0 where one was estimated. Per sample: whole-genome doubling, purity and ploidy as FACETS called them. Counts only.", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + })); + steps.push({ kind: "transform", title: t.qc ? "Keep samples that pass FACETS QC" : "Keep every sample", + detail: t.qc ? "Only samples whose FACETS_QC is Pass (matched ignoring case) contribute calls, WGD, purity and ploidy." : "qc: false — FACETS_QC failures are used too.", + params: t.panels.map((pn) => [pn.cohort, pn.qcPass.toLocaleString() + " pass, " + pn.qcFail.toLocaleString() + " fail, of " + + pn.ascnSamples.toLocaleString() + " samples with allele-specific copy number"] as [string, string]) }); + if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's counts across its studies", detail: "The counts add exactly." }); + if (two) { + steps.push({ kind: "statistic", title: "Fisher's exact test per gene, and on WGD", + detail: "Two-sided, on clonal against subclonal calls between the two cohorts, for each gene with at least one such call in both; indeterminate and uncalled calls do not enter. And on whole-genome doubled against not, per sample." }); + steps.push({ kind: "statistic", title: "Benjamini–Hochberg across genes", detail: "q-values over the genes tested." }); + } + const python = [ + "# Recompute" + (two ? " with scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against" : "") + ".", + "import io", + "import pandas as pd", + ...(two ? ["from scipy.stats import fisher_exact", "from statsmodels.stats.multitest import multipletests"] : []), + "", + RESULT_INIT, + ...pyTable("calls", "Per cohort (summed over its studies) and gene: FACETS's calls", ["cohort", "gene", "calls", "clonal", "subclonal", "indeterminate", "loh", "loh_known"], + t.genes.map((g) => [g.cohort, g.gene, g.calls, g.clonal, g.subclonal, g.indeterminate, g.loh, g.lohKnown]), "clonality_calls.csv"), + "wgd = " + JSON.stringify(Object.fromEntries(t.panels.map((pn) => [pn.cohort, [pn.wgdYes, pn.wgdNo]]))) + " # cohort: [doubled, not], QC-passing samples", + "calls[\"uncalled\"] = calls[\"calls\"] - calls[\"clonal\"] - calls[\"subclonal\"] - calls[\"indeterminate\"]", + "print(calls.to_string(index=False))", + "for r in calls.itertuples():", + " RESULT[f\"clonality:{r.cohort}:{r.gene}:clonal\"], RESULT[f\"clonality:{r.cohort}:{r.gene}:subclonal\"] = r.clonal, r.subclonal", + ...(two ? [ + "a_name, b_name = " + JSON.stringify(t.panels.map((pn) => pn.cohort)).slice(1, -1), + "a, b = calls[calls[\"cohort\"] == a_name].set_index(\"gene\"), calls[calls[\"cohort\"] == b_name].set_index(\"gene\")", + "tested = [g for g in a.index if g in b.index and a.loc[g, \"clonal\"] + a.loc[g, \"subclonal\"] > 0 and b.loc[g, \"clonal\"] + b.loc[g, \"subclonal\"] > 0]", + "p = [fisher_exact([[a.loc[g, \"clonal\"], a.loc[g, \"subclonal\"]], [b.loc[g, \"clonal\"], b.loc[g, \"subclonal\"]]]).pvalue for g in tested]", + "q = multipletests(p, method=\"fdr_bh\")[1] if p else []", + "for g, pv, qv in zip(tested, p, q):", + " print(f\"{g}: clonal vs subclonal p={pv:.4g}, q={qv:.4g}\")", + " RESULT[f\"clonality:{g}:p\"], RESULT[f\"clonality:{g}:q\"] = pv, qv", + "if sum(wgd[a_name]) and sum(wgd[b_name]):", + " RESULT[\"clonality:wgd:p\"] = fisher_exact([wgd[a_name], wgd[b_name]]).pvalue", + " print(\"whole-genome doubling p =\", RESULT[\"clonality:wgd:p\"])", + ] : []), + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: two ? ["fisherExact", "benjaminiHochberg"] : [], file: "lib/stats.ts", commit: BUILD_COMMIT }, + ...(two ? { verification: "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/tables-golden.test.ts)." } : {}), + python: withResult(python), + check: { + ...checks("clonality:", t.genes, (g) => g.cohort + ":" + g.gene, [["clonal", "clonal", { abs: 0 }], ["subclonal", "subclonal", { abs: 0 }]]), + ...checks("clonality:", t.results.genes, (g) => g.gene, [["p", "p"], ["q", "q"]]), + ...(t.results.wgdP != null ? { "clonality:wgd:p": { v: t.results.wgdP } } : {}), + }, + }; +} diff --git a/lib/views/clonality.ts b/lib/views/clonality/run.ts similarity index 94% rename from lib/views/clonality.ts rename to lib/views/clonality/run.ts index 4439878..6ee76f1 100644 --- a/lib/views/clonality.ts +++ b/lib/views/clonality/run.ts @@ -1,12 +1,14 @@ // Clonality view: FACETS clonal / subclonal calls, LOH, WGD, purity and ploidy -import type { ViewResult, ClonalityView, ClonalityPanel } from "../types.ts"; -import { hasViewAggregates } from "../federation.ts"; -import { clonalityGenesSql, facetsSummarySql, facetsHistogramSql, ascnStudiesSql } from "../federation-views.ts"; -import { fisherExact, benjaminiHochberg } from "../stats.ts"; -import { geneSymbols } from "../gene-tracks.ts"; -import { clonalityMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, type ViewResolver } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { hasViewAggregates } from "../../federation.ts"; +import { ascnStudiesSql } from "../../federation-views.ts"; +import { fisherExact, benjaminiHochberg } from "../../stats.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, type ViewResolver } from "../shared.ts"; +import type { ClonalityView, ClonalityPanel } from "./types.ts"; +import { clonalityGenesSql, facetsSummarySql, facetsHistogramSql } from "./sql.ts"; +import { clonalityMethod } from "./method.ts"; export async function runClonality(view: ClonalityView, ctx: ViewRun): Promise { const { doc, cohorts, vNames, pin } = ctx; diff --git a/lib/views/clonality/sql.ts b/lib/views/clonality/sql.ts new file mode 100644 index 0000000..19e5e37 --- /dev/null +++ b/lib/views/clonality/sql.ts @@ -0,0 +1,107 @@ +// The clonality view's SQL: what its runner (run.ts) asks each participant's database for. +import { pidOf } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; +import { clinicalValueFoldSql } from "../../event-names.ts"; +import { clinicalTable } from "../../custom-data.ts"; +import { finiteSql } from "../../sql-number.ts"; + +/** + * Per gene, over the cohort's mutation CALLS that carry a FACETS estimate (a call is one mutation + * in one sample): how many are clonal, subclonal, indeterminate or uncalled; how many sit in a + * region with no minor allele (LOH at the locus), of those where the minor copy number is + * known; and the cancer-cell fraction in ten bins. Only the study's current mutation profile is + * read — the table also holds orphaned rows from superseded loads. + */ +export function clonalityGenesSql(cohortSql: string, study: string, db: string, genes: string[], pin: string | null, + measure: "driver" | "any", qc: boolean): string { + const geneList = genes.map(sq).join(","); + const drv = measure === "driver" && pin; + return ( + facetsSamplesCte(cohortSql, study, db, qc) + ",\n" + + "prof AS (\n" + + " SELECT gp.genetic_profile_id AS id FROM " + db + ".genetic_profile gp\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = gp.cancer_study_id\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND gp.genetic_alteration_type = 'MUTATION_EXTENDED'\n" + + "),\n" + + "calls AS (\n" + + " SELECT g.hugo_gene_symbol AS gene, a.sample_id AS sid, a.clonal AS call_state, a.ccf_expected_copies AS ccf, a.minor_copy_number AS minor\n" + + " FROM " + db + ".allele_specific_copy_number a\n" + + " JOIN " + db + ".mutation_event me ON me.mutation_event_id = a.mutation_event_id\n" + + " JOIN " + db + ".gene g ON g.entrez_gene_id = me.entrez_gene_id\n" + + (drv + ? " INNER JOIN oncokb_annotation.variant_annotation ann ON ann.oncokb_version = " + sq(pin) + " AND ann.is_driver\n" + + " AND ann.hugo_gene_symbol = g.hugo_gene_symbol AND ann.alteration_type = 'MUTATION' AND ann.alteration = me.protein_change\n" + : "") + + " WHERE a.genetic_profile_id IN (SELECT id FROM prof) AND a.sample_id IN (SELECT sid FROM samp)\n" + + " AND g.hugo_gene_symbol IN (" + geneList + ")\n" + + ")\n" + + "SELECT gene, count() AS calls, uniqExact(sid) AS samples,\n" + + " countIf(call_state = 'CLONAL') AS clonal, countIf(call_state = 'SUBCLONAL') AS subclonal,\n" + + " countIf(call_state = 'INDETERMINATE') AS indeterminate,\n" + + " countIf(minor = 0) AS loh, countIf(minor IS NOT NULL) AS loh_known,\n" + + Array.from({ length: 10 }, (_, i) => + " countIf(ccf >= " + (i / 10) + (i === 9 ? "" : " AND ccf < " + ((i + 1) / 10)) + ") AS ccf" + i).join(",\n") + "\n" + + "FROM calls GROUP BY gene" + ); +} + +/** + * Per cohort: its samples, how many carry any FACETS estimate, FACETS_QC pass/fail over ALL of + * them, and whole-genome doubling among the samples considered (QC-passing ones, with `qc`). + */ +export function facetsSummarySql(cohortSql: string, study: string, db: string, qc: boolean): string { + const attr = (name: string) => "(SELECT sample_unique_id AS suid, " + clinicalValueFoldSql("attribute_value") + " AS v FROM " + clinicalTable(db, [name]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(name) + ")"; + const considered = qc ? " AND suid IN (SELECT suid FROM qcv WHERE v = 'pass')" : ""; + return ( + facetsSamplesCte(cohortSql, study, db, false) + ",\n" + + "prof AS (\n" + + " SELECT gp.genetic_profile_id AS id FROM " + db + ".genetic_profile gp\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = gp.cancer_study_id\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND gp.genetic_alteration_type = 'MUTATION_EXTENDED'\n" + + "),\n" + + "wgd AS " + attr("FACETS_WGD") + ",\n" + + "qcv AS " + attr("FACETS_QC") + "\n" + + "SELECT count() AS samples,\n" + + " countIf(sid IN (SELECT DISTINCT sample_id FROM " + db + ".allele_specific_copy_number WHERE genetic_profile_id IN (SELECT id FROM prof))) AS ascn_samples,\n" + + " countIf(suid IN (SELECT suid FROM qcv WHERE v = 'pass')) AS qc_pass,\n" + + " countIf(suid IN (SELECT suid FROM qcv WHERE v = 'fail')) AS qc_fail,\n" + + " countIf(suid IN (SELECT suid FROM wgd WHERE v = 'true')" + considered + ") AS wgd_true,\n" + + " countIf(suid IN (SELECT suid FROM wgd WHERE v = 'false')" + considered + ") AS wgd_false\n" + + "FROM samp" + ); +} + +/** FACETS purity (0.05 bins over 0-1) and ploidy (0.25 bins over 0-8) among the samples considered. */ +export function facetsHistogramSql(cohortSql: string, study: string, db: string, qc: boolean): string { + return ( + facetsSamplesCte(cohortSql, study, db, qc) + "\n" + + "SELECT attribute_name AS attr,\n" + + " toInt32(floor(least(if(attribute_name = 'FACETS_PURITY', 0.9999, 7.9999), greatest(0, " + finiteSql("attribute_value") + ")) /\n" + + " if(attribute_name = 'FACETS_PURITY', 0.05, 0.25))) AS bin,\n" + + " count() AS n\n" + + "FROM " + clinicalTable(db, ["FACETS_PURITY", "FACETS_PLOIDY"]) + "\n" + + "WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name IN ('FACETS_PURITY', 'FACETS_PLOIDY')\n" + + " AND sample_unique_id IN (SELECT suid FROM samp) AND " + finiteSql("attribute_value") + " IS NOT NULL\n" + + "GROUP BY attr, bin ORDER BY attr, bin" + ); +} + +// --------------------------------------------------------------------------- +// clonality: FACETS allele-specific copy number, per mutation call + +/** The cohort's samples; with `qc`, only those whose FACETS_QC is Pass. */ +function facetsSamplesCte(cohortSql: string, study: string, db: string, qc: boolean): string { + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "samp AS (\n" + + " SELECT internal_id AS sid, sample_unique_id AS suid FROM " + db + ".sample_derived\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + + (qc + ? " AND sample_unique_id IN (SELECT sample_unique_id FROM " + clinicalTable(db, ["FACETS_QC"]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = 'FACETS_QC'\n" + + " AND " + clinicalValueFoldSql("attribute_value") + " = 'pass')\n" + : "") + + ")" + ); +} diff --git a/lib/views/clonality/types.ts b/lib/views/clonality/types.ts new file mode 100644 index 0000000..419c775 --- /dev/null +++ b/lib/views/clonality/types.ts @@ -0,0 +1,54 @@ +// The clonality view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Clonality from FACETS allele-specific copy number: for each gene, whether the cohort's + * mutation calls are clonal or subclonal, how often the mutated locus has lost its other allele, + * and their cancer-cell fractions; per cohort, whole-genome doubling, purity and ploidy. Only + * studies with allele-specific copy number can answer. Counts only. + */ +export interface ClonalityView { + note?: string; + kind: "clonality"; cohorts?: string[]; + genes: string[]; + /** OncoKB driver mutations only (default) or every mutation call */ + measure?: "driver" | "any"; + /** use only samples whose FACETS_QC is Pass (default true) */ + qc?: boolean; +} +export interface ClonalityGene { + gene: string; + /** mutation calls with a FACETS estimate, and the samples they are in */ + calls: number; samples: number; + clonal: number; subclonal: number; indeterminate: number; uncalled: number; + /** calls where the locus has no minor allele, of calls where the minor copy number is known */ + loh: number; lohKnown: number; + /** cancer-cell fraction, ten bins of 0.1 */ + ccf: number[]; + /** two cohorts: Fisher on clonal vs subclonal, BH across genes */ + p?: number; q?: number; +} +export interface ClonalityPanel { + cohort: string; samples: number; ascnSamples: number; qcPass: number; qcFail: number; + wgd: { yes: number; no: number }; + /** FACETS purity in 0.05 bins over 0–1, ploidy in 0.25 bins over 0–8 */ + purity: number[]; ploidy: number[]; + genes: ClonalityGene[]; +} +export interface ClonalityResult { + measure: "driver" | "any"; qc: boolean; + panels: ClonalityPanel[]; + /** two cohorts: Fisher on whole-genome doubling */ + wgdP?: number; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { clonality: ClonalityView } + interface ViewResultFields { + clonality: { + clonality?: ClonalityResult; + }; + } + interface ViewResultMain { clonality: "clonality" } +} diff --git a/lib/views/coexpression/method.ts b/lib/views/coexpression/method.ts new file mode 100644 index 0000000..1c8f70e --- /dev/null +++ b/lib/views/coexpression/method.ts @@ -0,0 +1,94 @@ +// The coexpression view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface CoexpressionTrace { + cohort: string; + profileType: string; + cohortPatients: { study: string; patients: number }[]; + sums: { study: string; a: string; b: string; n: number; sx: number; sy: number; sxy: number; sxx: number; syy: number }[]; + pairs: number; + tested: number; + /** what the view reported per pair — for the script check */ + results: { a: string; b: string; r: number | null; p: number; q: number; pMeta?: number }[]; +} + +export function coexpressionMethod(t: CoexpressionTrace): MethodRecord { + const multi = t.cohortPatients.length > 1; + const steps: MethodStep[] = t.cohortPatients.map((s) => ({ + kind: "fetch" as const, title: "Sum each gene pair's values", + detail: "The pairMoments query above, on “" + t.profileType + "”. Per pair, over the patients with a value for both genes (a patient with several samples contributes their average): n, Σx, Σy, Σxy, Σx² and Σy² — six sums, no individual value.", + scope: t.cohort + " · " + s.study, rowsIn: s.patients, params: [["pairs", String(t.pairs)]], + })); + if (multi) steps.push({ kind: "transform", title: "Add the six sums across studies", + detail: "Every one is a sum, so the pooled totals — and the correlation of all patients taken together — are exact." }); + steps.push({ kind: "statistic", title: "Pearson correlation per pair" + (multi ? ", pooled" : ""), + detail: "r = (nΣxy − ΣxΣy) / √((nΣx² − (Σx)²)(nΣy² − (Σy)²)); t = r√((n − 2)/(1 − r²)), two-sided p on n − 2 df. Undefined for n < 3 or a constant series." }); + if (multi) steps.push({ kind: "statistic", title: "Fisher-z meta-analysis across studies", + detail: "Each study's own r → z = atanh(r), weighted by n − 3 (fixed effect); a study with n < 4 or |r| = 1 is left out. Pooled z / SE against the normal, two-sided. This asks whether the genes move together within a study, which differing study means can hide in the pooled r; it is the p that is corrected and ranked." }); + steps.push({ kind: "statistic", title: "Benjamini–Hochberg across pairs", + detail: "q-values over the " + t.tested + " pairs with a defined correlation, from the " + (multi ? "meta-analysis" : "Pearson") + " p-values." }); + const python = [ + "# Recompute with scipy and statsmodels: scipy " + VERIFIED.pearson.version + " and statsmodels " + VERIFIED.meta.version + + " are the versions our code is tested against.", + "import io", + "import numpy as np", + "import pandas as pd", + "from scipy.stats import t as t_dist", + ...(multi ? ["from statsmodels.stats.meta_analysis import combine_effects"] : []), + "from statsmodels.stats.multitest import multipletests", + "", + RESULT_INIT, + ...pyTable("sums", "What the pairMoments query returned for each study, cohort " + JSON.stringify(t.cohort) + ", profile " + t.profileType, + ["study", "a", "b", "n", "sx", "sy", "sxy", "sxx", "syy"], t.sums.map((r) => [r.study, r.a, r.b, r.n, r.sx, r.sy, r.sxy, r.sxx, r.syy]), "pair_sums.csv"), + "", + "def pearson(s):", + " # the same r and p scipy.stats.pearsonr gives on the values these sums came from", + " vx, vy = s.n * s.sxx - s.sx ** 2, s.n * s.syy - s.sy ** 2", + " if s.n < 3 or vx <= 0 or vy <= 0:", + " return None, 1.0", + " r = float(np.clip((s.n * s.sxy - s.sx * s.sy) / np.sqrt(vx * vy), -1, 1))", + " if abs(r) == 1:", + " return r, 0.0", + " t = r * np.sqrt((s.n - 2) / (1 - r * r))", + " return r, 2 * t_dist.sf(abs(t), s.n - 2)", + "", + "rows = []", + "for (a, b), g in sums.groupby([\"a\", \"b\"], sort=False):", + " r, p = pearson(g[[\"n\", \"sx\", \"sy\", \"sxy\", \"sxx\", \"syy\"]].sum())", + " row = {\"a\": a, \"b\": b, \"r\": r, \"p\": p}", + ...(multi ? [ + " if len(g) > 1: # in more than one study: meta-analyse the per-study correlations", + " per = [(pearson(s)[0], s.n) for s in g.itertuples()]", + " per = [(r_, n) for r_, n in per if r_ is not None and n >= 4 and abs(r_) < 1]", + " if per:", + " z = np.arctanh([r_ for r_, _ in per]); var = np.array([1 / (n - 3) for _, n in per])", + " res = combine_effects(z, var, method_re=\"chi2\")", + " zf, se = res.mean_effect_fe, res.sd_eff_w_fe", + " row.update(r_meta=np.tanh(zf), p_meta=2 * t_dist.sf(abs(zf / se), np.inf)) # normal tail", + " else:", + " row.update(r_meta=None, p_meta=1.0) # no study had a usable correlation", + ] : []), + " rows.append(row)", + "", + "result = pd.DataFrame(rows)", + "tested = result[result[\"r\"].notna()]", + "p = " + (multi ? "tested[\"p_meta\"].fillna(tested[\"p\"])" : "tested[\"p\"]"), + "result.loc[tested.index, \"q\"] = multipletests(p, method=\"fdr_bh\")[1]", + "print(result.reindex(result[\"r\"].abs().sort_values(ascending=False).index).to_string(index=False))", + "for r in result.itertuples():", + " for f in (\"r\", \"p\", \"q\"" + (multi ? ", \"p_meta\"" : "") + "):", + " RESULT[f\"coexpression:{r.a}/{r.b}:{f}\"] = getattr(r, f)", + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: ["pearsonFromMoments", ...(multi ? ["fisherZMeta"] : []), "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "Pearson's r and p from the six sums match scipy " + VERIFIED.pearson.version + " pearsonr on the underlying values" + + (multi ? ", and the Fisher-z meta-analysis statsmodels " + VERIFIED.meta.version + " " + VERIFIED.meta.fn : "") + + ", to a relative 1e-6 (test/moments-golden.test.ts).", + python: withResult(python), + // r is reported to 4 decimals + check: checks("coexpression:", t.results.filter((x) => x.r != null), (x) => x.a + "/" + x.b, + [["r", "r", { abs: 5e-5 + 1e-12 }], ["p", "p"], ["q", "q"], ["pMeta", "p_meta"]]), + }; +} diff --git a/lib/views/coexpression.ts b/lib/views/coexpression/run.ts similarity index 92% rename from lib/views/coexpression.ts rename to lib/views/coexpression/run.ts index 283281e..532339e 100644 --- a/lib/views/coexpression.ts +++ b/lib/views/coexpression/run.ts @@ -1,12 +1,13 @@ // Coexpression view: pairwise correlation of genes' profile values within one cohort, // pooled from each participant's moments. -import { geneSymbols } from "../gene-tracks.ts"; -import { benjaminiHochberg, pearsonFromMoments, pearsonR, fisherZMeta } from "../stats.ts"; -import type { ViewResult, CoexpressionView, CoexprRow } from "../types.ts"; -import type { PairMoments } from "../federation.ts"; -import { coexpressionMethod } from "../method.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { overLimit, COOCCUR_GENE_LIMIT } from "./shared.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import { benjaminiHochberg, pearsonFromMoments, pearsonR, fisherZMeta } from "../../stats.ts"; +import type { ViewResult } from "../../types.ts"; +import type { PairMoments } from "../../federation.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { overLimit, COOCCUR_GENE_LIMIT } from "../shared.ts"; +import type { CoexpressionView, CoexprRow } from "./types.ts"; +import { coexpressionMethod } from "./method.ts"; export async function runCoexpression(view: DocView, c: ViewRun): Promise { const { doc, cohorts, resolvers, vNames, participationFor } = c; diff --git a/lib/views/coexpression/types.ts b/lib/views/coexpression/types.ts new file mode 100644 index 0000000..ffd9864 --- /dev/null +++ b/lib/views/coexpression/types.ts @@ -0,0 +1,29 @@ +// The coexpression view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { PlotData } from "../../plot.ts"; +import type { } from "../../types.ts"; + +export interface CoexpressionView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; kind: "coexpression"; genes: string[]; profileType: string; cohorts?: string[] } +/** One gene pair's correlation on a continuous profile, from six sums per site. */ +export interface CoexprRow { + a: string; b: string; + /** correlation of the pooled population, from summed moments (exact) */ + r: number | null; n: number; p: number; q: number; + /** present with >1 study: per-site correlations combined by Fisher z */ + rMeta?: number | null; pMeta?: number; sites?: number; + /** per-site r and n, so a reader can see whether the sites agree */ + perStudy?: { study: string; r: number | null; n: number }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { coexpression: CoexpressionView } + interface ViewResultFields { + coexpression: { + coexpression?: CoexprRow[]; + /** a plot's aggregated contract: bins, groups or cells — never points */ + plot?: PlotData; + }; + } + interface ViewResultMain { coexpression: "coexpression" } +} diff --git a/lib/views/compare/method.ts b/lib/views/compare/method.ts new file mode 100644 index 0000000..b2dc1d0 --- /dev/null +++ b/lib/views/compare/method.ts @@ -0,0 +1,107 @@ +// The compare view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, ALTERED_MEANING, driverMeaning, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface CompareTrace { + oncokbPin: string | null; + cohorts: string[]; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + /** the combined ("any") row each study returned per gene */ + counts: { cohort: string; study: string; gene: string; profiled: number; altered: number; driver: number }[]; + genes: string[]; + /** what the view reported per gene — for the script check */ + results: { gene: string; pAny?: number; pDriver?: number; qAny?: number; qDriver?: number }[]; +} + +export function compareMethod(t: CompareTrace): MethodRecord { + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Count profiled and altered patients per gene", + detail: "The geneCounts query above. Per gene: patients with a sample on a gene panel that covers the gene (profiled — the denominator, so a gene a panel omits is not counted as wild-type), " + + ALTERED_MEANING + ", and " + driverMeaning(t.oncokbPin) + ". Counts only.", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + params: [["genes", String(t.genes.length)]], + })); + const multiStudy = t.cohorts.some((c) => t.perSite.filter((s) => s.cohort === c).length > 1); + if (multiStudy) { + steps.push({ kind: "transform", title: "Sum each cohort's counts across its studies", + detail: "Profiled, altered and driver counts are added study by study, so the denominator stays panel-aware. The test that follows is on the pooled counts, not stratified by study." }); + } + const k = t.cohorts.length; + if (k === 2) { + steps.push({ kind: "statistic", title: "Fisher's exact test per gene", + detail: "Two-sided, on the 2×2 table of altered vs not altered (profiled − altered) in each cohort — once for any alteration and once for drivers only.", + params: [["genes tested", String(t.genes.length)]] }); + } else if (k > 2) { + steps.push({ kind: "statistic", title: "Pearson χ² test per gene", + detail: "On the 2×" + k + " table of altered vs not altered, one column per cohort, with no continuity correction; an all-zero row or column is dropped first. Once for any alteration and once for drivers only.", + params: [["genes tested", String(t.genes.length)], ["df", String(k - 1)]] }); + } + if (k >= 2) { + steps.push({ kind: "statistic", title: "Benjamini–Hochberg across genes", + detail: "q-values over the " + t.genes.length + " genes tested here, separately for the any-alteration and driver-only families." }); + } else { + steps.push({ kind: "statistic", title: "No test", detail: "One cohort: frequencies only, nothing to compare against." }); + } + const python = [ + "# Recompute with scipy and statsmodels: scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + + " are the versions our code is tested against.", + "import io", + "import pandas as pd", + k > 2 ? "from scipy.stats import chi2_contingency" : "from scipy.stats import fisher_exact", + "from statsmodels.stats.multitest import multipletests", + "", + RESULT_INIT, + ...pyTable("counts", "What the geneCounts query returned for each cohort and study: patients profiled for, altered in, and with a driver in each gene", + ["cohort", "study", "gene", "profiled", "altered", "driver"], + t.counts.map((r) => [r.cohort, r.study, r.gene, r.profiled, r.altered, r.driver]), "gene_counts.csv"), + "cohorts = " + JSON.stringify(t.cohorts), + "per_cohort = counts.groupby([\"gene\", \"cohort\"])[[\"profiled\", \"altered\", \"driver\"]].sum()", + "for (gene, cohort), r in per_cohort.iterrows():", + " for f in (\"profiled\", \"altered\", \"driver\"):", + " RESULT[f\"compare:{gene}:{cohort}:{f}\"] = r[f]", + "", + ...(k < 2 ? ["print(per_cohort)"] : [ + "def test(gene, col):", + " t = per_cohort.loc[gene].reindex(cohorts).fillna(0)", + ...(k === 2 ? [ + " (a, pa), (b, pb) = [(r[col], r[\"profiled\"]) for _, r in t.iterrows()]", + " return fisher_exact([[a, pa - a], [b, pb - b]], alternative=\"two-sided\").pvalue", + ] : [ + " table = pd.DataFrame({\"yes\": t[col], \"no\": t[\"profiled\"] - t[col]})", + " table = table.loc[table.sum(axis=1) > 0, table.sum(axis=0) > 0] # drop an all-zero row or column", + " if min(table.shape) < 2:", + " return 1.0", + " return chi2_contingency(table.to_numpy(), correction=False).pvalue", + ]), + "", + "genes = " + JSON.stringify(t.genes), + "result = pd.DataFrame({\"gene\": genes,", + " \"p_any\": [test(g, \"altered\") for g in genes],", + " \"p_driver\": [test(g, \"driver\") for g in genes]})", + "result[\"q_any\"] = multipletests(result[\"p_any\"], method=\"fdr_bh\")[1]", + "result[\"q_driver\"] = multipletests(result[\"p_driver\"], method=\"fdr_bh\")[1]", + "print(result.sort_values(\"p_any\").to_string(index=False))", + "for r in result.itertuples():", + " for f in (\"p_any\", \"p_driver\", \"q_any\", \"q_driver\"):", + " RESULT[f\"compare:{r.gene}:{f}\"] = getattr(r, f)", + ]), + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: k === 2 ? ["fisherExact", "benjaminiHochberg"] : k > 2 ? ["chiSquareTest", "benjaminiHochberg"] : [], + file: "lib/stats.ts", commit: BUILD_COMMIT }, + ...(k >= 2 ? { verification: (k === 2 ? "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact" + : "The χ² test matches scipy " + VERIFIED.chi2.version + " chi2_contingency(correction=False)") + + " and Benjamini–Hochberg matches statsmodels " + VERIFIED.bh.version + " " + VERIFIED.bh.fn + ", to a relative 1e-6 (test/tables-golden.test.ts)." } : {}), + python: withResult(python), + check: { + ...checks("compare:", t.results, (r) => r.gene, [["pAny", "p_any"], ["pDriver", "p_driver"], ["qAny", "q_any"], ["qDriver", "q_driver"]]), + // the summed counts behind every cell, exact + ...checks("compare:", [...t.counts.reduce((m, r) => { + const k = r.gene + ":" + r.cohort, cur = m.get(k) || { key: k, profiled: 0, altered: 0, driver: 0 }; + cur.profiled += r.profiled; cur.altered += r.altered; cur.driver += r.driver; return m.set(k, cur); + }, new Map()).values()], (r) => r.key, + [["profiled", "profiled", { abs: 0 }], ["altered", "altered", { abs: 0 }], ["driver", "driver", { abs: 0 }]]), + }, + }; +} diff --git a/lib/views/compare.ts b/lib/views/compare/run.ts similarity index 95% rename from lib/views/compare.ts rename to lib/views/compare/run.ts index 34f1683..718c294 100644 --- a/lib/views/compare.ts +++ b/lib/views/compare/run.ts @@ -1,13 +1,14 @@ // Compare view: per-gene alteration frequencies in each cohort, summed across participants // (each answers with counts from its own database), with Fisher/chi-square tests and BH correction. -import { geneSymbols } from "../gene-tracks.ts"; -import { buildAnnotationIndex } from "../annotation.ts"; -import { fisherExact, benjaminiHochberg, chiSquareTest } from "../stats.ts"; -import type { ViewResult, CompareRow, CompareCell, CompareView } from "../types.ts"; -import { isPatientLevel, type GeneCounts } from "../federation.ts"; -import { compareMethod } from "../method.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { overLimit } from "./shared.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import { buildAnnotationIndex } from "../../annotation.ts"; +import { fisherExact, benjaminiHochberg, chiSquareTest } from "../../stats.ts"; +import type { ViewResult } from "../../types.ts"; +import { isPatientLevel, type GeneCounts } from "../../federation.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { overLimit } from "../shared.ts"; +import type { CompareRow, CompareCell, CompareView } from "./types.ts"; +import { compareMethod } from "./method.ts"; /** the three alteration types a compare-view gene's "any alteration" figure combines */ export const ALTERATION_TYPES = ["mutation", "cna", "structuralVariant"] as const; diff --git a/lib/views/compare/types.ts b/lib/views/compare/types.ts new file mode 100644 index 0000000..3024818 --- /dev/null +++ b/lib/views/compare/types.ts @@ -0,0 +1,37 @@ +// The compare view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { SplitBy } from "../../types.ts"; + +export interface CompareView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; + kind: "compare"; genes: string[]; cohorts?: string[]; splitBy?: SplitBy; +} +export interface CompareCell { + cohort: string; profiled: number; any: number; driver: number; + anyPct: number | null; driverPct: number | null; + /** + * profiled/altered split by alteration type (mutation/cna/structuralVariant) — lets a reader + * (or the UI) tell "0% altered, fully assayed" apart from "0% altered because this whole type + * was never assayed for this cohort's study". `any`/`anyPct` above combine all three, matching + * the oncoprint's "any alteration" convention, and can look like a real negative result even + * when a whole type contributed zero because it was never checked. + */ + byType: { mutation: { profiled: number; altered: number }; cna: { profiled: number; altered: number }; + structuralVariant: { profiled: number; altered: number } }; +} +export interface CompareRow { + gene: string; cells: CompareCell[]; pAny?: number; pDriver?: number; + /** Benjamini–Hochberg across the genes tested in this view, computed separately for + * each of pAny/pDriver — only present once 2+ cohorts produce a p-value to correct. */ + qAny?: number; qDriver?: number; +} + +declare module "../../types.ts" { + interface ViewSpecs { compare: CompareView } + interface ViewResultFields { + compare: { + rows?: CompareRow[]; + }; + } + interface ViewResultMain { compare: "rows" } +} diff --git a/lib/figures/composition.ts b/lib/views/composition/figure.ts similarity index 98% rename from lib/figures/composition.ts rename to lib/views/composition/figure.ts index 648536e..689c6fc 100644 --- a/lib/figures/composition.ts +++ b/lib/views/composition/figure.ts @@ -4,12 +4,12 @@ // // Bars at least MIN_BAR wide are drawn one per case; narrower ones as stacked areas over the // sorted cases, so a cohort of 2,000 is still every case rather than a sample of them. -import type { ViewResultOf, DrawnResultOf } from "../types.ts"; +import type { ViewResultOf, DrawnResultOf } from "../../types.ts"; import { palette, frame, caption, label, legend, linear, axisLeft, leftAxisWidth, textWidth, fmtInt, fmtNum, fmtShare, fmtP, esc, num, FS, SW, type SvgOpts, type LegendItem -} from "../svg-kit.ts"; -import { categoryColor, numericColor, STYLE } from "../oncoprint-style.ts"; +} from "../../svg-kit.ts"; +import { categoryColor, numericColor, STYLE } from "../../oncoprint-style.ts"; type CResult = NonNullable["composition"]>; diff --git a/lib/views/composition/method.ts b/lib/views/composition/method.ts new file mode 100644 index 0000000..cac6916 --- /dev/null +++ b/lib/views/composition/method.ts @@ -0,0 +1,90 @@ +// The composition view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import type { CompositionSource } from "../../composition.ts"; +import type { CompositionCell } from "./types.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface CompositionTrace { + source: CompositionSource; sourceName: string; cohorts: string[]; unit: "samples" | "patients"; normalize: boolean; eps: number; + perSite: { cohort: string; study: string; n: number }[]; + /** per cohort, summed over its studies: n, per part Σp and Σp², Σ and Σ² of entropy, cases left out */ + sums: { cohort: string; n: number; sx: number[]; sxx: number[]; hx: number; hxx: number; incomplete: number; invalid: number }[]; + /** what the view reported, for the script check */ + cells: CompositionCell[]; + two: boolean; +} + +export function compositionMethod(t: CompositionTrace): MethodRecord { + const one = t.unit.replace(/s$/, ""), parts = t.source.parts, k = parts.length; + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Sum each part over the cohort's " + t.unit, + detail: "The composition:moments query above: each " + one + "'s " + k + " parts read and put in part order inside the database; a " + one + + " with every part, none below zero and a sum above zero is kept" + (t.normalize ? ", its parts divided by their sum" : "") + + "; then over those " + t.unit + " n, Σp and Σp² per part, and Σ and Σ² of each one's Shannon entropy. No case's values leave the database in this query.", + scope: s.cohort + " · " + s.study, rowsOut: s.n, + })); + if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's sums across its studies", + detail: "The sums add exactly; there is no batch correction between studies." }); + if (t.cohorts.length >= 2) steps.push({ kind: "statistic", title: "Each cohort against the others pooled, per part; then Benjamini–Hochberg", + detail: "The other cohorts' sums are added into one group. Effect: log2((mean + " + t.eps + ") / (others' mean + " + t.eps + ")). Test: Welch's unequal-variance t " + + "from each side's n, mean and variance (Σp² − (Σp)²/n)/(n − 1), two-sided; not tested when either side has under 2 " + t.unit + " or neither has any spread. " + + (t.two ? "With two cohorts each part's two cells are one comparison, corrected for once. " : "") + "q across every comparison tested.", + params: [["comparisons", String(t.cells.filter((c) => c.p != null && (!t.two || c.cohort === t.cohorts[0])).length)]] }); + const file = "composition_moments.csv"; + const python = [ + "# Recompute with scipy " + VERIFIED.welch.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against.", + "import io", + "import numpy as np", + "import pandas as pd", + "from scipy.stats import ttest_ind_from_stats", + "from statsmodels.stats.multitest import multipletests", + "", + RESULT_INIT, + "cohorts = " + JSON.stringify(t.cohorts), + "parts = " + JSON.stringify(parts), + "EPS = " + t.eps, + "TWO = " + (t.two ? "True" : "False"), + ...pyTable("data", "Per cohort and part: how many " + t.unit + " have every part, and the sum and sum of squares of this part's value over them, from the composition:moments queries summed over each cohort's studies", + ["cohort", "part", "n", "sx", "sxx"], t.sums.flatMap((s) => parts.map((p, j) => [s.cohort, p, s.n, s.sx[j], s.sxx[j]])), file), + "def stats(n, sx, sxx):", + " mean = sx / n if n else 0.0", + " var = max(0.0, (sxx - sx ** 2 / n) / (n - 1)) if n >= 2 else 0.0", + " return mean, var", + "rows = []", + "for cohort in cohorts:", + " for part in parts:", + " a = data[(data[\"cohort\"] == cohort) & (data[\"part\"] == part)].iloc[0]", + " mean, var = stats(a[\"n\"], a[\"sx\"], a[\"sxx\"])", + " RESULT[f\"composition:{cohort}|{part}:mean\"] = mean", + " if len(cohorts) < 2:", + " continue", + " rest = data[(data[\"cohort\"] != cohort) & (data[\"part\"] == part)]", + " rn, rsx, rsxx = rest[\"n\"].sum(), rest[\"sx\"].sum(), rest[\"sxx\"].sum()", + " rmean, rvar = stats(rn, rsx, rsxx)", + " log2 = np.log2((mean + EPS) / (rmean + EPS))", + " RESULT[f\"composition:{cohort}|{part}:log2\"] = log2", + " if a[\"n\"] >= 2 and rn >= 2 and var + rvar > 0:", + " p = ttest_ind_from_stats(mean, np.sqrt(var), a[\"n\"], rmean, np.sqrt(rvar), rn, equal_var=False).pvalue", + " rows.append((cohort, part, p))", + "res = pd.DataFrame(rows, columns=[\"cohort\", \"part\", \"p\"])", + "tested = res[res[\"cohort\"] == cohorts[0]] if TWO else res # two cohorts: one comparison per part", + "if len(tested):", + " q = dict(zip(tested[\"part\"] + \"|\" + tested[\"cohort\"], multipletests(tested[\"p\"], method=\"fdr_bh\")[1]))", + " for r in res.itertuples():", + " RESULT[f\"composition:{r.cohort}|{r.part}:p\"] = r.p", + " RESULT[f\"composition:{r.cohort}|{r.part}:q\"] = q[r.part + \"|\" + (cohorts[0] if TWO else r.cohort)]", + "print(res.assign(q=[RESULT.get(f\"composition:{c}|{p}:q\") for c, p in zip(res[\"cohort\"], res[\"part\"])]).sort_values(\"p\").head(30).to_string(index=False))", + "RESULT[\"composition:tested\"] = len(tested)", + ].join("\n") + "\n"; + const tol = { rel: 1e-6, abs: 1e-9 }, ptol = { rel: 1e-6, abs: 1e-300 }; + return { + steps, + implementation: { functions: ["welchT", "varianceFromMoments", "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "Welch's t from moments matches scipy " + VERIFIED.welch.version + " ttest_ind(equal_var=False) and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/moments-golden.test.ts).", + python: withResult(python), + check: { + "composition:tested": { v: t.cells.filter((c) => c.p != null && (!t.two || c.cohort === t.cohorts[0])).length }, + ...checks("composition:", t.cells as unknown as Record[], (c) => c.cohort + "|" + c.part, [["mean", "mean", tol], ["log2", "log2", tol], ["p", "p", ptol], ["q", "q", ptol]]) + }, + }; +} diff --git a/lib/views/composition.ts b/lib/views/composition/run.ts similarity index 95% rename from lib/views/composition.ts rename to lib/views/composition/run.ts index c4ee19a..7ed5918 100644 --- a/lib/views/composition.ts +++ b/lib/views/composition/run.ts @@ -1,15 +1,17 @@ // Composition view: what each case is made of — cell types, cell states, signature contributions — // as parts of a whole, compared between cohorts. -import type { ViewResult, CompositionView, CompositionCell, CompositionResult } from "../types.ts"; -import { compositionMomentsSql, compositionEntitiesSql, assayProfileSql, assayEntityNamesSql } from "../federation-views.ts"; -import { welchT, benjaminiHochberg, varianceFromMoments } from "../stats.ts"; -import { isPatientLevel } from "../federation.ts"; -import { compositionMethod } from "../method.ts"; +import type { ViewResult } from "../../types.ts"; +import { assayProfileSql, assayEntityNamesSql } from "../../federation-views.ts"; +import { welchT, benjaminiHochberg, varianceFromMoments } from "../../stats.ts"; +import { isPatientLevel } from "../../federation.ts"; import { COMPOSITION_MAX_PARTS, COMPOSITION_CASES_CAP, COMPOSITION_EPS, compositionCasesSql, partNames, validateSource, type CompositionSource -} from "../composition.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, PROFILE_NAME } from "./shared.ts"; +} from "../../composition.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, PROFILE_NAME } from "../shared.ts"; +import type { CompositionView, CompositionCell, CompositionResult } from "./types.ts"; +import { compositionMomentsSql, compositionEntitiesSql } from "./sql.ts"; +import { compositionMethod } from "./method.ts"; const MAX_COHORTS = 8; diff --git a/lib/views/composition/sql.ts b/lib/views/composition/sql.ts new file mode 100644 index 0000000..364a213 --- /dev/null +++ b/lib/views/composition/sql.ts @@ -0,0 +1,29 @@ +// The composition view's SQL: what its runner (run.ts) asks each participant's database for. +// composition: parts of a whole per case (lib/composition.ts builds the vectors) +import type { CompositionSource } from "../../composition.ts"; +import { casesCte } from "../../composition.ts"; +import { sq } from "../../compiler.ts"; + +/** + * Over the cohort's cases that have every part: n, and per part (arrays in part order) Σp and Σp² + * of the values compared; Σh and Σh² of each case's Shannon entropy; and how many cases were left + * out — with only some of the parts, or with a part below zero or none above. One row of sums: the + * vectors are built and summed inside the database. + */ +export function compositionMomentsSql(cohortSql: string, study: string, db: string, s: CompositionSource): string { + const k = s.parts.length; + return casesCte(s, study, db, cohortSql) + + "SELECT count() AS n, sumForEach(ps) AS sx, sumForEach(arrayMap(x -> x * x, ps)) AS sxx, sum(h) AS hx, sum(h * h) AS hxx,\n" + + " (SELECT countIf(k < " + k + ") FROM raw) AS incomplete,\n" + + " (SELECT countIf(k = " + k + " AND NOT (lo >= 0 AND tot > 0)) FROM raw) AS invalid\n" + + "FROM cases"; +} + +/** A generic-assay profile's entities, in id order. Metadata: an entity is a cell type or a + * signature, never a case. */ +export function compositionEntitiesSql(study: string, db: string, profileType: string): string { + return "WITH entities AS (\n" + + " SELECT DISTINCT entity_stable_id AS entity FROM " + db + ".generic_assay_data_derived\n" + + " WHERE profile_type = " + sq(profileType) + " AND profile_stable_id = " + sq(study + "_" + profileType) + "\n" + + ")\nSELECT entity FROM entities ORDER BY entity"; +} diff --git a/lib/views/composition/types.ts b/lib/views/composition/types.ts new file mode 100644 index 0000000..fbae187 --- /dev/null +++ b/lib/views/composition/types.ts @@ -0,0 +1,72 @@ +// The composition view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Composition: what each case is made of — cell types, cell states, signature contributions — as + * parts of a whole, compared between cohorts (the stacked-bar panels of single-cell studies). The + * parts are every entity of one generic-assay profile, or a list of numeric clinical attributes + * (custom: uploads included). Each cohort's mean composition and every (cohort, part) enrichment + * against the view's other cohorts come from sums computed in the database; the per-case bars are + * drawn only where a participant may show cases. + */ +export interface CompositionView { + note?: string; + kind: "composition"; cohorts?: string[]; + /** a generic-assay profile whose entities are the parts (e.g. cell_type_relative_counts) */ + profileType?: string; + /** with profileType: only these of its entities, in this order */ + entities?: string[]; + /** numeric clinical attributes that are the parts, in this order (2–16); custom: names allowed */ + attributes?: string[]; + /** attributes only: one bar per patient (default) or per sample */ + unit?: "patient" | "sample"; + /** divide each case's parts by their sum, so every bar is a whole (default true) */ + normalize?: boolean; + /** the part cases are ordered by, highest first (default the first part) */ + sort?: string; +} +export interface CompositionPart { + /** the entity or attribute it reads */ + id: string; + /** how the figure names it: the entity's name, or the attribute with the parts' shared prefix taken off */ + name: string; +} +export interface CompositionCell { + cohort: string; part: string; + n: number; mean: number; sd: number | null; + /** this cohort against the view's other cohorts pooled: log2 of the ratio of means (each + 0.001) */ + log2?: number; + /** Welch's t on the same split, and Benjamini–Hochberg across every (cohort, part) tested */ + p?: number; q?: number; +} +export interface CompositionResult { + source: "genericAssay" | "clinical"; + /** the profile, or "clinical attributes" */ + sourceName: string; + unit: "samples" | "patients"; + normalize: boolean; + parts: CompositionPart[]; + sort: string; + /** per cohort: cases with every part, cases left out (some part missing, or a part below zero, or all zero) */ + groups: { cohort: string; n: number; incomplete: number; invalid: number; + /** Shannon entropy (natural log) of the normalised parts, mean and SD over the cohort's cases */ + entropy: { mean: number; sd: number | null } | null; + /** the cases' own compositions, sorted by `sort`, highest first; absent when they may not be shown */ + cases?: number[][]; + /** why `cases` is absent */ + casesWhy?: string; + }[]; + cells: CompositionCell[]; + tested: number; significant: number; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { composition: CompositionView } + interface ViewResultFields { + composition: { + composition?: CompositionResult; + }; + } + interface ViewResultMain { composition: "composition" } +} diff --git a/lib/views/cooccurrence/method.ts b/lib/views/cooccurrence/method.ts new file mode 100644 index 0000000..ce0125c --- /dev/null +++ b/lib/views/cooccurrence/method.ts @@ -0,0 +1,99 @@ +// The cooccurrence view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, ALTERED_MEANING, plural, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface CooccurrenceTrace { + cohort: string; + cohortPatients: { study: string; patients: number }[]; + /** the four counts each study returned per pair */ + counts: { study: string; a: string; b: string; both: number; onlyA: number; onlyB: number; neither: number }[]; + pairs: number; + assessable: number; + /** what the view reported per assessable pair — for the script check */ + results: { a: string; b: string; p: number; q: number; pStratified?: number }[]; +} + +export function cooccurrenceMethod(t: CooccurrenceTrace): MethodRecord { + const stratified = t.cohortPatients.length > 1; + const steps: MethodStep[] = t.cohortPatients.map((s) => ({ + kind: "fetch" as const, title: "Count each gene pair's 2×2 table", + detail: "The pairCounts query above. Of the patients with a sample on a mutation panel covering both genes: how many are altered in both, only the first, only the second, or neither (" + + ALTERED_MEANING + "). Four counts per pair, nothing else.", + scope: t.cohort + " · " + s.study, rowsIn: s.patients, + params: [["pairs", String(t.pairs)]], + })); + if (t.assessable < t.pairs) { + steps.push({ kind: "transform", title: "Set aside pairs no panel covers", + detail: plural(t.pairs - t.assessable, "pair") + " had no patient profiled for both genes — no denominator, so no test; they are omitted rather than reported as p = 1.", + rowsIn: t.pairs, rowsOut: t.assessable }); + } + steps.push({ kind: "statistic", title: "Log₂ odds ratio per pair", + detail: "log₂((both · neither) / (only first · only second)) on the counts summed across studies, with 0.5 added to every cell when any cell is 0." + + (stratified ? " The direction reported (co-occurrence vs mutual exclusivity) follows the Mantel–Haenszel odds ratio below." : "") }); + steps.push(stratified + ? { kind: "statistic", title: "Cochran–Mantel–Haenszel test, stratified by study", + detail: "Each study's 2×2 is a stratum (one with an empty margin says nothing and is dropped); the test of a common odds ratio of 1, with the 0.5 continuity correction applied when |Σa − ΣE[a]| ≥ 0.5 (as R's mantelhaen.test), on 1 df. This p is the one corrected and ranked; the two-sided Fisher p on the pooled counts is reported alongside." } + : { kind: "statistic", title: "Fisher's exact test per pair", detail: "Two-sided, on the pair's 2×2 table." }); + steps.push({ kind: "statistic", title: "Benjamini–Hochberg across pairs", + detail: "q-values over the " + t.assessable + " assessable pairs, from the " + (stratified ? "stratified" : "Fisher") + " p-values." }); + const python = [ + "# Recompute with scipy and statsmodels: scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + + " are the versions our code is tested against.", + "import io", + "import numpy as np", + "import pandas as pd", + "from scipy.stats import fisher_exact", + ...(stratified ? ["from scipy.stats import chi2 as chi2_dist", "from statsmodels.stats.contingency_tables import StratifiedTable"] : []), + "from statsmodels.stats.multitest import multipletests", + "", + RESULT_INIT, + ...pyTable("counts", "What the pairCounts query returned for each study, cohort " + JSON.stringify(t.cohort), + ["study", "a", "b", "both", "only_a", "only_b", "neither"], + t.counts.map((r) => [r.study, r.a, r.b, r.both, r.onlyA, r.onlyB, r.neither]), "pair_counts.csv"), + "", + "def log2_or(a, b, c, d):", + " if 0 in (a, b, c, d):", + " a, b, c, d = a + 0.5, b + 0.5, c + 0.5, d + 0.5", + " return np.log2(a * d / (b * c))", + "", + "rows = []", + "for (a, b), g in counts.groupby([\"a\", \"b\"], sort=False):", + " s = g[[\"both\", \"only_a\", \"only_b\", \"neither\"]].sum()", + " if s.sum() == 0:", + " continue # no patient profiled for both genes: not assessable", + " row = {\"a\": a, \"b\": b, \"log2_or\": log2_or(*s),", + " \"p_fisher\": fisher_exact([[s.both, s.only_a], [s.only_b, s.neither]], alternative=\"two-sided\").pvalue}", + ...(stratified ? [ + " strata = [np.array([[r.both, r.only_a], [r.only_b, r.neither]], dtype=float) for r in g.itertuples()", + " if min(r.both + r.only_a, r.only_b + r.neither, r.both + r.only_b, r.only_a + r.neither) > 0]", + " if strata:", + " st = StratifiedTable(strata)", + " expected = sum(t[0].sum() * t[:, 0].sum() / t.sum() for t in strata)", + " delta = abs(sum(t[0, 0] for t in strata) - expected)", + " stat = st.test_null_odds(correction=bool(delta >= 0.5)).statistic # R's continuity-correction rule", + " row[\"p_cmh\"] = chi2_dist.sf(stat, 1) # from the tail directly: test_null_odds' own p is 0 below about 1e-16", + " row[\"or_mh\"] = st.oddsratio_pooled", + " else:", + " row[\"p_cmh\"] = 1.0", + ] : []), + " rows.append(row)", + "", + "result = pd.DataFrame(rows)", + "p = result[\"" + (stratified ? "p_cmh" : "p_fisher") + "\"]", + "result[\"q\"] = multipletests(p, method=\"fdr_bh\")[1]", + "print(result.sort_values(\"" + (stratified ? "p_cmh" : "p_fisher") + "\").to_string(index=False))", + "for r in result.itertuples():", + " RESULT[f\"cooccurrence:{r.a}/{r.b}:p\"], RESULT[f\"cooccurrence:{r.a}/{r.b}:q\"] = r.p_fisher, r.q", + ...(stratified ? [" RESULT[f\"cooccurrence:{r.a}/{r.b}:p_cmh\"] = r.p_cmh"] : []), + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: ["fisherExact", ...(stratified ? ["cochranMantelHaenszel"] : []), "log2OddsRatio", "benjaminiHochberg"], + file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact" + + (stratified ? ", the CMH test and Mantel–Haenszel odds ratio statsmodels " + VERIFIED.cmh.version + " " + VERIFIED.cmh.fn : "") + + " and Benjamini–Hochberg statsmodels " + VERIFIED.bh.version + " " + VERIFIED.bh.fn + ", to a relative 1e-6 (test/tables-golden.test.ts).", + python: withResult(python), + check: checks("cooccurrence:", t.results, (r) => r.a + "/" + r.b, [["p", "p"], ["q", "q"], ["pStratified", "p_cmh"]]), + }; +} diff --git a/lib/views/cooccurrence.ts b/lib/views/cooccurrence/run.ts similarity index 93% rename from lib/views/cooccurrence.ts rename to lib/views/cooccurrence/run.ts index 7ba2576..cbb562f 100644 --- a/lib/views/cooccurrence.ts +++ b/lib/views/cooccurrence/run.ts @@ -1,12 +1,13 @@ // Co-occurrence view: whether pairs of genes are altered together more or less often than // chance within one cohort, with BH correction. -import { geneSymbols } from "../gene-tracks.ts"; -import { fisherExact, benjaminiHochberg, cochranMantelHaenszel, log2OddsRatio } from "../stats.ts"; -import type { ViewResult, CooccurrenceView, CooccurRow } from "../types.ts"; -import type { PairCounts } from "../federation.ts"; -import { cooccurrenceMethod } from "../method.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { overLimit, COOCCUR_GENE_LIMIT } from "./shared.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import { fisherExact, benjaminiHochberg, cochranMantelHaenszel, log2OddsRatio } from "../../stats.ts"; +import type { ViewResult } from "../../types.ts"; +import type { PairCounts } from "../../federation.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { overLimit, COOCCUR_GENE_LIMIT } from "../shared.ts"; +import type { CooccurrenceView, CooccurRow } from "./types.ts"; +import { cooccurrenceMethod } from "./method.ts"; export async function runCooccurrence(view: DocView, c: ViewRun): Promise { const { doc, cohorts, resolvers, vNames, participationFor } = c; diff --git a/lib/views/cooccurrence/types.ts b/lib/views/cooccurrence/types.ts new file mode 100644 index 0000000..4d3d567 --- /dev/null +++ b/lib/views/cooccurrence/types.ts @@ -0,0 +1,29 @@ +// The cooccurrence view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +export interface CooccurrenceView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; kind: "cooccurrence"; genes: string[]; cohorts?: string[] } +/** One gene pair, as a 2x2 among patients profiled for both, plus its inference. */ +export interface CooccurRow { + a: string; b: string; + both: number; onlyA: number; onlyB: number; neither: number; profiled: number; + log2Odds: number | null; + /** two-sided Fisher on the POOLED table */ + p: number; + /** BH-adjusted across all pairs in this view, using whichever p is primary */ + q: number; + /** present when more than one study contributed: the site-stratified answer */ + pStratified?: number; oddsRatioMH?: number | null; strata?: number; + tendency: "co-occurrence" | "mutual exclusivity" | null; +} + +declare module "../../types.ts" { + interface ViewSpecs { cooccurrence: CooccurrenceView } + interface ViewResultFields { + cooccurrence: { + pairs?: CooccurRow[]; + }; + } + interface ViewResultMain { cooccurrence: "pairs" } +} diff --git a/lib/figures/correlates.ts b/lib/views/correlates/figure.ts similarity index 98% rename from lib/figures/correlates.ts rename to lib/views/correlates/figure.ts index ac86e4e..b313238 100644 --- a/lib/figures/correlates.ts +++ b/lib/views/correlates/figure.ts @@ -1,10 +1,10 @@ // Correlates figure: one gene's expression against every gene's — every ρ as a histogram, and the // most positive and most negative genes listed with their ρ as a bar. -import type { ViewResultOf, DrawnResultOf } from "../types.ts"; +import type { ViewResultOf, DrawnResultOf } from "../../types.ts"; import { palette, frame, caption, label, textWidth, wrapWidth, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, fmtInt, pDigits, tickFormat, esc, FS, SW, SERIES, type SvgOpts -} from "../svg-kit.ts"; +} from "../../svg-kit.ts"; type Pal = ReturnType; type CoResult = NonNullable["correlates"]>; diff --git a/lib/views/correlates/method.ts b/lib/views/correlates/method.ts new file mode 100644 index 0000000..4715b2f --- /dev/null +++ b/lib/views/correlates/method.ts @@ -0,0 +1,91 @@ +// The correlates view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, VERIFIED, RESULT_INIT, py, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface CorrelatesTrace { + gene: string; profileType: string; otherProfile: string; cohort: string; + minN: number; multi: boolean; tested: number; untested: number; + perSite: { study: string; genes: number; patients: number }[]; + /** per study and gene: the six sums on values, and the same six on average ranks */ + sums: { study: string; gene: string; n: number; sx: number; sy: number; sxy: number; sxx: number; syy: number; + rsx: number; rsy: number; rsxy: number; rsxx: number; rsyy: number }[]; + /** what the view reported, for the script check */ + results: { gene: string; rho: number; rhoP: number; q: number; r: number; p: number }[]; +} + +export function correlatesMethod(t: CorrelatesTrace): MethodRecord { + const same = t.profileType === t.otherProfile; + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Sum every gene's values and ranks against " + t.gene + "'s", + detail: "The correlates:moments query above. Per patient, " + t.gene + " on “" + t.profileType + "” and every gene on “" + t.otherProfile + + "” (a patient's samples averaged); per gene, over the patients with both: n, Σx, Σy, Σxy, Σx², Σy², and the same six over each value's average rank among those patients (ties share the mean of their ranks). Sums only.", + scope: t.cohort + " · " + s.study, rowsIn: s.patients, rowsOut: s.genes, + })); + steps.push({ kind: "statistic", title: "Pearson's r and Spearman's ρ per gene", + detail: "r from the sums: (nΣxy − ΣxΣy) / √((nΣx² − (Σx)²)(nΣy² − (Σy)²)); ρ is the same formula on the rank sums. Each p from t = r√((n − 2)/(1 − r²)) on n − 2 df, as scipy's pearsonr and spearmanr compute it." + + (t.multi ? " Across studies, r pools the sums (exact); ρ is each study's, combined by Fisher z with weight n − 3 and a normal p, because ranks do not add." : "") + + (same ? " " + t.gene + " against itself is left out." : "") + " A gene with fewer than " + t.minN + " patients, or no spread, is not tested.", + params: [["tested", t.tested.toLocaleString("en-US")], ["not tested", t.untested.toLocaleString("en-US")]] }); + steps.push({ kind: "statistic", title: "Benjamini–Hochberg over ρ", detail: "q-values over every gene tested, from ρ's p." }); + const python = [ + "# Recompute with numpy, scipy " + VERIFIED.pearson.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against.", + "import io", + "import numpy as np", + "import pandas as pd", + "from scipy.stats import t as student_t, norm", + "from statsmodels.stats.multitest import multipletests", + "", + RESULT_INIT, + "MIN_N = " + t.minN, + "QUERY = " + py(t.gene) + (same ? " # left out: a gene against itself" : ""), + ...pyTable("sums", "Per study and gene: the six sums on values and on average ranks, from the correlates:moments queries", + ["study", "gene", "n", "sx", "sy", "sxy", "sxx", "syy", "rsx", "rsy", "rsxy", "rsxx", "rsyy"], + t.sums.map((r) => [r.study, r.gene, r.n, r.sx, r.sy, r.sxy, r.sxx, r.syy, r.rsx, r.rsy, r.rsxy, r.rsxx, r.rsyy]), "correlates_sums.csv"), + "", + "def corr(n, sx, sy, sxy, sxx, syy):", + " den = np.sqrt((n * sxx - sx ** 2) * (n * syy - sy ** 2))", + " with np.errstate(invalid=\"ignore\", divide=\"ignore\"):", + " return np.where(den > 0, (n * sxy - sx * sy) / den, np.nan).clip(-1, 1)", + "", + "def t_p(r, n):", + " with np.errstate(invalid=\"ignore\", divide=\"ignore\"):", + " tt = r * np.sqrt((n - 2) / (1 - r ** 2))", + " return np.where(np.abs(r) >= 1, 0.0, 2 * student_t.sf(np.abs(tt), n - 2))", + "", + "sums[\"rho_s\"] = corr(sums.n, sums.rsx, sums.rsy, sums.rsxy, sums.rsxx, sums.rsyy)", + "g = sums.groupby(\"gene\")[[\"n\", \"sx\", \"sy\", \"sxy\", \"sxx\", \"syy\"]].sum()", + "g[\"r\"] = corr(g.n, g.sx, g.sy, g.sxy, g.sxx, g.syy)", + "g[\"p\"] = t_p(g.r, g.n)", + ...(t.multi ? [ + "# rho: each study's own, combined by Fisher z with weight n - 3 (|rho| = 1 or n < 4 carries no z)", + "ok = (sums.n >= 4) & (sums.rho_s.abs() < 1) & sums.rho_s.notna()", + "s = sums[ok].assign(w=lambda d: d.n - 3, z=lambda d: np.arctanh(d.rho_s))", + "m = s.assign(wz=s.w * s.z).groupby(\"gene\")[[\"w\", \"wz\"]].sum()", + "z = m.wz / m.w", + "g[\"rho\"] = np.tanh(z).reindex(g.index)", + "g[\"rho_p\"] = pd.Series(2 * norm.sf(np.abs(z) * np.sqrt(m.w)), index=m.index).reindex(g.index)", + ] : [ + "g[\"rho\"] = sums.set_index(\"gene\").rho_s.reindex(g.index)", + "g[\"rho_p\"] = t_p(g.rho, g.n)", + ]), + "g = g[(g.n >= MIN_N) & g.rho.notna() & g.r.notna()" + (same ? " & (g.index != QUERY)" : "") + "].copy()", + "g[\"q\"] = multipletests(g.rho_p, method=\"fdr_bh\")[1]", + "print(len(g), \"genes tested\")", + "print(g.sort_values(\"rho\", ascending=False)[[\"n\", \"rho\", \"rho_p\", \"q\", \"r\", \"p\"]].head(15))", + "print(g.sort_values(\"rho\")[[\"n\", \"rho\", \"rho_p\", \"q\", \"r\", \"p\"]].head(15))", + "RESULT[\"correlates:tested\"] = len(g)", + "for gene, x in g.iterrows():", + " for f in (\"rho\", \"rho_p\", \"q\", \"r\", \"p\"):", + " RESULT[f\"correlates:{gene}:{f}\"] = x[f]", + ].join("\n") + "\n"; + // ρ and r are reported to 4 decimals; their p-values come from the unrounded coefficient + const pTol = { rel: 1e-6, abs: 1e-300 }; + return { + steps, + implementation: { functions: ["pearsonFromMoments", ...(t.multi ? ["fisherZMeta"] : []), "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "Pearson's r from sums matches scipy " + VERIFIED.pearson.version + " pearsonr (test/moments-golden.test.ts); ρ is the same formula on average ranks, which is how scipy's spearmanr defines it — checked against spearmanr on raw values for TCGA breast cancer ESR1 (FOXA1 ρ 0.7478, PSAT1 −0.6732). Benjamini–Hochberg matches statsmodels multipletests (test/tables-golden.test.ts).", + python: withResult(python), + check: { "correlates:tested": { v: t.tested }, + ...checks("correlates:", t.results.slice(0, 80), (r) => r.gene, [["rho", "rho", { abs: 5e-5 + 1e-12 }], ["rhoP", "rho_p", pTol], ["q", "q", pTol], ["r", "r", { abs: 5e-5 + 1e-12 }], ["p", "p", pTol]]) }, + }; +} diff --git a/lib/views/correlates.ts b/lib/views/correlates/run.ts similarity index 94% rename from lib/views/correlates.ts rename to lib/views/correlates/run.ts index 8900ed4..4290545 100644 --- a/lib/views/correlates.ts +++ b/lib/views/correlates/run.ts @@ -1,11 +1,12 @@ // Correlates view: one gene's expression against every gene's -import type { ViewResult, CorrelatesView, CorrelatesGene } from "../types.ts"; -import { correlatesMomentsSql } from "../federation-views.ts"; -import { benjaminiHochberg, pearsonFromMoments, pearsonR, fisherZMeta } from "../stats.ts"; -import { geneSymbols } from "../gene-tracks.ts"; -import { correlatesMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { cytobands, answering, refuse, PROFILE_NAME } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { benjaminiHochberg, pearsonFromMoments, pearsonR, fisherZMeta } from "../../stats.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import type { ViewRun } from "../run.ts"; +import { cytobands, answering, refuse, PROFILE_NAME } from "../shared.ts"; +import type { CorrelatesView, CorrelatesGene } from "./types.ts"; +import { correlatesMomentsSql } from "./sql.ts"; +import { correlatesMethod } from "./method.ts"; const CORR_SHOW_MAX = 250; diff --git a/lib/views/correlates/sql.ts b/lib/views/correlates/sql.ts new file mode 100644 index 0000000..6c16729 --- /dev/null +++ b/lib/views/correlates/sql.ts @@ -0,0 +1,41 @@ +// The correlates view's SQL: what its runner (run.ts) asks each participant's database for. +// correlates: one gene's expression against every gene's, genome-wide +import { pidOf } from "../../federation-views.ts"; +import { finiteSql } from "../../sql-number.ts"; +import { sq } from "../../compiler.ts"; + +/** + * Per GENE, over the cohort's patients with a value for both it and the query gene (each the mean + * of the patient's samples, as profileMomentsSql takes it): the six sums Pearson's r needs, n, Σx, + * Σy, Σxy, Σx², Σy² — and the same six over the two values' average ranks among those patients, + * which is Spearman's ρ. x is the query gene on `profileType`; y every gene on `otherProfile` + * (the same profile unless given). Sums only: ranks and values never leave the database. + */ +export function correlatesMomentsSql(cohortSql: string, study: string, db: string, gene: string, profileType: string, otherProfile: string): string { + const pid = pidOf("sd.patient_unique_id", study); + const raw = finiteSql("ga.alteration_value"); + const vals = (pt: string, geneCond: string) => + " SELECT " + pid + " AS pid, ga.hugo_gene_symbol AS gene, avg(" + raw + ") AS v\n" + + " FROM " + db + ".genetic_alteration_derived ga\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = ga.sample_unique_id\n" + + " WHERE ga.cancer_study_identifier = " + sq(study) + " AND ga.profile_type = " + sq(pt) + geneCond + "\n" + + " AND " + raw + " IS NOT NULL AND isFinite(" + raw + ")\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid, gene\n"; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "q AS (\n" + vals(profileType, " AND ga.hugo_gene_symbol = " + sq(gene)) + "),\n" + + "o AS (\n" + vals(otherProfile, "") + "),\n" + + "paired AS (SELECT o.gene AS gene, q.v AS x, o.v AS y FROM o INNER JOIN q ON q.pid = o.pid),\n" + + // average rank among tied values: the first rank of the tie plus half its width + "ranked AS (\n" + + " SELECT gene, x, y,\n" + + " rank() OVER (PARTITION BY gene ORDER BY x) + (count() OVER (PARTITION BY gene, x) - 1) / 2 AS rx,\n" + + " rank() OVER (PARTITION BY gene ORDER BY y) + (count() OVER (PARTITION BY gene, y) - 1) / 2 AS ry\n" + + " FROM paired\n" + + ")\n" + + "SELECT gene, count() AS n, sum(x) AS sx, sum(y) AS sy, sum(x * y) AS sxy, sum(x * x) AS sxx, sum(y * y) AS syy,\n" + + " sum(rx) AS rsx, sum(ry) AS rsy, sum(rx * ry) AS rsxy, sum(rx * rx) AS rsxx, sum(ry * ry) AS rsyy\n" + + "FROM ranked GROUP BY gene" + ); +} diff --git a/lib/views/correlates/types.ts b/lib/views/correlates/types.ts new file mode 100644 index 0000000..2ec334f --- /dev/null +++ b/lib/views/correlates/types.ts @@ -0,0 +1,52 @@ +// The correlates view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Genome-wide co-expression (cBioPortal's results-view Co-expression tab): one gene's expression + * against every gene's in a profile, over one cohort's patients — Spearman's ρ (ranked by it, as + * the portal ranks) and Pearson's r, each with a p, and Benjamini–Hochberg over ρ. Sums only. + */ +export interface CorrelatesView { + note?: string; + kind: "correlates"; cohorts?: string[]; + /** the query gene */ + gene: string; + /** the query gene's profile */ + profileType: string; + /** the profile every other gene is read from (default: the same one) — e.g. protein against mRNA */ + otherProfile?: string; + /** genes listed in each direction (default 100, at most 250) */ + show?: number; + /** a gene with fewer patients having both values is not tested (default 10) */ + minN?: number; +} +export interface CorrelatesGene { + gene: string; n: number; + /** in the study's reference genome, when known */ + cytoband?: string; + /** Spearman's ρ with its p (the t approximation scipy uses, or the Fisher-z meta p across studies) and BH q */ + rho: number | null; rhoP: number; q: number; + /** Pearson's r and its p, from the pooled sums */ + r: number | null; p: number; +} +export interface CorrelatesResult { + gene: string; profileType: string; otherProfile: string; cohort: string; + tested: number; untested: number; significant: number; minN: number; + /** "rank" when ρ is one study's; "meta" when ρ combines several studies' by Fisher z */ + rhoFrom: "rank" | "meta"; + /** the most positively and most negatively correlated genes, by ρ */ + positive: CorrelatesGene[]; negative: CorrelatesGene[]; + /** ρ of every gene tested, in bins of 0.05 from −1 to 1: [bin low edge, count] */ + histogram: [number, number][]; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { correlates: CorrelatesView } + interface ViewResultFields { + correlates: { + correlates?: CorrelatesResult; + }; + } + interface ViewResultMain { correlates: "correlates" } +} diff --git a/lib/figures/course.ts b/lib/views/course/figure.ts similarity index 95% rename from lib/figures/course.ts rename to lib/views/course/figure.ts index b11cd70..cf8cf0b 100644 --- a/lib/figures/course.ts +++ b/lib/views/course/figure.ts @@ -1,12 +1,13 @@ // The clinical course: small multiples, one panel per event type, one line per cohort. -import type { CourseSeries, DrawnResultOf } from "../types.ts"; +import type { DrawnResultOf } from "../../types.ts"; import { palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, legend, label, textWidth, seriesStyle, fmtInt, FS, SW, type SvgOpts -} from "../svg-kit.ts"; -import { timeScale } from "../chart/time.ts"; -import { lineSwatch } from "../chart/legend.ts"; -import { r1 } from "../chart/marks.ts"; +} from "../../svg-kit.ts"; +import { timeScale } from "../../chart/time.ts"; +import { lineSwatch } from "../../chart/legend.ts"; +import { r1 } from "../../chart/marks.ts"; +import type { CourseSeries } from "./types.ts"; /** * The clinical course: small multiples, one panel per event type, one line per diff --git a/lib/views/course/method.ts b/lib/views/course/method.ts new file mode 100644 index 0000000..dd60793 --- /dev/null +++ b/lib/views/course/method.ts @@ -0,0 +1,71 @@ +// The course view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface CourseTrace { + anchor?: string; + binMonths: number; + fromBin: number; toBin: number; + riskFloor: number; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + dropped: string[]; + /** each measured event type: its label, the spellings folded into it, and whether it carries durations */ + events: { canonical: string; label: string; variants: string[]; isInterval: boolean }[]; + cohorts: { name: string; patients: number; anchored: number; observed: number }[]; + /** per cohort, summed over its studies: patients observable in each bin (event ''), and in each state */ + counts: { cohort: string; bin: number; event: string; n: number }[]; + /** what the view reported — for the script check */ + results: { cohort: string; event: string; bin: number; atRisk: number; n: number; pct: number | null }[]; +} + +export function courseMethod(t: CourseTrace): MethodRecord { + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Count patients per time bin", + detail: "The timelineSpan and eventCourse queries above. Per " + t.binMonths + "-month bin: the cohort's patients observable in it, and of those the ones in each event's state (or with the event in it). Counts only.", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + })); + if (t.dropped.length) steps.push({ kind: "transform", title: "Leave out studies with no timeline structure", + detail: "A study whose every event sits on one day cannot place anything in time: " + t.dropped.join(", ") + "." }); + const folds = t.events.filter((e) => e.variants.length > 1); + steps.push({ kind: "transform", title: "Match event types", + detail: "Event types, keys and values match ignoring case and surrounding whitespace; nothing else is merged." + + (folds.length ? " Folded here: " + folds.map((e) => e.variants.join(" = ")).join("; ") + "." : ""), + params: t.events.map((e) => [e.label, e.isInterval ? "has durations: a state the patient is in during a bin" : "no durations: an event that happened in a bin"] as [string, string]) }); + steps.push({ kind: "transform", title: t.anchor ? "Align on " + t.anchor : "Align on each study's day 0", + detail: (t.anchor ? "Day 0 is each patient's " + t.anchor + "; a patient without one is in no bin." + : "Day 0 is the study's own baseline, which is not comparable across studies.") + + " A patient is observable from their first recorded event to their last contact — the later of their last recorded event and their OS follow-up (OS_MONTHS × 30.4375 days, assuming it starts at the timeline's day 0).", + params: t.cohorts.map((c) => [c.name, c.patients.toLocaleString() + " in the cohort → " + c.anchored.toLocaleString() + (t.anchor ? " with the anchor event" : " aligned") + + " → " + c.observed.toLocaleString() + " observable in the window"] as [string, string]) }); + steps.push({ kind: "statistic", title: "Share per bin", + detail: "For each bin from month " + t.fromBin * t.binMonths + " to " + (t.toBin + 1) * t.binMonths + " (half-open [t, t+" + t.binMonths + ")): patients in the state ÷ patients observable, as a percentage. The figure stops each line where fewer than " + + t.riskFloor + " patients remain observable." }); + const python = [ + "# Recompute each bin's percentage from the counts.", + "import io", + "import pandas as pd", + "", + RESULT_INIT, + ...pyTable("counts", "Per cohort (summed over its studies) and bin: patients observable (event \"\") and in each event's state", ["cohort", "bin", "event", "n"], + t.counts.map((r) => [r.cohort, r.bin, r.event, r.n]), "course_counts.csv"), + "counts[\"event\"] = counts[\"event\"].fillna(\"\")", + "n = counts.set_index([\"cohort\", \"event\", \"bin\"])[\"n\"]", + "events = " + JSON.stringify(Object.fromEntries(t.events.map((e) => [e.canonical, e.label]))) + " # folded name: label", + "for cohort in " + JSON.stringify(t.cohorts.map((c) => c.name)) + ":", + " for event, label in events.items():", + " for b in range(" + t.fromBin + ", " + (t.toBin + 1) + "):", + " at_risk, k = n.get((cohort, \"\", b), 0), n.get((cohort, event, b), 0)", + " pct = 100 * k / at_risk if at_risk else None", + " RESULT[f\"course:{cohort}:{label}:{b}:pct\"], RESULT[f\"course:{cohort}:{label}:{b}:at_risk\"] = pct, at_risk", + " print(cohort, label, [round(RESULT[f'course:{cohort}:{label}:{b}:pct'], 1) if RESULT[f'course:{cohort}:{label}:{b}:pct'] is not None else None", + " for b in range(" + t.fromBin + ", " + (t.toBin + 1) + ")])", + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: [], file: "lib/views/course/run.ts", commit: BUILD_COMMIT }, + python: withResult(python), + // percentages are reported to 1 decimal + check: checks("course:", t.results, (r) => r.cohort + ":" + r.event + ":" + r.bin, + [["pct", "pct", { abs: 0.05 + 1e-9 }], ["atRisk", "at_risk", { abs: 0 }]]), + }; +} diff --git a/lib/views/course.ts b/lib/views/course/run.ts similarity index 96% rename from lib/views/course.ts rename to lib/views/course/run.ts index 2f70fd134a7e22b909d3256f0b50a8870d50b7bf..f61b66f8fab31fd980e121bac4d14a2893788e44 100644 GIT binary patch delta 94 zcmaDCHaC32*@>q#c@%0D(u(qP6_oV!^d~DbR!*MD7&7@4qwHi8ChN(0OzP5l`o$TE yMX4!zCB;hCT$#BA`9&oPC6xuK3e^+87_jIAnUlXUs t.scope ? { ...s, scope: t.scope + " · " + s.scope } : s)); + const rows = t.perStudy.reduce((n, s) => n + s.rows, 0); + if (o.cutoffMonths != null) { + steps.push({ kind: "transform", title: "Censor at " + o.cutoffMonths + " months", + detail: plural(t.censoredAtCutoff, "patient") + " followed past " + o.cutoffMonths + " months had their time set to " + + o.cutoffMonths + " and their event to censored.", + ...(t.scope ? { scope: t.scope } : {}), rowsIn: rows, rowsOut: rows }); + } + if (t.droppedBadTime > 0) { + steps.push({ kind: "transform", title: "Drop invalid times", + detail: plural(t.droppedBadTime, "patient") + " with a negative or non-numeric time were dropped.", + ...(t.scope ? { scope: t.scope } : {}), rowsIn: rows, rowsOut: rows - t.droppedBadTime }); + } + steps.push(codingStep(t)); + steps.push({ + kind: "statistic", + title: "Fit a Cox proportional-hazards model", + detail: "Maximum partial likelihood with Efron's method for tied event times, by Newton–Raphson with step-halving.", + ...(t.scope ? { scope: t.scope } : {}), + params: [ + ["patients / events", t.fit.n.toLocaleString() + " / " + t.fit.events.toLocaleString()], + ["terms", String(t.columns.length)], + ["baseline hazard", o.stratifyByStudy ? "one per study (stratified by study)" : "one, shared by every patient"], + ["standard errors", "from the observed information at the estimate"], + ["95% interval", "exp(β ± 1.959964·SE), Wald"], + ["p-value", "Wald χ² on 1 df, (β/SE)²"], + ["fit", t.fit.iterations < COX_MAX_ITERATIONS ? "converged in " + plural(t.fit.iterations, "iteration") + : "stopped at the " + COX_MAX_ITERATIONS + "-iteration limit without converging"], + ["log partial likelihood", t.fit.loglik.toFixed(4)], + ], + }); + } + return { + steps, + implementation: { functions: ["coxRegression"], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "Matches " + VERIFIED.cox.library + " " + VERIFIED.cox.version + " " + VERIFIED.cox.fn + + " (" + VERIFIED.cox.ties + " ties) to a relative 1e-6 on coefficients, SEs, p-values and intervals (" + GOLDEN_TEST + ").", + python: withResult(coxPython(traces, o)), + // lifelines can stop ~1e-5 short of the optimum on a term with only a handful of patients + check: Object.assign({}, ...traces.map((t) => checks("cox:" + (t.scope || "") + ":", t.coefficients, (c) => c.label, + [["beta", "beta", { rel: 1e-4, abs: 1e-6 }], ["se", "se", { rel: 1e-4 }], ["p", "p", { rel: 1e-3, abs: 1e-12 }]]))), + }; +} + +function coxPython(traces: CoxTrace[], o: CoxMethodOptions): string { + const multiStudy = traces.some((t) => t.perStudy.length > 1); + const lines = [ + "# Refit with lifelines (" + VERIFIED.cox.version + " is the version our fit is tested against).", + "# Input: the rows the " + o.queryLabel + " query above returns — columns m (months), ev (0/1), c0, c1, …", + multiStudy || o.stratifyByStudy + ? "# It runs once per study: concatenate the results and add a \"study\" column." + : "# Save them as CSV.", + "import pandas as pd", + "from lifelines import CoxPHFitter", + "", + RESULT_INIT, + ...(traces.some((t) => t.scope) ? ["fits = {} # one independent model per cohort", ""] : []), + ]; + for (const t of traces) { + if (t.scope) lines.push("# --- cohort " + py(t.scope) + ": its own model, own coding ---"); + lines.push("df = pd.read_csv(" + py(pyFileName("cox_rows", t.scope)) + pyCategoricalDtypes(t) + ")"); + if (o.cutoffMonths != null) { + lines.push("df.loc[df[\"m\"] > " + o.cutoffMonths + ", \"ev\"] = 0 # censor at " + o.cutoffMonths + " months", + "df[\"m\"] = df[\"m\"].clip(upper=" + o.cutoffMonths + ")"); + } + lines.push("df = df[df[\"m\"] >= 0]", + "X = pd.DataFrame({", + " \"m\": df[\"m\"], \"ev\": df[\"ev\"],", + pyTerms(t, " "), + "})"); + if (o.stratifyByStudy) lines.push("X[\"study\"] = df[\"study\"]"); + lines.push("cph = CoxPHFitter() # Efron ties and no penalty: the same model", + "# a tighter tolerance than lifelines' default (1e-7), which can stop about 1e-6 short of the optimum", + "cph.fit(X, duration_col=\"m\", event_col=\"ev\"" + (o.stratifyByStudy ? ", strata=[\"study\"]" : "") + + ", fit_options={\"precision\": 1e-10})", + "cph.print_summary() # coef, se(coef), p and the exp(coef) 95% bounds are the forest plot's numbers", + ...(t.scope ? ["fits[" + py(t.scope) + "] = cph"] : []), + "for term, row in cph.summary.iterrows():", + " k = " + py("cox:" + (t.scope || "") + ":") + " + term", + " RESULT[k + \":beta\"], RESULT[k + \":se\"], RESULT[k + \":p\"] = row[\"coef\"], row[\"se(coef)\"], row[\"p\"]", + ""); + } + return lines.join("\n").trimEnd() + "\n"; +} + +export interface CoxMethodOptions { + endpoint: string; + monthsColumn: string; + statusColumn: string; + cutoffMonths?: number; + stratifyByStudy: boolean; + queryLabel: string; +} diff --git a/lib/views/cox.ts b/lib/views/cox/run.ts similarity index 95% rename from lib/views/cox.ts rename to lib/views/cox/run.ts index 9da7f7d..f0c107f 100644 --- a/lib/views/cox.ts +++ b/lib/views/cox/run.ts @@ -1,14 +1,16 @@ // Cox view: a multivariate proportional-hazards fit from per-patient rows inside each local // study (patient-level participants only). Covariates are coded by ./covariates.ts. -import { coxRegression, type CoxRow } from "../stats.ts"; -import type { ViewResult, CoxView, CoxCoefficient } from "../types.ts"; -import { isPatientLevel, type EndpointColumns } from "../federation.ts"; -import { validateAxis, type AxisKind } from "../plot.ts"; -import { coxMethod, type CoxTrace } from "../method.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { overLimit, studyCatalogs, ATTR_NAME } from "./shared.ts"; -import { buildCoxColumns, notProfiledNote } from "./covariates.ts"; -import { resolveProfileAxis } from "../plot-profile.ts"; +import { coxRegression, type CoxRow } from "../../stats.ts"; +import type { ViewResult } from "../../types.ts"; +import { isPatientLevel, type EndpointColumns } from "../../federation.ts"; +import { validateAxis, type AxisKind } from "../../plot.ts"; +import { type CoxTrace } from "../../method.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { overLimit, studyCatalogs, ATTR_NAME } from "../shared.ts"; +import { buildCoxColumns, notProfiledNote } from "../covariates.ts"; +import { resolveProfileAxis } from "../../plot-profile.ts"; +import type { CoxView, CoxCoefficient } from "./types.ts"; +import { coxMethod } from "./method.ts"; export async function runCox(view: DocView, c: ViewRun): Promise { const { doc, cohorts, resolvers, vNames, vStudies, participationFor } = c; @@ -21,7 +23,7 @@ export async function runCox(view: DocView, c: ViewRun): Promise_MONTHS/_STATUS */ + endpoint?: string; + /** same escape hatch as SurvivalView.monthsAttribute/statusAttribute, for the same reason */ + monthsAttribute?: string; statusAttribute?: string; + /** one or more covariates to fit simultaneously; each becomes one or more forest-plot rows */ + covariates: CoxCovariate[]; + /** fit separate baseline hazards per study rather than pooling them (default false) */ + stratifyByStudy?: boolean; + /** + * Fit each named cohort as its own separate Cox model — its own coefficients, its own chosen + * reference levels, its own N/events — instead of pooling every cohort's patients into one + * fit (default false). Unlike `stratifyByStudy` (one shared model, a per-study baseline + * hazard via Cox's `stratum`), this produces N independent models: the right tool when the + * covariate EFFECT itself, not just the baseline hazard, might differ between cohorts (e.g. + * a gene's hazard ratio in NSCLC vs in CRC). A no-op when only one cohort is named. + */ + stratifyByCohort?: boolean; + /** administratively censor everyone still at risk past this month, same semantics as SurvivalView */ + cutoffMonths?: number; +} +export interface CoxCoefficient { + /** covariate name, or ": " for a categorical level */ + label: string; + /** the baseline level this coefficient is measured against, for a categorical covariate */ + reference?: string; + /** this coefficient's own level (the non-reference side), for a categorical covariate */ + level?: string; + /** which entry of the view's `covariates` array this came from — lets the UI group a + * categorical covariate's coefficients back together to offer its observed levels as a + * reference-level picker, without re-deriving them from raw rows */ + axisIndex?: number; + beta: number; se: number; hr: number; ciLow: number; ciHigh: number; p: number; +} +export interface CoxResult { + coefficients: CoxCoefficient[]; + n: number; events: number; + /** studies whose patients were left out of the fit for lacking patient-level access */ + excludedRemote?: { study: string; patients: number }[]; + /** + * Present only when the view asked for `stratifyByCohort` (and named 2+ cohorts): one + * independent Cox fit per cohort, in place of the single pooled fit above (`coefficients` + * here is then empty; `n`/`events` are the sum across cohorts, for the header only). Each + * cohort's `coefficients` chose its OWN reference levels from what IT observed, so the same + * covariate can read against a different reference in different cohorts — real information, + * not an inconsistency to paper over. + */ + byCohort?: { cohort: string; coefficients: CoxCoefficient[]; n: number; events: number; + excludedRemote?: { study: string; patients: number }[]; error?: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { cox: CoxView } + interface ViewResultFields { + cox: { + cox?: CoxResult; + /** which endpoint was fitted */ + endpoint?: string; + }; + } + interface ViewResultMain { cox: "cox" } +} diff --git a/lib/figures/ctdna.ts b/lib/views/ctdna/figure.ts similarity index 97% rename from lib/figures/ctdna.ts rename to lib/views/ctdna/figure.ts index 7da4ff2..0a2d2dd 100644 --- a/lib/figures/ctdna.ts +++ b/lib/views/ctdna/figure.ts @@ -3,9 +3,10 @@ import { esc, palette, frame, caption, linear, axisBottom, axisLeft, leftAxisWidth, bottomAxisHeight, legend, label, textWidth, wrapWidth, fmtInt, fmtShare, fmtP, fmtNum, fmtMonths, textOn, seriesStyle, timeScale, timeTitle, stepPath, paragraph, FS, SW, OTHER, SERIES, type SvgOpts -} from "../svg-kit.ts"; -import { plotHeight } from "./common.ts"; -import type { DrawnResultOf, CtdnaGene, CtdnaPanel } from "../types.ts"; +} from "../../svg-kit.ts"; +import { plotHeight } from "../../figures/common.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { CtdnaGene, CtdnaPanel } from "./types.ts"; const PAD = 8, GUT = 14, ROW = 20, BAR = 14, MIN_BAR_W = 200; diff --git a/lib/views/ctdna.ts b/lib/views/ctdna/run.ts similarity index 97% rename from lib/views/ctdna.ts rename to lib/views/ctdna/run.ts index 65bfbcc..314eb1d 100644 --- a/lib/views/ctdna.ts +++ b/lib/views/ctdna/run.ts @@ -1,12 +1,15 @@ // ctDNA view: a patient's plasma (cfDNA) samples against their tissue, gene by gene, and survival by ctDNA detected -import type { ViewResult, CtdnaView, CtdnaPanel, CtdnaGene, CtdnaChPanel, LogRank } from "../types.ts"; +import type { ViewResult, LogRank } from "../../types.ts"; type CtdnaChGene = CtdnaChPanel["genes"][number]; -import { ctdnaCountsSql, ctdnaFunnelSql, ctdnaSurvivalSql, ctdnaChRemovedSql, sampleAttributeValuesSql, type CtdnaSides } from "../federation-views.ts"; -import { kaplanMeier, logRank, wilsonInterval, type SurvRecord } from "../stats.ts"; -import { geneSymbols } from "../gene-tracks.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, plural, refuse, ATTR_NAME } from "./shared.ts"; +import { sampleAttributeValuesSql } from "../../federation-views.ts"; +import { kaplanMeier, logRank, wilsonInterval, type SurvRecord } from "../../stats.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, plural, refuse, ATTR_NAME } from "../shared.ts"; +import type { CtdnaView, CtdnaPanel, CtdnaGene, CtdnaChPanel } from "./types.ts"; +import { ctdnaCountsSql, ctdnaFunnelSql, ctdnaSurvivalSql, ctdnaChRemovedSql } from "./sql.ts"; +import type { CtdnaSides } from "./sql.ts"; const TOP_DEFAULT = 15, TOP_MAX = 30, GENES_MAX = 30; const SAMPLE_EVENTS = ["Sample acquisition", "Sequencing", "Specimen"]; diff --git a/lib/views/ctdna/sql.ts b/lib/views/ctdna/sql.ts new file mode 100644 index 0000000..898d14f --- /dev/null +++ b/lib/views/ctdna/sql.ts @@ -0,0 +1,189 @@ +// The ctdna view's SQL: what its runner (run.ts) asks each participant's database for. +import { sideCountsTail, pidOf } from "../../federation-views.ts"; +import { finiteSql } from "../../sql-number.ts"; +import { clinicalTable } from "../../custom-data.ts"; +import { sq } from "../../compiler.ts"; +import { clinicalValueFoldSql, foldedEqualsSql, foldedInSql } from "../../event-names.ts"; +import { driverJoin, ALTERED_COND } from "../../federation.ts"; + +/** Per gene, among patients with plasma and tissue both profiled for it: as pairedCountsSql (a = tissue, b = plasma). */ +export function ctdnaCountsSql(cohortSql: string, study: string, db: string, genes: string[] | null, o: CtdnaSides, + pin: string | null, measure: "driver" | "any"): string { + return ctdnaSidesCtes(cohortSql, study, db, o) + ",\n" + sideCountsTail(study, db, genes, pin, measure, chKeep(o, "ps.side")); +} + +/** + * The patients with a plasma sample, a tissue sample, both (and, with dates, how many classed + * samples were dated); and of those with plasma, how many had any alteration called in it + * (ctDNA detected: a mutation, an amplification or deep deletion, or a structural variant; with + * `driver`, an OncoKB oncogenic one). + */ +export function ctdnaFunnelSql(cohortSql: string, study: string, db: string, o: CtdnaSides, pin: string | null, measure: "driver" | "any"): string { + return ( + ctdnaSidesCtes(cohortSql, study, db, o) + ",\n" + + ctdnaDetectedCte(study, db, pin, measure, chKeep(o, "s.side")) + "\n" + + "SELECT (SELECT uniqExactIf(pid, side = 'b') FROM classed) AS plasma_all, (SELECT uniqExactIf(pid, side = 'a') FROM classed) AS tissue_all,\n" + + " (SELECT uniqExactIf(pid, side = 'b') FROM sides) AS plasma, (SELECT uniqExactIf(pid, side = 'a') FROM sides) AS tissue,\n" + + " (SELECT count() FROM paired) AS both, (SELECT count() FROM det) AS detected,\n" + + " (SELECT countIf(side = 'b') FROM ps) AS plasma_samples, (SELECT countIf(side = 'a') FROM ps) AS tissue_samples,\n" + + " " + (o.withinDays != null ? "(SELECT count() FROM dated)" : "toUInt64(0)") + " AS dated_samples" + + (o.ch ? ",\n (SELECT count() FROM chx) AS ch_calls, (SELECT countIf(has_normal) FROM chx) AS ch_with_normal,\n" + + " (SELECT countIf(by_gene) FROM chx) AS ch_by_gene, (SELECT countIf(by_normal) FROM chx) AS ch_by_normal, (SELECT countIf(by_gene OR by_normal) FROM chx) AS ch_removed,\n" + + " (SELECT uniqExact(s.pid) FROM chx INNER JOIN sides s ON s.suid = chx.suid WHERE by_gene OR by_normal) AS ch_patients" : "") + "\n" + + "FROM system.one" + ); +} + +/** + * Overall survival of the patients with a plasma sample, by ctDNA detected or not: counts of + * (detected, months, dead) — OS_MONTHS / OS_STATUS as the study records them. + */ +export function ctdnaSurvivalSql(cohortSql: string, study: string, db: string, o: CtdnaSides, pin: string | null, measure: "driver" | "any"): string { + const pid = pidOf("patient_unique_id", study); + return ( + ctdnaSidesCtes(cohortSql, study, db, o) + ",\n" + + ctdnaDetectedCte(study, db, pin, measure, chKeep(o, "s.side")) + ",\n" + + "os AS (\n" + + " SELECT " + pid + " AS pid, " + finiteSql("anyIf(attribute_value, attribute_name = 'OS_MONTHS')") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = 'OS_STATUS') LIKE '1%' AS dead, " + pid + " IN (SELECT pid FROM det) AS detected\n" + + " FROM " + clinicalTable(db, ["OS_MONTHS", "OS_STATUS"]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN ('OS_MONTHS', 'OS_STATUS')\n" + + " AND " + pid + " IN (SELECT pid FROM sides WHERE side = 'b')\n" + + " GROUP BY pid\n" + + ")\n" + + "SELECT detected, round(months, 2) AS t, dead, count() AS n\n" + + "FROM os WHERE months IS NOT NULL AND months >= 0 GROUP BY detected, t, dead ORDER BY t" + ); +} + +/** with a CH rule: per gene, the plasma mutation calls it removes and their patients, by which rule */ +export function ctdnaChRemovedSql(cohortSql: string, study: string, db: string, o: CtdnaSides): string { + if (!o.ch) throw new Error("ctdnaChRemovedSql needs a CH rule"); + return ( + ctdnaSidesCtes(cohortSql, study, db, o) + "\n" + + "SELECT chx.gene AS gene, countIf(by_gene OR by_normal) AS calls, uniqExactIf(s.pid, by_gene OR by_normal) AS patients,\n" + + " countIf(by_gene) AS gene_calls, countIf(by_normal) AS normal_calls\n" + + "FROM chx INNER JOIN sides s ON s.suid = chx.suid\n" + + "GROUP BY gene HAVING calls > 0 ORDER BY calls DESC, gene LIMIT 40" + ); +} + +// --------------------------------------------------------------------------- +// ctdna: a patient's plasma (cfDNA) samples against their tissue samples + +export interface CtdnaSides { + /** the sample attribute that classes samples (SAMPLE_CLASS), its plasma values and its tissue values */ + attribute: string; plasma: string[]; tissue: string[]; + /** samples on these panels are plasma whatever the attribute says (e.g. ACCESS129) */ + plasmaPanels: string[]; + /** keep only samples with a sample of the other kind dated within this many days (timeline dates); null: no dates needed */ + withinDays: number | null; + sampleEvents: string[]; sampleKey: string; + /** + * leave out plasma mutation calls likely from clonal haematopoiesis: those in `genes`, and (with + * `normalMinAlt`) those whose matched normal (white-cell) sample carries at least that many + * alternate reads at an allele fraction at least `normalMinRatio` of the plasma's; absent, every call counts + */ + ch?: { genes: string[]; normalMinAlt: number | null; normalMinRatio: number }; +} + +/** + * The cohort's samples classed plasma ('b') or tissue ('a'), as a `ps` CTE of the patients with + * both; `sides` keeps every classed sample, for questions about plasma alone. With `withinDays`, + * a sample is kept only when the timeline dates it and a sample of the other kind within that + * many days (`dated` and `near` say how many). + */ +function ctdnaSidesCtes(cohortSql: string, study: string, db: string, o: CtdnaSides): string { + const fold = (x: string) => clinicalValueFoldSql(x); + const lit = (vs: string[]) => vs.map((v) => fold(sq(v))).join(","); + const dated = o.withinDays != null; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "cls AS (\n" + + " SELECT sample_unique_id AS suid, any(" + fold("attribute_value") + ") AS v\n" + + " FROM " + clinicalTable(db, [o.attribute]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(o.attribute) + "\n" + + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY suid\n" + + "),\n" + + "classed AS (\n" + + " SELECT sd.sample_unique_id AS suid, sd.patient_stable_id AS pid, sd.sample_stable_id AS sample,\n" + + " multiIf(" + (o.plasmaPanels.length ? "sd.sample_unique_id IN (SELECT sample_unique_id FROM " + db + ".sample_to_gene_panel_derived WHERE cancer_study_identifier = " + sq(study) + + " AND gene_panel_id IN (" + o.plasmaPanels.map(sq).join(",") + ")), 'b', " : "") + + "cls.v IN (" + lit(o.plasma) + "), 'b', cls.v IN (" + lit(o.tissue) + "), 'a', '') AS side\n" + + " FROM " + db + ".sample_derived sd LEFT JOIN cls ON cls.suid = sd.sample_unique_id\n" + + " WHERE sd.cancer_study_identifier = " + sq(study) + " AND sd.patient_stable_id IN (SELECT pid FROM cohort)\n" + + "),\n" + + (dated + ? "tl AS (\n" + + " SELECT p.stable_id AS pid, d.value AS sample, min(ce.start_date) AS day\n" + + " FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("d.key", o.sampleKey) + "\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedInSql("ce.event_type", o.sampleEvents) + "\n" + + " AND p.stable_id IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid, sample\n" + + "),\n" + + "dated AS (SELECT c.suid AS suid, c.pid AS pid, c.side AS side, tl.day AS day FROM classed c INNER JOIN tl ON tl.pid = c.pid AND tl.sample = c.sample WHERE c.side != ''),\n" + + "near AS (\n" + + " SELECT DISTINCT x.suid AS suid FROM dated x INNER JOIN dated y ON y.pid = x.pid\n" + + " WHERE x.side != y.side AND abs(x.day - y.day) <= " + Math.floor(o.withinDays!) + "\n" + + "),\n" + + "sides AS (SELECT suid, pid, side FROM classed WHERE side != '' AND suid IN (SELECT suid FROM near)),\n" + : "sides AS (SELECT suid, pid, side FROM classed WHERE side != ''),\n") + + "paired AS (SELECT pid FROM sides GROUP BY pid HAVING countIf(side = 'a') > 0 AND countIf(side = 'b') > 0),\n" + + "ps AS (SELECT suid, pid, side FROM sides WHERE pid IN (SELECT pid FROM paired))" + + (o.ch ? ",\n" + ctdnaChCte(study, db, o.ch) : "") + ); +} + +/** + * `chx`: the plasma samples' mutation calls a clonal-haematopoiesis rule removes — by gene, or by + * alternate reads in the matched normal (mutation_derived's normal counts, where the study loads them). + */ +function ctdnaChCte(study: string, db: string, ch: NonNullable): string { + const byGene = ch.genes.length ? "g.hugo_gene_symbol IN (" + ch.genes.map(sq).join(",") + ")" : "0"; + const normal = ch.normalMinAlt != null; + return ( + (normal + ? "nm AS (\n" + + " SELECT concat(studyId, '_', sampleId) AS suid, `GENE.hugoGeneSymbol` AS gene, proteinChange AS mv,\n" + + " max(normalAltCount >= " + Math.floor(ch.normalMinAlt!) + " AND normalAltCount / (normalAltCount + normalRefCount) >= " + Number(ch.normalMinRatio) + + " * tumorAltCount / greatest(1, tumorAltCount + tumorRefCount)) AS hit, max(normalAltCount IS NOT NULL) AS loaded\n" + + " FROM " + db + ".mutation_derived\n" + + " WHERE studyId = " + sq(study) + " AND concat(studyId, '_', sampleId) IN (SELECT suid FROM sides WHERE side = 'b')\n" + + " GROUP BY suid, gene, mv\n" + + "),\n" + : "") + + "chx AS (\n" + + " SELECT g.sample_unique_id AS suid, g.hugo_gene_symbol AS gene, g.mutation_variant AS mv,\n" + + " max(" + byGene + ") AS by_gene, " + (normal ? "max(ifNull(nm.hit, 0) = 1)" : "toUInt8(0)") + " AS by_normal,\n" + + " " + (normal ? "max(ifNull(nm.loaded, 0) = 1)" : "toUInt8(0)") + " AS has_normal\n" + + " FROM " + db + ".genomic_event_derived g\n" + + (normal ? " LEFT JOIN nm ON nm.suid = g.sample_unique_id AND nm.gene = g.hugo_gene_symbol AND nm.mv = g.mutation_variant\n" : "") + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation'\n" + + " AND g.sample_unique_id IN (SELECT suid FROM sides WHERE side = 'b')\n" + + " GROUP BY suid, gene, mv\n" + + ")" + ); +} + +/** with a CH rule, the condition that keeps an event: not a plasma mutation call `chx` removes (`side` is the side column) */ +const chKeep = (o: CtdnaSides, side: string): string | undefined => o.ch + ? "NOT (" + side + " = 'b' AND g.variant_type = 'mutation' AND (g.sample_unique_id, g.hugo_gene_symbol, g.mutation_variant) IN (SELECT suid, gene, mv FROM chx WHERE by_gene OR by_normal))" + : undefined; + +/** the patients with any (driver) alteration called in a plasma sample */ +function ctdnaDetectedCte(study: string, db: string, pin: string | null, measure: "driver" | "any", exclude?: string): string { + return ( + "det AS (\n" + + " SELECT DISTINCT s.pid AS pid FROM " + db + ".genomic_event_derived g\n" + + (pin && measure === "driver" ? driverJoin(pin) : "") + + " INNER JOIN sides s ON s.suid = g.sample_unique_id\n" + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND s.side = 'b' AND " + ALTERED_COND + "\n" + + " AND g.sample_unique_id IN (SELECT suid FROM sides WHERE side = 'b')" + (pin && measure === "driver" ? " AND ann.is_driver" : measure === "driver" ? " AND 0" : "") + + (exclude ? " AND " + exclude : "") + "\n" + + ")" + ); +} diff --git a/lib/views/ctdna/types.ts b/lib/views/ctdna/types.ts new file mode 100644 index 0000000..0c7f7e7 --- /dev/null +++ b/lib/views/ctdna/types.ts @@ -0,0 +1,89 @@ +// The ctdna view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { KmCurve, LogRank } from "../../types.ts"; + +/** what the clonal-haematopoiesis filter removed from a cohort's plasma calls, and the headline numbers without it */ +export interface CtdnaChPanel { + /** plasma mutation calls, those with matched-normal counts loaded, removed by gene / by the normal / by either, and the patients with a removed call */ + calls: number; withNormal: number; byGene: number; byNormal: number; removed: number; patients: number; + /** per gene, the calls and patients removed */ + genes: { gene: string; calls: number; patients: number; byGene: number; byNormal: number }[]; + /** without the filter */ + before: { + detected: number; detectedLow: number | null; detectedHigh: number | null; + genes: { gene: string; both: number; plasmaOnly: number; ppa: number | null }[]; + survival: { curves: { label: string; n: number; events: number; median: number | null }[]; p: number | null } | null; + }; +} +/** + * ctDNA against tissue: patients with a plasma (cfDNA) sample and a tissue sample, per gene covered + * by both, altered in both, tissue only, plasma only or neither; plasma's positive percent + * agreement with tissue as the reference; the share of plasma patients with any alteration + * detected; and overall survival by ctDNA detected or not. + */ +export interface CtdnaView { + note?: string; + kind: "ctdna"; cohorts?: string[]; + /** the genes (at most 30); omitted, the `top` (default 15, at most 30) most altered in the paired patients */ + genes?: string[]; top?: number; + /** the sample attribute classing samples (default SAMPLE_CLASS) and its plasma (default cfDNA, ctDNA) and tissue (default Tumor) values */ + attribute?: string; plasma?: string[]; tissue?: string[]; + /** samples on these panels count as plasma whatever the attribute says (e.g. ["ACCESS129"]) */ + plasmaPanels?: string[]; + /** keep only samples the timeline dates within this many days of a sample of the other kind (default: no dates needed) */ + withinDays?: number; + sampleEvents?: string[]; sampleKey?: string; + /** any alteration (default) or OncoKB oncogenic only */ + measure?: "driver" | "any"; + /** + * leave out plasma mutation calls likely from clonal haematopoiesis: true for the defaults, or + * `genes` (default the canonical CH genes; [] for none) and `normal` (default true: calls whose + * matched white-cell sample has at least `normalMinAlt`, default 2, alternate reads at an allele + * fraction at least `normalMinRatio`, default 0.3, of the plasma's) + */ + chFilter?: boolean | { genes?: string[]; normal?: boolean; normalMinAlt?: number; normalMinRatio?: number }; +} +export interface CtdnaGene { + gene: string; + /** patients whose plasma and tissue samples both cover the gene */ + pairs: number; both: number; tissueOnly: number; plasmaOnly: number; neither: number; + /** positive percent agreement: both ÷ altered in tissue, with its 95% Wilson interval (null when tissue has none) */ + ppa: number | null; ppaLow: number | null; ppaHigh: number | null; + /** (both + neither) ÷ pairs */ + concordance: number | null; +} +export interface CtdnaPanel { + cohort: string; + /** patients with a plasma sample, a tissue sample, both; samples of each kind among those with both */ + plasmaPatients: number; tissuePatients: number; patients: number; plasmaSamples: number; tissueSamples: number; + /** with withinDays: before the date rule, patients with plasma and tissue; and the classed samples the timeline dated */ + undated?: { plasmaPatients: number; tissuePatients: number; datedSamples: number }; + /** of the plasma patients, those with any (driver) alteration called in plasma, with a 95% Wilson interval */ + detected: number; detectedLow: number | null; detectedHigh: number | null; + genes: CtdnaGene[]; + /** overall survival of the plasma patients by ctDNA detected or not; null with why when not drawn */ + survival: { curves: (Omit & { label: string })[]; logrank: LogRank | null; missing: number } | null; + survivalWhy?: string; + /** with chFilter */ + ch?: CtdnaChPanel; +} +export interface CtdnaResult { + attribute: string; plasma: string[]; tissue: string[]; plasmaPanels: string[]; + withinDays: number | null; + measure: "driver" | "any"; + /** present when the genes were chosen as the most altered */ + top?: number; + panels: CtdnaPanel[]; + excluded?: { study: string; reason: string }[]; + /** with chFilter: the rule applied */ + chFilter?: { genes: string[]; normalMinAlt: number | null; normalMinRatio: number }; +} + +declare module "../../types.ts" { + interface ViewSpecs { ctdna: CtdnaView } + interface ViewResultFields { + ctdna: { + ctdna?: CtdnaResult; + }; + } + interface ViewResultMain { ctdna: "ctdna" } +} diff --git a/lib/figures/cutpoint.ts b/lib/views/cutpoint/figure.ts similarity index 97% rename from lib/figures/cutpoint.ts rename to lib/views/cutpoint/figure.ts index 2bbdc8a..837ac89 100644 --- a/lib/figures/cutpoint.ts +++ b/lib/views/cutpoint/figure.ts @@ -1,11 +1,11 @@ // Cut-point figure: the scan's statistic against each candidate cut (the chosen one marked, the // permutation 95% line dashed), the two groups' Kaplan–Meier curves at it, and per-stratum cuts. -import type { DrawnResultOf } from "../types.ts"; +import type { DrawnResultOf } from "../../types.ts"; import { palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, label, textWidth, fmtInt, fmtNum, fmtP, fmtRatio, fmtMonths, paragraph, SERIES, FS, SW, r1, type SvgOpts -} from "../svg-kit.ts"; -import { kmPanel } from "./km.ts"; +} from "../../svg-kit.ts"; +import { kmPanel } from "../../figures/km.ts"; const medianText = (m: number | null) => m == null ? "not reached" : fmtMonths(m); diff --git a/lib/views/cutpoint.ts b/lib/views/cutpoint/run.ts similarity index 97% rename from lib/views/cutpoint.ts rename to lib/views/cutpoint/run.ts index 0a28165..2528e40 100644 --- a/lib/views/cutpoint.ts +++ b/lib/views/cutpoint/run.ts @@ -1,12 +1,13 @@ // Cut-point view: the value of a numeric marker that best splits a cohort's survival, found by a // scan of candidate cuts, with the maximum's p corrected for the scan by permutation. Fitted from // per-patient (marker, time, event) rows inside each local patient-level study. -import type { ViewResult, CutpointView, CutpointScan, CutpointResult } from "../types.ts"; -import { hasViewAggregates, isPatientLevel } from "../federation.ts"; -import { cutpointRowsSql } from "../federation-views.ts"; -import { kaplanMeier, logRank, coxRegression, seededRandom } from "../stats.ts"; -import type { ViewRun } from "./run.ts"; -import { refuse, plural, ATTR_NAME } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { hasViewAggregates, isPatientLevel } from "../../federation.ts"; +import { kaplanMeier, logRank, coxRegression, seededRandom } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { refuse, plural, ATTR_NAME } from "../shared.ts"; +import type { CutpointView, CutpointScan, CutpointResult } from "./types.ts"; +import { cutpointRowsSql } from "./sql.ts"; /** cuts are scanned between these quantiles of the marker, at most CUT_MAX_CANDIDATES of them */ export const CUT_LOW_Q = 0.1, CUT_HIGH_Q = 0.9, CUT_MAX_CANDIDATES = 100; diff --git a/lib/views/cutpoint/sql.ts b/lib/views/cutpoint/sql.ts new file mode 100644 index 0000000..5203a00 --- /dev/null +++ b/lib/views/cutpoint/sql.ts @@ -0,0 +1,44 @@ +// The cutpoint view's SQL: what its runner (run.ts) asks each participant's database for. +// cutpoint: a numeric marker against a survival endpoint, per patient +import { pidOf } from "../../federation-views.ts"; +import { finiteSql } from "../../sql-number.ts"; +import { sq } from "../../compiler.ts"; +import { clinicalTable } from "../../custom-data.ts"; + +/** + * One row per cohort patient with a survival time: months `m`, event `ev`, the marker `v` — the + * patient's value, or for a sample attribute the value of the patient's first sample by sample id + * — and, with `stratum`, that attribute's value read the same way as `s`. Values only, in no + * particular order: the patient id joins the CTEs and is never returned. Run only through + * PatientLevelResolver.viewRows, since a permutation test needs each patient's pair. + */ +export function cutpointRowsSql(cohortSql: string, study: string, db: string, + o: { attribute: string; months: string; status: string; stratum?: string | null }): string { + for (const a of [o.attribute, o.stratum, o.months, o.status]) if (a && !/^[A-Za-z0-9_:/.-]{1,128}$/.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); + const pid = pidOf("patient_unique_id", study); + const attrs = [o.attribute, ...(o.stratum ? [o.stratum] : [])]; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "os AS (\n" + + " SELECT " + pid + " AS pid,\n" + + " " + finiteSql("anyIf(attribute_value, attribute_name = " + sq(o.months) + ")") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = " + sq(o.status) + ") AS status\n" + + " FROM " + clinicalTable(db, [o.months, o.status]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN (" + sq(o.months) + ", " + sq(o.status) + ")\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "val AS (\n" + + " SELECT " + pid + " AS pid,\n" + + " " + finiteSql("argMinIf(attribute_value, sample_unique_id, attribute_name = " + sq(o.attribute) + ")") + " AS v" + + (o.stratum ? ",\n argMinIf(trimBoth(attribute_value), sample_unique_id, attribute_name = " + sq(o.stratum) + ") AS s" : "") + "\n" + + " FROM " + clinicalTable(db, attrs) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + attrs.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")\n" + + "SELECT os.months AS m, if(os.status LIKE '1%', 1, 0) AS ev, val.v AS v" + (o.stratum ? ", val.s AS s" : "") + "\n" + + "FROM os LEFT JOIN val ON val.pid = os.pid\n" + + "WHERE os.months IS NOT NULL AND os.months >= 0" + ); +} diff --git a/lib/views/cutpoint/types.ts b/lib/views/cutpoint/types.ts new file mode 100644 index 0000000..72bdb83 --- /dev/null +++ b/lib/views/cutpoint/types.ts @@ -0,0 +1,55 @@ +// The cutpoint view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { KmCurve } from "../../types.ts"; + +export interface CutpointView { + note?: string; + kind: "cutpoint"; cohorts?: string[]; + /** the numeric marker (default TMB_NONSYNONYMOUS): the patient's value, or a sample attribute's value on the patient's first sample */ + attribute?: string; + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** permutations of the marker for the corrected p (default 1000, 100 to 5000), and their seed (default 1) */ + permutations?: number; seed?: number; + /** a categorical attribute: a cut-point per stratum (its largest values, at most 8, each with at least minStratum patients) */ + stratifyBy?: string; minStratum?: number; +} +export interface CutpointScan { + /** patients with a survival time and a marker value, and their events */ + n: number; events: number; + /** the chosen cut: high is marker > cut */ + cut: number; + /** the share of patients at or below it */ + lowShare: number; + /** the maximally selected standardized log-rank statistic (z², chi-squared scale) at the cut, and its permutation-corrected p */ + maxStat: number; permP: number; + /** the ordinary log-rank test of the two groups at the cut, as if the cut had been fixed in advance */ + naiveChi2: number; naiveP: number; + /** the 95th percentile of the permutation distribution of the maximum: the statistic a cut must pass to be significant at 0.05 */ + critical95: number; + /** high against low, Cox */ + hr: { hr: number; ciLow: number; ciHigh: number; p: number } | null; + low: { n: number; events: number; median: number | null }; high: { n: number; events: number; median: number | null }; + /** candidate cuts scanned and the statistic at each */ + candidates: { cut: number; stat: number }[]; +} +export interface CutpointResult extends CutpointScan { + attribute: string; endpoint: string; permutations: number; seed: number; + /** cohort patients with a survival time but no marker value */ + noValue: number; + curves: { low: Omit; high: Omit }; + strata?: { + attribute: string; minStratum: number; + rows: ({ stratum: string } & Omit)[]; + /** strata not scanned: too small, past the eighth, or no value */ + omitted: { values: number; patients: number }; + }; +} + +declare module "../../types.ts" { + interface ViewSpecs { cutpoint: CutpointView } + interface ViewResultFields { + cutpoint: { + cutpoint?: CutpointResult; + }; + } + interface ViewResultMain { cutpoint: "cutpoint" } +} diff --git a/lib/figures/diffexpr.ts b/lib/views/diffexpr/figure.ts similarity index 97% rename from lib/figures/diffexpr.ts rename to lib/views/diffexpr/figure.ts index 180a147..5246fea 100644 --- a/lib/figures/diffexpr.ts +++ b/lib/views/diffexpr/figure.ts @@ -1,12 +1,12 @@ -// Differential expression figure: a volcano of every gene (the shared one, lib/figures/enrichment.ts), +// Differential expression figure: a volcano of every gene (the shared one, lib/views/enrichment/figure.ts), // and the GSEA gene sets — their normalised scores as bars, the strongest set each way as a // running-sum curve over the ranking. -import type { ViewResultOf, DrawnResultOf } from "../types.ts"; +import type { ViewResultOf, DrawnResultOf } from "../../types.ts"; import { palette, frame, caption, label, textWidth, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, legend, fmtInt, fmtNum, fmtQ, FS, SW, SERIES, OTHER, type SvgOpts -} from "../svg-kit.ts"; -import { volcano, volcanoPlotH } from "./volcano.ts"; +} from "../../svg-kit.ts"; +import { volcano, volcanoPlotH } from "../../figures/volcano.ts"; type Pal = ReturnType; type DeResult = NonNullable["diffexpr"]>; diff --git a/lib/views/diffexpr/method.ts b/lib/views/diffexpr/method.ts new file mode 100644 index 0000000..dfcb4e8 --- /dev/null +++ b/lib/views/diffexpr/method.ts @@ -0,0 +1,105 @@ +// The diffexpr view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, VERIFIED, RESULT_INIT, pyTable, py, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +/** python/golden/gsea_null.py verbatim — the Python port of our seeded GSEA null; test/gsea-golden.test.ts + * holds the two to the same text and proves the port gives our p-values exactly. */ +export const GSEA_NULL_PY = "import math\n\n\ndef seed_of(s):\n \"\"\"FNV-1a over the name's UTF-16 code units: the seed each set's null is drawn from.\"\"\"\n h = 0x811C9DC5\n for unit in memoryview(s.encode(\"utf-16-le\")).cast(\"H\"):\n h = ((h ^ unit) * 0x01000193) & 0xFFFFFFFF\n return h\n\n\ndef mulberry32(seed):\n a = seed & 0xFFFFFFFF\n\n def rnd():\n nonlocal a\n a = (a + 0x6D2B79F5) & 0xFFFFFFFF\n t = a\n t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF\n t ^= (t + ((t ^ (t >> 7)) * (t | 61) & 0xFFFFFFFF)) & 0xFFFFFFFF\n return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296\n\n return rnd\n\n\ndef enrichment_score(positions, weights, n):\n \"\"\"Weighted (p = 1) running-sum ES from the members' 0-based ranks, ascending.\"\"\"\n k = len(positions)\n if not k or k >= n:\n return 0.0\n nr = sum(weights)\n unweighted = not nr > 0\n if unweighted:\n nr = k\n miss = 1 / (n - k)\n cum, best = 0.0, 0.0\n for i, (pos, w) in enumerate(zip(positions, weights)):\n before = cum / nr - (pos - i) * miss\n if abs(before) > abs(best):\n best = before\n cum += 1 if unweighted else w\n after = cum / nr - (pos + 1 - (i + 1)) * miss\n if abs(after) > abs(best):\n best = after\n return best\n\n\ndef preranked_gsea(ranked, sets, permutations=1000, min_size=10):\n \"\"\"ranked: [(gene, score)] best first; sets: {name: [genes]}. Returns {name: (size, es, nes, p)}.\n The null for each set is `permutations` random sets of the same size drawn from the same ranking\n (Floyd's sampling), from a generator seeded by the set's name, so p is the same on every run.\"\"\"\n n = len(ranked)\n rank_of = {}\n for i, (g, _) in enumerate(ranked):\n rank_of.setdefault(g, i)\n abs_w = [abs(s) for _, s in ranked]\n out = {}\n for name, genes in sets.items():\n pos = sorted({rank_of[g] for g in set(genes) if g in rank_of})\n k = len(pos)\n if k < min_size or k >= n:\n continue\n es = enrichment_score(pos, [abs_w[i] for i in pos], n)\n rnd = mulberry32(seed_of(name))\n null_pos, null_neg = [], []\n for _ in range(permutations):\n chosen = set()\n for j in range(n - k, n):\n t = math.floor(rnd() * (j + 1))\n chosen.add(j if t in chosen else t)\n pick = sorted(chosen)\n e = enrichment_score(pick, [abs_w[i] for i in pick], n)\n (null_pos if e >= 0 else null_neg).append(e)\n mean = lambda xs: sum(xs) / len(xs) if xs else 0.0\n if es >= 0:\n nes = es / mean(null_pos) if mean(null_pos) > 0 else 0.0\n same, beyond = null_pos, sum(1 for e in null_pos if e >= es)\n else:\n nes = -es / mean(null_neg) if mean(null_neg) < 0 else 0.0\n same, beyond = null_neg, sum(1 for e in null_neg if e <= es)\n out[name] = (k, es, nes, (beyond + 1) / (len(same) + 1))\n return out\n"; + +export interface DiffexprTrace { + profileType: string; + log2: boolean; + groups: [string, string]; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + /** per cohort, summed over its studies: each gene's n, Σv and Σv² */ + moments: { cohort: string; gene: string; n: number; sx: number; sxx: number }[]; + minN: number; + tested: number; + skipped: number; + gsea?: { sets: { name: string; genes: string[] }[]; tested: number; skipped: string[]; permutations: number; minSize: number }; + /** what the view reported — genes drawn as points, and each set tested — for the script check */ + results: { genes: { gene: string; p: number; q: number }[]; sets: { name: string; es: number; nes: number; p: number }[] }; +} + +export function diffexprMethod(t: DiffexprTrace): MethodRecord { + const [A, B] = t.groups; + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Sum every gene's values", + detail: "The diffexpr:moments query above, on “" + t.profileType + "”" + (t.log2 ? " as log2(v + 1) (a negative stored value has no log and is left out, not clamped)" : " as stored") + + ". Per gene, over the cohort's patients with a value (a patient with several samples contributes their average): n, Σv and Σv². Every gene in the profile, no individual value.", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + })); + if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's sums across its studies", + detail: "The totals add exactly; there is no batch correction between studies." }); + steps.push({ kind: "transform", title: "Leave out genes that cannot be tested", + detail: "A gene with fewer than " + t.minN + " patients with a value in either cohort, or with no variance (Var A + Var B ≤ 1e-9 · (1 + mean A² + mean B²) — judged to a tolerance, since a constant gene's variance from the sums is rounding noise).", + rowsIn: t.tested + t.skipped, rowsOut: t.tested }); + steps.push({ kind: "statistic", title: "Welch's t-test per gene", + detail: "Mean A − mean B (" + A + " minus " + B + ") with each cohort's variance (Σv² − (Σv)²/n)/(n − 1); unequal-variance t, Welch–Satterthwaite df, two-sided. With thousands of patients a tiny difference is significant: read the effect too.", + params: [["genes tested", t.tested.toLocaleString()]] }); + steps.push({ kind: "statistic", title: "Benjamini–Hochberg across genes", detail: "q-values over all " + t.tested.toLocaleString() + " genes tested." }); + if (t.gsea) steps.push({ kind: "statistic", title: "Preranked gene-set enrichment (GSEA)", + detail: "Genes ranked by the unrounded t, " + A + " over " + B + " first. Enrichment score as in Subramanian et al. 2005 with weight 1 (each member's step is |t| / Σ|t| of the members). Significance from " + + t.gsea.permutations + " random sets of the same size drawn from the same ranking, from a generator seeded by the set's name — so a shared link gives the same p — with p = (b + 1)/(m + 1) over the m null scores of the same sign and NES = ES / mean of those. Gene-set permutation ignores correlation between genes, so it is anti-conservative next to phenotype permutation. A set with fewer than " + + t.gsea.minSize + " ranked members is not tested. q is Benjamini–Hochberg across the sets tested.", + params: [["sets tested", String(t.gsea.tested)], ...(t.gsea.skipped.length ? [["too small to test", t.gsea.skipped.join(", ")] as [string, string]] : [])] }); + const python = [ + "# Recompute with scipy and statsmodels; the enrichment uses the port of our seeded null below,", + "# which reproduces our permutation p-values exactly (test/gsea-golden.test.ts). Its enrichment scores match gseapy " + VERIFIED.gsea.version + " prerank.", + "import io", + "import json", + "import numpy as np", + "import pandas as pd", + "from scipy.stats import ttest_ind_from_stats", + "from statsmodels.stats.multitest import multipletests", + "", + RESULT_INIT, + ...pyTable("moments", "Each gene's n, sum and sum of squares per cohort (" + (t.log2 ? "log2(v + 1)" : "as stored") + "), from the diffexpr:moments queries, summed over each cohort's studies", + ["cohort", "gene", "n", "sx", "sxx"], t.moments.map((r) => [r.cohort, r.gene, r.n, r.sx, r.sxx]), "diffexpr_moments.csv"), + "", + "a = moments[moments[\"cohort\"] == " + py(A) + "].set_index(\"gene\")", + "b = moments[moments[\"cohort\"] == " + py(B) + "].set_index(\"gene\")", + "g = a.join(b, lsuffix=\"_a\", rsuffix=\"_b\", how=\"inner\")", + "g[\"mean_a\"], g[\"mean_b\"] = g[\"sx_a\"] / g[\"n_a\"], g[\"sx_b\"] / g[\"n_b\"]", + "g[\"var_a\"] = ((g[\"sxx_a\"] - g[\"sx_a\"] ** 2 / g[\"n_a\"]) / (g[\"n_a\"] - 1)).clip(lower=0) # a constant gene's is rounding noise, sometimes below 0", + "g[\"var_b\"] = ((g[\"sxx_b\"] - g[\"sx_b\"] ** 2 / g[\"n_b\"]) / (g[\"n_b\"] - 1)).clip(lower=0)", + "keep = (g[\"n_a\"] >= " + t.minN + ") & (g[\"n_b\"] >= " + t.minN + ") & (g[\"var_a\"] + g[\"var_b\"] > 1e-9 * (1 + g[\"mean_a\"] ** 2 + g[\"mean_b\"] ** 2))", + "g = g[keep].copy()", + "g[\"t\"], g[\"p\"] = ttest_ind_from_stats(g[\"mean_a\"], np.sqrt(g[\"var_a\"]), g[\"n_a\"], g[\"mean_b\"], np.sqrt(g[\"var_b\"]), g[\"n_b\"], equal_var=False)", + "g[\"effect\"] = g[\"mean_a\"] - g[\"mean_b\"]", + "g[\"q\"] = multipletests(g[\"p\"], method=\"fdr_bh\")[1]", + "print(len(g), \"genes tested\")", + "print(g.sort_values(\"p\")[[\"effect\", \"t\", \"p\", \"q\"]].head(20))", + "RESULT[\"diffexpr:tested\"] = len(g)", + "for gene, r in g.iterrows():", + " RESULT[f\"diffexpr:{gene}:p\"], RESULT[f\"diffexpr:{gene}:q\"] = r[\"p\"], r[\"q\"]", + ...(t.gsea ? [ + "", + "# ---- the port of our seeded GSEA null (python/golden/gsea_null.py) ----", + ...GSEA_NULL_PY.trimEnd().split("\n"), + "# ----", + "", + "sets = json.loads(" + py(JSON.stringify(Object.fromEntries(t.gsea.sets.map((x) => [x.name, x.genes])))) + ")", + "ranked = list(g.sort_values(\"t\", ascending=False, kind=\"stable\")[\"t\"].items())", + "res = preranked_gsea(ranked, sets, permutations=" + t.gsea.permutations + ", min_size=" + t.gsea.minSize + ")", + "out = pd.DataFrame([(k, *v) for k, v in res.items()], columns=[\"set\", \"size\", \"es\", \"nes\", \"p\"])", + "out[\"q\"] = multipletests(out[\"p\"], method=\"fdr_bh\")[1]", + "print(out.sort_values([\"q\", \"nes\"], key=lambda c: c if c.name == \"q\" else -c.abs()).to_string(index=False))", + "for r in out.itertuples():", + " RESULT[f\"gsea:{r.set}:es\"], RESULT[f\"gsea:{r.set}:nes\"], RESULT[f\"gsea:{r.set}:p\"] = r.es, r.nes, r.p", + ] : []), + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: ["welchT", "benjaminiHochberg", ...(t.gsea ? ["preRankedGsea"] : [])], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "Welch's t from moments matches scipy " + VERIFIED.welch.version + " ttest_ind(equal_var=False) and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/moments-golden.test.ts, test/tables-golden.test.ts)" + + (t.gsea ? "; enrichment scores match gseapy " + VERIFIED.gsea.version + " prerank, and NES and p match the script's port of our seeded null exactly (test/gsea-golden.test.ts)" : "") + ".", + python: withResult(python), + check: { + "diffexpr:tested": { v: t.tested, abs: 0 }, + ...checks("diffexpr:", t.results.genes, (g) => g.gene, [["p", "p", { rel: 1e-6, abs: 1e-300 }], ["q", "q", { rel: 1e-6, abs: 1e-300 }]]), + // ES and NES are reported to 4 and 3 decimals; the permutation p is a ratio of counts + ...checks("gsea:", t.results.sets, (x) => x.name, [["es", "es", { abs: 5e-5 + 1e-12 }], ["nes", "nes", { abs: 5e-4 + 1e-12 }], ["p", "p", { abs: 0 }]]), + }, + }; +} diff --git a/lib/views/diffexpr.ts b/lib/views/diffexpr/run.ts similarity index 97% rename from lib/views/diffexpr.ts rename to lib/views/diffexpr/run.ts index 67f92b0..39aa975 100644 --- a/lib/views/diffexpr.ts +++ b/lib/views/diffexpr/run.ts @@ -1,10 +1,11 @@ // Differential expression view: a volcano of every gene, and preranked GSEA of the same ranking -import type { ViewResult, DiffexprView, DiffexprGene, ViewResultOf } from "../types.ts"; -import { profileMomentsAllSql, genesetMembersSql } from "../federation-views.ts"; -import { benjaminiHochberg, varianceFromMoments, welchT, preRankedGsea } from "../stats.ts"; -import { diffexprMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, niceCeil, askEachSite } from "./shared.ts"; +import type { ViewResult, ViewResultOf } from "../../types.ts"; +import { benjaminiHochberg, varianceFromMoments, welchT, preRankedGsea } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, niceCeil, askEachSite } from "../shared.ts"; +import type { DiffexprView, DiffexprGene } from "./types.ts"; +import { profileMomentsAllSql, genesetMembersSql } from "./sql.ts"; +import { diffexprMethod } from "./method.ts"; const DE_MIN_N = 3, DE_POINT_CAP = 1500, DE_PERMUTATIONS = 1000; const SET_NAME = /^[A-Z0-9_]{3,120}$/; diff --git a/lib/views/diffexpr/sql.ts b/lib/views/diffexpr/sql.ts new file mode 100644 index 0000000..93f25af --- /dev/null +++ b/lib/views/diffexpr/sql.ts @@ -0,0 +1,43 @@ +// The diffexpr view's SQL: what its runner (run.ts) asks each participant's database for. +// diffexpr: every gene's moments on one molecular profile +import { pidOf } from "../../federation-views.ts"; +import { finiteSql } from "../../sql-number.ts"; +import { sq } from "../../compiler.ts"; + +/** + * Per GENE, over the cohort's patients: n, Σv and Σv² of the patient's value (the mean of their + * samples', as profileMomentsSql takes it), on the profile as stored or as log2(v + 1). Three + * additive totals per gene are all Welch's t needs, so a site sends no value at all, and every + * gene in the profile comes back — a ranking for enrichment needs the whole list, not a + * shortlist. A negative stored value under log2 has no meaning and is left out, never clamped. + */ +export function profileMomentsAllSql(cohortSql: string, study: string, db: string, profileType: string, log2: boolean): string { + const pid = pidOf("sd.patient_unique_id", study); + const raw = finiteSql("ga.alteration_value"); + const val = log2 ? "log2(" + raw + " + 1)" : raw; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "vals AS (\n" + + " SELECT " + pid + " AS pid, ga.hugo_gene_symbol AS gene, avg(" + val + ") AS v\n" + + " FROM " + db + ".genetic_alteration_derived ga\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = ga.sample_unique_id\n" + + " WHERE ga.cancer_study_identifier = " + sq(study) + " AND ga.profile_type = " + sq(profileType) + "\n" + + " AND " + raw + " IS NOT NULL AND isFinite(" + raw + ")" + (log2 ? " AND " + raw + " >= 0" : "") + "\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid, gene\n" + + ")\n" + + "SELECT gene, count() AS n, sum(v) AS sx, sum(v * v) AS sxx FROM vals GROUP BY gene" + ); +} + +/** Named MSigDB gene sets and their members' symbols (`prefix` a set-name prefix, e.g. HALLMARK_). */ +export function genesetMembersSql(names: string[], prefix: string | null, db: string): string { + const cond = names.length + ? "gs.external_id IN (" + names.map(sq).join(",") + ")" + : "startsWith(gs.external_id, " + sq(prefix || "HALLMARK_") + ")"; + return "SELECT gs.external_id AS set_name, groupUniqArray(g.hugo_gene_symbol) AS genes\n" + + "FROM " + db + ".geneset gs\n" + + "JOIN " + db + ".geneset_gene gg ON gg.geneset_id = gs.id\n" + + "JOIN " + db + ".gene g ON g.entrez_gene_id = gg.entrez_gene_id\n" + + "WHERE " + cond + " GROUP BY set_name ORDER BY set_name"; +} diff --git a/lib/views/diffexpr/types.ts b/lib/views/diffexpr/types.ts new file mode 100644 index 0000000..3611237 --- /dev/null +++ b/lib/views/diffexpr/types.ts @@ -0,0 +1,51 @@ +// The diffexpr view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Differential expression between exactly two cohorts on one molecular profile: every gene's + * Welch t-test from per-cohort moments (n, Σv, Σv² — additive, so exact across sites), drawn as + * a volcano, and preranked GSEA of that t ranking against MSigDB gene sets. + */ +export interface DiffexprView { + note?: string; + kind: "diffexpr"; cohorts?: string[]; + /** the profile to compare on (mRNA z-scores, RNA-seq, protein, methylation…) */ + profileType: string; + /** compare log2(v + 1) rather than v; defaults to true for a non-z-score RNA-seq profile */ + log2?: boolean; + /** MSigDB sets to test (≤12 names); default every HALLMARK_ set. [] skips enrichment */ + geneSets?: string[]; + /** genes labelled on the volcano (default 12, max 40) */ + label?: number; +} +export interface DiffexprGene { gene: string; nA: number; nB: number; meanA: number; meanB: number; effect: number; t: number; p: number; q: number } +export interface DiffexprResult { + profileType: string; log2: boolean; + /** what `effect` is: a difference of means on the compared scale */ + effectLabel: string; + /** cohorts in order: effect = first − second */ + groups: [string, string]; + tested: number; + /** genes left out for fewer than 3 patients in a cohort, or no variance in either */ + skipped: number; + up: number; down: number; + /** genes at q < 0.05, and the labelled ones, drawn individually (≤1,500, the most significant) */ + points: DiffexprGene[]; + /** every other tested gene, binned on the axes below: [x bin, y bin, count, 0 not significant | 1 higher in the first cohort | 2 in the second] */ + grid: { xMax: number; yMax: number; nx: number; ny: number; cells: [number, number, number, 0 | 1 | 2][] }; + /** genes q < 0.05 not drawn individually because of the cap */ + pooledSignificant: number; + labels: string[]; + gsea?: { ranking: string; permutations: number; sets: (import("../../stats.ts").GseaResult & { q: number })[]; skipped: string[] }; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { diffexpr: DiffexprView } + interface ViewResultFields { + diffexpr: { + diffexpr?: DiffexprResult; + }; + } + interface ViewResultMain { diffexpr: "diffexpr" } +} diff --git a/lib/figures/drivernegative.ts b/lib/views/drivernegative/figure.ts similarity index 96% rename from lib/figures/drivernegative.ts rename to lib/views/drivernegative/figure.ts index b7c8975..4fb22f9 100644 --- a/lib/figures/drivernegative.ts +++ b/lib/views/drivernegative/figure.ts @@ -1,8 +1,8 @@ // Driver-negative figure: the cohort split into driver-positive, driver-negative and not fully // profiled as one bar, then the volcano of every other gene between the two groups and its top genes. -import type { DrawnResultOf } from "../types.ts"; -import { volcano, volcanoPlotH, type VolcanoPoint } from "./volcano.ts"; -import { palette, frame, caption, label, textWidth, wrapWidth, seriesStyle, textOn, fmtInt, fmtPct, pDigits, FS, SW, OTHER, type SvgOpts } from "../svg-kit.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { volcano, volcanoPlotH, type VolcanoPoint } from "../../figures/volcano.ts"; +import { palette, frame, caption, label, textWidth, wrapWidth, seriesStyle, textOn, fmtInt, fmtPct, pDigits, FS, SW, OTHER, type SvgOpts } from "../../svg-kit.ts"; const PAD = 8, TABLE_ROWS = 12; const NEG = seriesStyle(0).color, POS = seriesStyle(1).color; diff --git a/lib/views/drivernegative.ts b/lib/views/drivernegative/run.ts similarity index 95% rename from lib/views/drivernegative.ts rename to lib/views/drivernegative/run.ts index 8745f69..ef0a2a7 100644 --- a/lib/views/drivernegative.ts +++ b/lib/views/drivernegative/run.ts @@ -1,10 +1,12 @@ // Driver-negative view: within one cohort, patients with no OncoKB driver in a canonical driver list // against those with one, and every other gene's alteration frequency compared between the two. -import type { ViewResult, DrivernegativeView, DrivernegativeGene } from "../types.ts"; -import { dnegGroupsSql, dnegAlteredSql, dnegProfiledSql, type EnrichAlteration } from "../federation-views.ts"; -import { fisherExact, benjaminiHochberg } from "../stats.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, plural, cytobands } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { type EnrichAlteration } from "../../federation-views.ts"; +import { fisherExact, benjaminiHochberg } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, plural, cytobands } from "../shared.ts"; +import type { DrivernegativeView, DrivernegativeGene } from "./types.ts"; +import { dnegGroupsSql, dnegAlteredSql, dnegProfiledSql } from "./sql.ts"; /** the canonical NSCLC drivers (targetable oncogenes) */ export const DNEG_DEFAULT_DRIVERS = ["EGFR", "KRAS", "ALK", "ROS1", "BRAF", "MET", "RET", "ERBB2", "NTRK1", "NTRK2", "NTRK3"]; diff --git a/lib/views/drivernegative/sql.ts b/lib/views/drivernegative/sql.ts new file mode 100644 index 0000000..715ee6a --- /dev/null +++ b/lib/views/drivernegative/sql.ts @@ -0,0 +1,75 @@ +// The drivernegative view's SQL: what its runner (run.ts) asks each participant's database for. +import { type EnrichAlteration, pidOf, enrichEventCond, geneList, ENRICH_PANEL_TYPE } from "../../federation-views.ts"; +import { driverJoin } from "../../federation.ts"; +import { sq } from "../../compiler.ts"; + +/** Patients per group (positive, negative, unprofiled), of the cohort's patients sequenced for mutations. Counts only. */ +export function dnegGroupsSql(cohortSql: string, study: string, db: string, pin: string, drivers: string[]): string { + return dnegCtes(cohortSql, study, db, pin, drivers, "mutation") + "\nSELECT g AS grp, count() AS n FROM grp GROUP BY grp"; +} + +/** Per gene outside `drivers` and per group (positive, negative): patients with that alteration in it + * (OncoKB drivers only with `driverOnly`). Counts only. */ +export function dnegAlteredSql(cohortSql: string, study: string, db: string, pin: string, drivers: string[], alteration: EnrichAlteration, driverOnly: boolean): string { + const gPid = pidOf("g.patient_unique_id", study); + return ( + dnegCtes(cohortSql, study, db, pin, drivers, alteration) + "\n" + + "SELECT g.hugo_gene_symbol AS gene, grp.g AS grp, uniqExact(" + gPid + ") AS altered\n" + + "FROM " + db + ".genomic_event_derived g\n" + (driverOnly ? driverJoin(pin) : "") + + " INNER JOIN grp ON grp.pid = " + gPid + "\n" + + "WHERE g.cancer_study_identifier = " + sq(study) + " AND " + enrichEventCond(alteration) + "\n" + + " AND g.hugo_gene_symbol NOT IN (" + geneList(drivers) + ") AND grp.g IN ('positive', 'negative')" + (driverOnly ? " AND ann.is_driver" : "") + "\n" + + "GROUP BY gene, grp" + ); +} + +/** Per gene and group: patients not on whole-exome with a panel covering the gene for that alteration + * type; gene '' carries each group's whole-exome patients, profiled for every gene. Counts only. */ +export function dnegProfiledSql(cohortSql: string, study: string, db: string, pin: string, drivers: string[], alteration: EnrichAlteration): string { + return ( + dnegCtes(cohortSql, study, db, pin, drivers, alteration) + ",\n" + + "pw AS (SELECT pid, max(panel = 'WES') AS wes FROM pp GROUP BY pid)\n" + + "SELECT if(pw.wes = 1, '', gp.gene) AS gene, grp.g AS grp, uniqExact(pp.pid) AS profiled\n" + + "FROM pp INNER JOIN pw ON pw.pid = pp.pid INNER JOIN grp ON grp.pid = pp.pid\n" + + " LEFT JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = pp.panel\n" + + "WHERE grp.g IN ('positive', 'negative')\n" + + "GROUP BY gene, grp" + ); +} + +/** + * Each sequenced patient's group: "positive" with an OncoKB driver (mutation, high-level copy + * number or structural variant) in one of `drivers`; else "negative" when a mutation panel of + * theirs covers every listed gene (or whole-exome); else "unprofiled". With `type`, also `pp`: + * each patient's panels for that alteration type and whether one is whole-exome. + */ +function dnegCtes(cohortSql: string, study: string, db: string, pin: string, drivers: string[], type: EnrichAlteration): string { + const sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study), list = geneList(drivers); + const panelsOf = (name: string, alterationType: string) => name + " AS (\n" + + " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(alterationType) + "\n" + + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + ")"; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + panelsOf("mp", "MUTATION_EXTENDED") + ",\n" + + panelsOf("pp", ENRICH_PANEL_TYPE[type]) + ",\n" + + "cov AS (\n" + + " SELECT mp.pid AS pid, max(mp.panel = 'WES') AS wes, uniqExactIf(gp.gene, gp.gene IN (" + list + ")) AS ng\n" + + " FROM mp LEFT JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = mp.panel\n" + + " GROUP BY pid\n" + + "),\n" + + "drv AS (\n" + + " SELECT DISTINCT " + gPid + " AS pid\n" + + " FROM " + db + ".genomic_event_derived g\n" + driverJoin(pin) + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.hugo_gene_symbol IN (" + list + ") AND ann.is_driver\n" + + " AND ((g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED') OR (g.variant_type = 'cna' AND abs(g.cna_alteration) >= 2) OR g.variant_type = 'structural_variant')\n" + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "grp AS (\n" + + " SELECT pid, multiIf(pid IN (SELECT pid FROM drv), 'positive', wes = 1 OR ng = " + new Set(drivers).size + ", 'negative', 'unprofiled') AS g FROM cov\n" + + ")" + ); +} diff --git a/lib/views/drivernegative/types.ts b/lib/views/drivernegative/types.ts new file mode 100644 index 0000000..d5adb7c --- /dev/null +++ b/lib/views/drivernegative/types.ts @@ -0,0 +1,49 @@ +// The drivernegative view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Driver-negative analysis: one cohort split into patients with an OncoKB driver in a canonical + * driver list, patients with none who were profiled for every listed gene, and those not fully + * profiled (neither); then every other gene's alteration frequency compared between the first two. + * Counts only. + */ +export interface DrivernegativeView { + note?: string; + kind: "drivernegative"; cohorts?: string[]; + /** the canonical drivers (default the NSCLC set: EGFR, KRAS, ALK, ROS1, BRAF, MET, RET, ERBB2, NTRK1–3) */ + drivers?: string[]; + /** what is compared between the groups (default mutation; sv counts either fusion partner) */ + alteration?: "mutation" | "sv" | "cna"; + /** count every alteration (default), or only OncoKB drivers */ + measure?: "any" | "driver"; + /** genes altered in fewer patients than this, both groups together, are not tested (default 5) */ + minAltered?: number; + /** genes named on the volcano (default 12) */ + label?: number; +} +export interface DrivernegativeGene { + gene: string; cytoband?: string; + /** [driver-negative, driver-positive] patients altered, and profiled for the gene */ + altered: [number, number]; profiled: [number, number]; + log2Ratio: number | null; p: number; q: number; +} +export interface DrivernegativeResult { + drivers: string[]; alteration: string; measure: "any" | "driver"; minAltered: number; + /** the cohort's patients, those sequenced for mutations, and the three groups of those */ + patients: number; sequenced: number; positive: number; negative: number; unprofiled: number; + tested: number; untested: number; significant: number; + /** the most significant genes (≤300) */ + rows: DrivernegativeGene[]; + labels: string[]; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { drivernegative: DrivernegativeView } + interface ViewResultFields { + drivernegative: { + drivernegative?: DrivernegativeResult; + }; + } + interface ViewResultMain { drivernegative: "drivernegative" } +} diff --git a/lib/figures/enrichment.ts b/lib/views/enrichment/figure.ts similarity index 98% rename from lib/figures/enrichment.ts rename to lib/views/enrichment/figure.ts index 1c0f733..9c50ce7 100644 --- a/lib/figures/enrichment.ts +++ b/lib/views/enrichment/figure.ts @@ -1,12 +1,12 @@ // Enrichment figure: every gene's alteration frequency compared between cohorts — the portal's // three charts (volcano, frequency scatter, most-altered genes) and its table. -import type { ViewResultOf, DrawnResultOf } from "../types.ts"; -import { volcano, volcanoPlotH } from "./volcano.ts"; +import type { ViewResultOf, DrawnResultOf } from "../../types.ts"; +import { volcano, volcanoPlotH } from "../../figures/volcano.ts"; import { palette, frame, caption, label, textWidth, wrapWidth, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, bandLabels, legend, seriesStyle, textOn, fmtInt, fmtPct, pDigits, tickFormat, esc, FS, SW, type SvgOpts -} from "../svg-kit.ts"; +} from "../../svg-kit.ts"; type Pal = ReturnType; diff --git a/lib/views/enrichment/method.ts b/lib/views/enrichment/method.ts new file mode 100644 index 0000000..ffe5017 --- /dev/null +++ b/lib/views/enrichment/method.ts @@ -0,0 +1,62 @@ +// The enrichment view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, VERIFIED, RESULT_INIT, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface EnrichmentTrace { + alteration: string; measure: "driver" | "any"; oncokbPin: string | null; cohorts: string[]; minAltered: number; + test: string; two: boolean; + perSite: { cohort: string; study: string; genes: number; wes: number }[]; + tested: number; untested: number; + /** the rows the view shows, whose p and q the script must reproduce */ + results: { gene: string; p: number; q: number }[]; +} + +export function enrichmentMethod(t: EnrichmentTrace): MethodRecord { + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Count altered and profiled samples per gene", + detail: "The enrichment queries above: per gene, samples with " + (t.measure === "driver" ? "an OncoKB driver (" + t.oncokbPin + ") " : "a ") + t.alteration + + "; per gene, samples on a panel listing it (counted per panel, then joined to its genes); and the whole-exome samples, profiled for every gene.", + scope: s.cohort + " · " + s.study, rowsOut: s.genes, params: [["whole-exome samples", s.wes.toLocaleString("en-US")]], + })); + steps.push({ kind: "transform", title: "Profiled = panel samples + whole-exome samples, summed across studies", + detail: "A sample altered in a gene its panel does not list still counts as measured. Genes with fewer than " + t.minAltered + + " altered samples across the cohorts, or not profiled in one of them, are not tested.", + params: [["tested", t.tested.toLocaleString("en-US")], ["not tested", t.untested.toLocaleString("en-US")]] }); + steps.push({ kind: "statistic", title: (t.two ? "Fisher's exact test" : "Chi-squared test") + " per gene, then Benjamini–Hochberg", + detail: "A " + t.test + " on altered vs not among the profiled, per gene; q across every gene tested." }); + const python = [ + "# Recompute with scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against.", + "import pandas as pd", + t.two ? "from scipy.stats import fisher_exact" : "from scipy.stats import chi2_contingency", + "from statsmodels.stats.multitest import multipletests", + "", + RESULT_INIT, + "# Per cohort and gene: altered samples, and samples profiled for the gene (panel + whole-exome),", + "# summed across studies — export it from the enrichment queries above as enrichment_counts.csv", + "counts = pd.read_csv(\"enrichment_counts.csv\")", + "cohorts = " + JSON.stringify(t.cohorts), + "MIN_ALTERED = " + t.minAltered, + "rows = []", + "for gene, g in counts.groupby(\"gene\"):", + " g = g.set_index(\"cohort\").reindex(cohorts).fillna(0)", + " if g[\"altered\"].sum() < MIN_ALTERED or (g[\"profiled\"] == 0).any():", + " continue", + t.two + ? " (a, pa), (b, pb) = [(r.altered, r.profiled) for r in g.itertuples()]\n p = fisher_exact([[a, pa - a], [b, pb - b]], alternative=\"two-sided\").pvalue" + : " p = chi2_contingency([[r.altered, r.profiled - r.altered] for r in g.itertuples()], correction=False).pvalue", + " rows.append((gene, p))", + "res = pd.DataFrame(rows, columns=[\"gene\", \"p\"])", + "res[\"q\"] = multipletests(res[\"p\"], method=\"fdr_bh\")[1]", + "res = res.sort_values([\"p\", \"gene\"])", + "print(len(res), \"genes tested\")", + "print(res.head(30).to_string(index=False))", + "for r in res.itertuples():", + " RESULT[f\"enrichment:{r.gene}:p\"], RESULT[f\"enrichment:{r.gene}:q\"] = r.p, r.q", + "RESULT[\"enrichment:tested\"] = len(res)", + ].join("\n") + "\n"; + return { steps, implementation: { functions: [t.two ? "fisherExact" : "chiSquareTest", "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: (t.two ? "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact" : "The chi-squared test matches scipy " + VERIFIED.chi2.version + " chi2_contingency") + + " and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/tables-golden.test.ts).", + python: withResult(python), + check: { "enrichment:tested": { v: t.tested }, ...checks("enrichment:", t.results.slice(0, 40), (r) => r.gene, [["p", "p"], ["q", "q"]]) } }; +} diff --git a/lib/views/enrichment.ts b/lib/views/enrichment/run.ts similarity index 95% rename from lib/views/enrichment.ts rename to lib/views/enrichment/run.ts index 3bfa6f8..6648cf6 100644 --- a/lib/views/enrichment.ts +++ b/lib/views/enrichment/run.ts @@ -1,10 +1,12 @@ // Enrichment view: every gene's alteration frequency, compared between cohorts -import type { ViewResult, EnrichmentView, EnrichmentGene, ViewResultOf } from "../types.ts"; -import { enrichAlteredSql, enrichPanelProfiledSql, enrichWesSql, type EnrichAlteration } from "../federation-views.ts"; -import { fisherExact, chiSquareTest, benjaminiHochberg } from "../stats.ts"; -import { enrichmentMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, cytobands, answering, refuse, niceCeil, askEachSite } from "./shared.ts"; +import type { ViewResult, ViewResultOf } from "../../types.ts"; +import { type EnrichAlteration } from "../../federation-views.ts"; +import { fisherExact, chiSquareTest, benjaminiHochberg } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, cytobands, answering, refuse, niceCeil, askEachSite } from "../shared.ts"; +import type { EnrichmentView, EnrichmentGene } from "./types.ts"; +import { enrichAlteredSql, enrichPanelProfiledSql, enrichWesSql } from "./sql.ts"; +import { enrichmentMethod } from "./method.ts"; const ENRICH_ROWS = 300; const ALTERATION_WORD: Record = { diff --git a/lib/views/enrichment/sql.ts b/lib/views/enrichment/sql.ts new file mode 100644 index 0000000..5178ebd --- /dev/null +++ b/lib/views/enrichment/sql.ts @@ -0,0 +1,53 @@ +// The enrichment view's SQL: what its runner (run.ts) asks each participant's database for. +import { type EnrichAlteration, pidOf, enrichEventCond, ENRICH_PANEL_TYPE } from "../../federation-views.ts"; +import { driverJoin } from "../../federation.ts"; +import { sq } from "../../compiler.ts"; + +/** Per gene: the cohort's samples with that alteration type in it (uncalled mutations excluded, as the + * portal's gene tables exclude them; high-level copy number only). Counts only. */ +export function enrichAlteredSql(cohortSql: string, study: string, db: string, alteration: EnrichAlteration, pin: string | null, driver: boolean): string { + const pid = pidOf("g.patient_unique_id", study); + return ( + "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT g.hugo_gene_symbol AS gene, uniqExact(g.sample_unique_id) AS altered\n" + + "FROM " + db + ".genomic_event_derived g\n" + + (driver && pin ? driverJoin(pin) : "") + + "WHERE g.cancer_study_identifier = " + sq(study) + " AND " + enrichEventCond(alteration) + "\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)" + (driver && pin ? " AND ann.is_driver" : "") + "\n" + + "GROUP BY gene" + ); +} + +/** Per gene: the cohort's samples on a (non-WES) panel covering it for that alteration type — samples + * are counted per panel first and the panels then joined to their genes, Explore's own construction + * (distinct samples, never rows: a study with two CNA profiles lists each sample twice). */ +export function enrichPanelProfiledSql(cohortSql: string, study: string, db: string, alteration: EnrichAlteration): string { + const covPid = pidOf("sd.patient_unique_id", study); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "pc AS (\n" + + " SELECT sp.gene_panel_id AS panel, uniqExact(sp.sample_unique_id) AS n\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(ENRICH_PANEL_TYPE[alteration]) + "\n" + + " AND sp.gene_panel_id != 'WES' AND " + covPid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY panel\n" + + ")\n" + + "SELECT gptg.gene AS gene, sum(pc.n) AS profiled\n" + + "FROM " + db + ".gene_panel_to_gene_derived gptg INNER JOIN pc ON pc.panel = gptg.gene_panel_id\n" + + "GROUP BY gene" + ); +} + +/** The cohort's whole-exome samples for that alteration type: profiled for every gene, so one number. */ +export function enrichWesSql(cohortSql: string, study: string, db: string, alteration: EnrichAlteration): string { + const covPid = pidOf("sd.patient_unique_id", study); + return ( + "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT uniqExact(sp.sample_unique_id) AS n\n" + + "FROM " + db + ".sample_to_gene_panel_derived sp\n" + + "JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + "WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(ENRICH_PANEL_TYPE[alteration]) + "\n" + + " AND sp.gene_panel_id = 'WES' AND " + covPid + " IN (SELECT pid FROM cohort)" + ); +} diff --git a/lib/views/enrichment/types.ts b/lib/views/enrichment/types.ts new file mode 100644 index 0000000..2189ff7 --- /dev/null +++ b/lib/views/enrichment/types.ts @@ -0,0 +1,52 @@ +// The enrichment view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Genomic-alteration enrichment (cBioPortal's group-comparison Genomic Alterations tab): every gene's + * frequency of one alteration type in each cohort, counted in samples over the samples profiled for + * that gene, with a test per gene and Benjamini–Hochberg across all genes tested. Counts only. + */ +export interface EnrichmentView { + note?: string; + kind: "enrichment"; cohorts?: string[]; + /** which alteration (default mutation); "cna" is amplification or deep deletion */ + alteration?: "mutation" | "cna" | "amp" | "homdel" | "sv"; + /** count OncoKB drivers only, or any alteration (default) */ + measure?: "driver" | "any"; + /** genes altered in fewer samples than this, across all cohorts, are not tested (default 5) */ + minAltered?: number; + /** genes named on the volcano (default 12) */ + label?: number; +} +export interface EnrichmentGene { + gene: string; + /** in the study's reference genome, when known */ + cytoband?: string; + cells: { cohort: string; altered: number; profiled: number; pct: number | null }[]; + /** two cohorts: log2 of the first cohort's altered share over the second's; null when either is zero */ + log2Ratio?: number | null; + p: number; q: number; + /** the cohort with the highest altered share */ + enrichedIn: string; +} +export interface EnrichmentResult { + alteration: string; measure: "driver" | "any"; groups: string[]; + tested: number; untested: number; significant: number; minAltered: number; + test: string; + /** the most significant genes (≤300), the rest summarised in `grid` */ + rows: EnrichmentGene[]; + /** two cohorts: every other tested gene binned on the volcano's axes, [x bin, y bin, count] */ + grid?: { xMax: number; yMax: number; nx: number; ny: number; cells: [number, number, number][] }; + labels: string[]; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { enrichment: EnrichmentView } + interface ViewResultFields { + enrichment: { + enrichment?: EnrichmentResult; + }; + } + interface ViewResultMain { enrichment: "enrichment" } +} diff --git a/lib/figures/exclusivity.ts b/lib/views/exclusivity/figure.ts similarity index 97% rename from lib/figures/exclusivity.ts rename to lib/views/exclusivity/figure.ts index 426214f..e1db417 100644 --- a/lib/figures/exclusivity.ts +++ b/lib/views/exclusivity/figure.ts @@ -2,8 +2,9 @@ // shaded by its q, then the most significant pairs set against the plain Fisher test. import { esc, palette, frame, caption, legend, label, textWidth, fmtInt, fmtNum, pDigits, textOn, FS, SW, SERIES, type SvgOpts -} from "../svg-kit.ts"; -import type { DrawnResultOf, ExclusivityPair } from "../types.ts"; +} from "../../svg-kit.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { ExclusivityPair } from "./types.ts"; const PAD = 8, GUT = 12, ROW = 18, TABLE_MAX = 12; diff --git a/lib/views/exclusivity.ts b/lib/views/exclusivity/run.ts similarity index 97% rename from lib/views/exclusivity.ts rename to lib/views/exclusivity/run.ts index f9bfd52..4df6df1 100644 --- a/lib/views/exclusivity.ts +++ b/lib/views/exclusivity/run.ts @@ -1,11 +1,12 @@ // Exclusivity view: mutual exclusivity and co-occurrence against each tumour's alteration burden // (DISCOVER, Canisius, Martens & Wessels, Genome Biology 2016) -import type { ViewResult, ExclusivityView, ExclusivityPair, ExclusivityResult } from "../types.ts"; -import { exclusivityGroupsSql, exclusivityColumnsSql, exclusivityCoverageSql } from "../federation-views.ts"; -import { benjaminiHochberg, fisherExact, log2OddsRatio, poissonBinomialTails } from "../stats.ts"; -import { geneSymbols } from "../gene-tracks.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, plural, refuse, ATTR_NAME } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { benjaminiHochberg, fisherExact, log2OddsRatio, poissonBinomialTails } from "../../stats.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, plural, refuse, ATTR_NAME } from "../shared.ts"; +import type { ExclusivityView, ExclusivityPair, ExclusivityResult } from "./types.ts"; +import { exclusivityGroupsSql, exclusivityColumnsSql, exclusivityCoverageSql } from "./sql.ts"; const GENES_MAX = 20; const FIT_TOL = 1e-4, FIT_MAX_ITER = 300; diff --git a/lib/views/exclusivity/sql.ts b/lib/views/exclusivity/sql.ts new file mode 100644 index 0000000..c643f62 --- /dev/null +++ b/lib/views/exclusivity/sql.ts @@ -0,0 +1,81 @@ +// The exclusivity view's SQL: what its runner (run.ts) asks each participant's database for. +import { sq } from "../../compiler.ts"; +import { driverJoin, ALTERED_COND } from "../../federation.ts"; +import { clinicalValueFoldSql } from "../../event-names.ts"; +import { clinicalTable } from "../../custom-data.ts"; + +/** + * The samples grouped by what their background probability depends on — panel signature, + * stratum and burden r (altered covered genes) — and by which of `genes` they have altered (a + * bitmask, bit i for genes[i]): counts only. + */ +export function exclusivityGroupsSql(cohortSql: string, study: string, db: string, genes: string[], stratifyBy: string | null, + pin: string | null, measure: "driver" | "any"): string { + const mask = genes.map((g, i) => "maxIf(" + (2 ** i) + ", altc.gene = " + sq(g) + ")").join(" + ") || "0"; + return ( + exclusivityCtes(cohortSql, study, db, stratifyBy, pin, measure) + ",\n" + + "per AS (SELECT suid, count() AS r, " + mask + " AS amask FROM altc GROUP BY suid)\n" + + "SELECT sp.sig AS sig, ifNull(strat.v, '') AS stratum, per.r AS r, per.amask AS amask, count() AS n\n" + + "FROM sp LEFT JOIN per ON per.suid = sp.suid LEFT JOIN strat ON strat.suid = sp.suid\n" + + "GROUP BY sig, stratum, r, amask" + ); +} + +/** Per stratum and gene, the samples with that gene altered (the background's column sums). */ +export function exclusivityColumnsSql(cohortSql: string, study: string, db: string, stratifyBy: string | null, pin: string | null, measure: "driver" | "any"): string { + return ( + exclusivityCtes(cohortSql, study, db, stratifyBy, pin, measure) + "\n" + + "SELECT ifNull(strat.v, '') AS stratum, altc.gene AS gene, count() AS n\n" + + "FROM altc LEFT JOIN strat ON strat.suid = altc.suid GROUP BY stratum, gene" + ); +} + +/** The genes each panel signature in the cohort covers. Metadata only. */ +export function exclusivityCoverageSql(cohortSql: string, study: string, db: string): string { + return exclusivityCtes(cohortSql, study, db, null, null, "any") + "\nSELECT sig, gene FROM cov"; +} + +// --------------------------------------------------------------------------- +// exclusivity: the sample-by-gene alteration matrix, reduced to what DISCOVER's background needs + +/** + * The cohort's samples with a gene panel, each with its panel signature (every panel it was + * profiled on, any alteration type, joined), its stratum (a sample attribute's folded value, or + * '' unstratified), and its altered genes among those its panels cover (any alteration, or OncoKB + * drivers): `sp`, `altc` and `strat` CTEs. + */ +function exclusivityCtes(cohortSql: string, study: string, db: string, stratifyBy: string | null, pin: string | null, measure: "driver" | "any"): string { + const driver = measure === "driver"; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "sp AS (\n" + + " SELECT sample_unique_id AS suid, arrayStringConcat(arraySort(groupUniqArray(gene_panel_id)), '+') AS sig\n" + + " FROM " + db + ".sample_to_gene_panel_derived\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND alteration_type IN ('MUTATION_EXTENDED','COPY_NUMBER_ALTERATION','STRUCTURAL_VARIANT')\n" + + " AND sample_unique_id IN (SELECT sample_unique_id FROM " + db + ".sample_derived WHERE cancer_study_identifier = " + sq(study) + + " AND patient_stable_id IN (SELECT pid FROM cohort))\n" + + " GROUP BY suid\n" + + "),\n" + + "cov AS (\n" + + " SELECT DISTINCT s.sig AS sig, pg.gene AS gene\n" + + " FROM (SELECT DISTINCT sig, arrayJoin(splitByChar('+', sig)) AS panel FROM sp) s\n" + + " JOIN " + db + ".gene_panel_to_gene_derived pg ON pg.gene_panel_id = s.panel\n" + + "),\n" + + "altc AS (\n" + + " SELECT DISTINCT g.sample_unique_id AS suid, g.hugo_gene_symbol AS gene\n" + + " FROM " + db + ".genomic_event_derived g\n" + + (driver && pin ? driverJoin(pin) : "") + + " INNER JOIN sp ON sp.suid = g.sample_unique_id\n" + + " INNER JOIN cov ON cov.sig = sp.sig AND cov.gene = g.hugo_gene_symbol\n" + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND " + ALTERED_COND + (driver ? (pin ? " AND ann.is_driver" : " AND 0") : "") + "\n" + + " AND g.sample_unique_id IN (SELECT suid FROM sp)\n" + + "),\n" + + "strat AS (\n" + + (stratifyBy + ? " SELECT sample_unique_id AS suid, any(" + clinicalValueFoldSql("attribute_value") + ") AS v FROM " + clinicalTable(db, [stratifyBy]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(stratifyBy) + " AND sample_unique_id IN (SELECT suid FROM sp)\n" + + " GROUP BY suid\n" + : " SELECT suid, '' AS v FROM sp\n") + + ")" + ); +} diff --git a/lib/views/exclusivity/types.ts b/lib/views/exclusivity/types.ts new file mode 100644 index 0000000..0b0cb2b --- /dev/null +++ b/lib/views/exclusivity/types.ts @@ -0,0 +1,51 @@ +// The exclusivity view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Mutual exclusivity and co-occurrence against each tumour's own alteration burden (DISCOVER, + * Canisius et al. 2016): per sample and gene a background probability fitted from the sample and + * gene marginals of the whole altered-gene matrix, so that heavily altered tumours are expected to + * share alterations; each pair's co-altered count tested against its Poisson-binomial null. + */ +export interface ExclusivityView { + note?: string; + kind: "exclusivity"; cohorts?: string[]; + /** 2 to 20 genes */ + genes: string[]; + /** estimate the background within each value of this sample attribute (e.g. CANCER_TYPE) */ + stratifyBy?: string; + /** any alteration (default) or OncoKB oncogenic only */ + measure?: "driver" | "any"; +} +export interface ExclusivityPair { + a: string; b: string; + /** samples profiled for both genes, those altered in both, and the background's expected number */ + profiled: number; both: number; expected: number; + /** DISCOVER: lower tail (exclusivity) and upper tail (co-occurrence), BH q in each direction */ + pExclusive: number; qExclusive: number; pCooccur: number; qCooccur: number; + /** false when the tails came from the refined normal approximation, not the exact distribution */ + exact: boolean; + /** the plain two-sided Fisher test on the same samples' 2×2, BH across the pairs */ + onlyA: number; onlyB: number; neither: number; fisherP: number; fisherQ: number; log2Odds: number | null; +} +export interface ExclusivityResult { + cohort: string; genes: string[]; + stratifyBy: string | null; strata: { name: string; samples: number }[]; + measure: "driver" | "any"; + /** samples with a panel, the genes in the background matrix, and how the fit ended */ + samples: number; matrixGenes: number; iterations: number; converged: boolean; maxResidual: number; + pairs: ExclusivityPair[]; + /** pairs no sample was profiled for both genes of */ + unassessable: string[]; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { exclusivity: ExclusivityView } + interface ViewResultFields { + exclusivity: { + exclusivity?: ExclusivityResult; + }; + } + interface ViewResultMain { exclusivity: "exclusivity" } +} diff --git a/lib/views/forest.ts b/lib/views/forest/run.ts similarity index 97% rename from lib/views/forest.ts rename to lib/views/forest/run.ts index dae5452..7bc8aad 100644 --- a/lib/views/forest.ts +++ b/lib/views/forest/run.ts @@ -1,14 +1,15 @@ // Forest view: one exposure's hazard ratio within each stratum (cancer type, study or cohort), each // from its own Cox fit on per-patient rows inside each local study, pooled by a DerSimonian–Laird // random-effects meta-analysis. Covariates are coded by ./covariates.ts, as the cox view codes them. -import { coxRegression, dersimonianLaird, COX_MAX_ITERATIONS, type CoxRow } from "../stats.ts"; -import type { ViewResult, ForestView, ForestRow, ForestResult, ForestPooled, CoxCovariate, PlotAxis } from "../types.ts"; -import { isPatientLevel, type EndpointColumns } from "../federation.ts"; -import { validateAxis, axisLabel, isAbsent, OQL_NOT_PROFILED, MULTIPLE, type AxisKind } from "../plot.ts"; -import { resolveProfileAxis } from "../plot-profile.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, refuse, studyCatalogs, plural, ATTR_NAME } from "./shared.ts"; -import { buildCoxColumns, notProfiledNote } from "./covariates.ts"; +import { coxRegression, dersimonianLaird, COX_MAX_ITERATIONS, type CoxRow } from "../../stats.ts"; +import type { ViewResult, CoxCovariate, PlotAxis } from "../../types.ts"; +import { isPatientLevel, type EndpointColumns } from "../../federation.ts"; +import { validateAxis, axisLabel, isAbsent, OQL_NOT_PROFILED, MULTIPLE, type AxisKind } from "../../plot.ts"; +import { resolveProfileAxis } from "../../plot-profile.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, refuse, studyCatalogs, plural, ATTR_NAME } from "../shared.ts"; +import { buildCoxColumns, notProfiledNote } from "../covariates.ts"; +import type { ForestView, ForestRow, ForestResult, ForestPooled } from "./types.ts"; export const FOREST_MIN_PATIENTS = 30, FOREST_MIN_EVENTS = 10, FOREST_MAX_STRATA = 30, FOREST_MAX_COVARIATES = 4; /** a stratum's fit is reported only when its log-HR's standard error is below this (larger: separation) */ diff --git a/lib/views/forest/types.ts b/lib/views/forest/types.ts new file mode 100644 index 0000000..0b264b2 --- /dev/null +++ b/lib/views/forest/types.ts @@ -0,0 +1,64 @@ +// The forest view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { CoxCovariate } from "../../types.ts"; +import type { PlotAxis } from "../../plot.ts"; + +/** + * Pan-cancer forest: one exposure's hazard ratio within each stratum of the cohort (a cancer type, + * a study, or a cohort), each from its own Cox fit, pooled by a DerSimonian–Laird random-effects + * meta-analysis (and the fixed-effect estimate beside it). Fit from per-patient rows within each + * local study, the same boundary as `cox`. + */ +export interface ForestView { + note?: string; + kind: "forest"; cohorts?: string[]; + /** the exposure, as a plot axis: a gene's alteration (not profiled is left out, never unaltered), a numeric or categorical attribute */ + exposure: PlotAxis; + /** a categorical exposure's level counted as exposed (default for an alteration axis: any alteration) */ + exposed?: string; + /** a numeric exposure: exposed when the value is at least this */ + threshold?: number; + /** endpoint prefix (default OS), and the cox view's escape hatch for other column names */ + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** what a row is: a categorical attribute (default CANCER_TYPE), "study", or "cohort" (the view's cohorts) */ + strata?: string; + /** adjust each stratum's fit for these (default none) */ + covariates?: CoxCovariate[]; + /** a stratum with fewer patients or events than these is pooled into "Other" (defaults 30 and 10) */ + minPatients?: number; minEvents?: number; + /** at most this many strata are drawn, the largest; the rest pool into "Other" (default 30) */ + maxStrata?: number; + /** what happens to the small strata: pooled into one "Other" row (default) or left out, listed */ + small?: "pool" | "drop"; +} +export interface ForestRow { + label: string; n: number; events: number; exposed: number; exposedEvents: number; + beta: number; se: number; hr: number; ciLow: number; ciHigh: number; p: number; + /** the row's share of the random-effects weight */ + weight: number; + /** an "Other" row: the strata it pools */ + pooled?: string[]; +} +export interface ForestPooled { hr: number; ciLow: number; ciHigh: number; p: number; beta: number; se: number } +export interface ForestResult { + exposure: string; exposedLabel: string; endpoint: string; strata: string; covariates: string[]; + rows: ForestRow[]; + /** strata that are not rows, and why */ + left: { label: string; n: number; events: number; reason: string }[]; + /** pooled estimates; null with fewer than two rows */ + fixed: ForestPooled | null; random: ForestPooled | null; + tau2: number; q: number; qDf: number; qP: number; i2: number; + /** patients with a time, the exposure and the stratum (and every covariate) */ + n: number; events: number; + notProfiled: number; missing: number; + excludedRemote?: { study: string; patients: number }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { forest: ForestView } + interface ViewResultFields { + forest: { + forest?: ForestResult; + }; + } + interface ViewResultMain { forest: "forest" } +} diff --git a/lib/figures/genome.ts b/lib/views/genome/figure.ts similarity index 97% rename from lib/figures/genome.ts rename to lib/views/genome/figure.ts index 55c2ca2..77ef467 100644 --- a/lib/figures/genome.ts +++ b/lib/views/genome/figure.ts @@ -2,9 +2,9 @@ import { palette, frame, caption, linear, axisLeft, leftAxisWidth, legend, label, textWidth, fmtInt, fmtNum, FS, SW, SERIES, type SvgOpts, type LegendItem -} from "../svg-kit.ts"; -import type { DrawnResultOf } from "../types.ts"; -import { CN_GAIN, CN_LOSS } from "./common.ts"; +} from "../../svg-kit.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { CN_GAIN, CN_LOSS } from "../../figures/common.ts"; const PAD = 8, R = 10, HEAD = 18, GAP = 14, STRIP = 10; const Y_TITLE = "% of samples"; diff --git a/lib/views/genome/method.ts b/lib/views/genome/method.ts new file mode 100644 index 0000000..59c9dec --- /dev/null +++ b/lib/views/genome/method.ts @@ -0,0 +1,64 @@ +// The genome view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, plural, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface GenomeTrace { + build: string; binMb: number; threshold: number; includeX: boolean; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + cohorts: { name: string; samples: number; segmented: number }[]; + bins: number; + pastEnd: number; + /** per cohort, summed over its studies: each bin's samples covered, gained and lost (bins with coverage only) */ + counts: { cohort: string; chrom: string; bin: number; profiled: number; gain: number; loss: number }[]; + /** the view's q-value per bin tested (two cohorts only) — for the script check */ + results: { chrom: string; bin: number; gainQ: number | null; lossQ: number | null }[]; +} + +export function genomeMethod(t: GenomeTrace): MethodRecord { + const two = t.cohorts.length === 2; + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Count gained and lost samples per bin", + detail: "The genome:coverage and genome:bins queries above. Per " + t.binMb + " Mb bin on " + t.build + ": the cohort's SAMPLES (a patient with two segmented samples counts twice) whose segmentation covers the bin's midpoint, and of those the ones gained (log2 segment mean ≥ " + + t.threshold + ") or lost (≤ −" + t.threshold + ") there. Counts only.", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + })); + steps.push({ kind: "transform", title: "Place the bins on one axis", + detail: "Chromosomes 1–22" + (t.includeX ? " and X" : "") + " at " + t.build + " lengths, " + t.bins.toLocaleString() + " bins; counts add across studies (all on one build)." + + (t.pastEnd ? " " + plural(t.pastEnd, "bin row") + " lay past a chromosome's end and were dropped." : ""), + params: t.cohorts.map((c) => [c.name, c.segmented.toLocaleString() + " of " + c.samples.toLocaleString() + " samples segmented"] as [string, string]) }); + if (two) { + steps.push({ kind: "statistic", title: "Fisher's exact test per bin", + detail: "Two-sided, on gained vs not (and separately lost vs not) among each cohort's covered samples, in every bin both cohorts cover." }); + steps.push({ kind: "statistic", title: "Benjamini–Hochberg across bins", + detail: "Separately for gains and losses. Adjacent bins are not independent: a run of significant bins is one region, not many findings." }); + } + const python = [ + "# Recompute" + (two ? " with scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against" : "") + ".", + "import io", + "import pandas as pd", + ...(two ? ["from scipy.stats import fisher_exact", "from statsmodels.stats.multitest import multipletests"] : []), + "", + RESULT_INIT, + ...pyTable("counts", "Per cohort (summed over its studies) and bin: samples covered, gained and lost", ["cohort", "chrom", "bin", "profiled", "gain", "loss"], + t.counts.map((r) => [r.cohort, r.chrom, r.bin, r.profiled, r.gain, r.loss]), "genome_bins.csv"), + "counts[\"chrom\"] = counts[\"chrom\"].astype(str) # \"1\"…\"22\", \"X\": names, not numbers", + "counts[\"gain_pct\"] = 100 * counts[\"gain\"] / counts[\"profiled\"]", + "counts[\"loss_pct\"] = 100 * counts[\"loss\"] / counts[\"profiled\"]", + ...(two ? [ + "a, b = [counts[counts[\"cohort\"] == c].set_index([\"chrom\", \"bin\"]) for c in " + JSON.stringify(t.cohorts.map((c) => c.name)) + "]", + "both = a.join(b, lsuffix=\"_a\", rsuffix=\"_b\", how=\"inner\")", + "both = both[(both[\"profiled_a\"] > 0) & (both[\"profiled_b\"] > 0)]", + "for kind in (\"gain\", \"loss\"):", + " p = [fisher_exact([[r[f\"{kind}_a\"], r[\"profiled_a\"] - r[f\"{kind}_a\"]], [r[f\"{kind}_b\"], r[\"profiled_b\"] - r[f\"{kind}_b\"]]]).pvalue", + " for _, r in both.iterrows()]", + " both[f\"{kind}_q\"] = multipletests(p, method=\"fdr_bh\")[1]", + " print(kind, \"bins with q < 0.05:\", int((both[f\"{kind}_q\"] < 0.05).sum()))", + " for (chrom, b), q in both[f\"{kind}_q\"].items():", + " RESULT[f\"genome:{chrom}:{b}:{kind}_q\"] = q", + ] : ["print(counts.head(30).to_string(index=False))"]), + ].join("\n") + "\n"; + return { steps, implementation: { functions: two ? ["fisherExact", "benjaminiHochberg"] : [], file: "lib/stats.ts", commit: BUILD_COMMIT }, + ...(two ? { verification: "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/tables-golden.test.ts)." } : {}), + python: withResult(python), + check: checks("genome:", t.results.filter((r) => r.gainQ != null), (r) => r.chrom + ":" + r.bin, [["gainQ", "gain_q"], ["lossQ", "loss_q"]]) }; +} diff --git a/lib/views/genome.ts b/lib/views/genome/run.ts similarity index 94% rename from lib/views/genome.ts rename to lib/views/genome/run.ts index af48d8a..054cca5 100644 --- a/lib/views/genome.ts +++ b/lib/views/genome/run.ts @@ -1,10 +1,12 @@ // Genome view: genome-wide copy-number frequency from segments -import type { ViewResult, GenomeView, GenomeSeries } from "../types.ts"; -import { genomeBinsSql, genomeCoverageSql, studyGenomeSql, CHROM_LENGTHS, GENOME_CHROMS } from "../federation-views.ts"; -import { fisherExact, benjaminiHochberg } from "../stats.ts"; -import { genomeMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, plural, refuse } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { genomeCoverageSql, studyGenomeSql, CHROM_LENGTHS, GENOME_CHROMS } from "../../federation-views.ts"; +import { fisherExact, benjaminiHochberg } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, plural, refuse } from "../shared.ts"; +import type { GenomeView, GenomeSeries } from "./types.ts"; +import { genomeBinsSql } from "./sql.ts"; +import { genomeMethod } from "./method.ts"; const GENOME_COHORT_LIMIT = 4; diff --git a/lib/views/genome/sql.ts b/lib/views/genome/sql.ts new file mode 100644 index 0000000..21f097f --- /dev/null +++ b/lib/views/genome/sql.ts @@ -0,0 +1,51 @@ +// The genome view's SQL: what its runner (run.ts) asks each participant's database for. +import { GENOME_CHROMS, pidOf } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; + +export interface GenomeBinOpts { + /** bin width in base pairs */ + binBp: number; + /** |segment mean| at or past this is a gain / loss (log2 ratio) */ + threshold: number; + includeX: boolean; +} + +/** + * The cohort's segmented SAMPLES, one row per (chromosome, bin): how many have a segment + * covering that bin's midpoint (the bin's own denominator — a sample whose segmentation does + * not reach a bin was not measured there, which is not the same as copy-neutral), and how many + * of those are gained or lost there. + * + * Each bin takes the value of the segment covering its MIDPOINT, so a focal event narrower than + * a bin can fall between midpoints and be missed; the caller says so and a smaller bin finds it. + * A sample with overlapping segments at one midpoint (it happens in a few studies' files) takes + * their mean, so it is still counted once. + */ +export function genomeBinsSql(cohortSql: string, study: string, db: string, o: GenomeBinOpts): string { + const B = Math.max(1, Math.floor(o.binBp)), half = Math.floor(B / 2), T = Number(o.threshold); + const chroms = GENOME_CHROMS.filter((c) => o.includeX || c !== "X"); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "samp AS (\n" + + " SELECT internal_id AS sid FROM " + db + ".sample_derived\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "seg AS (\n" + + " SELECT s.sample_id AS sid, multiIf(s.chr IN ('23', 'X', 'chrX'), 'X', replaceOne(s.chr, 'chr', '')) AS chrom,\n" + + " s.start AS st, s.end AS en, s.segment_mean AS m\n" + + " FROM " + db + ".copy_number_seg s\n" + + " WHERE s.cancer_study_id = (SELECT cancer_study_id FROM " + db + ".cancer_study WHERE cancer_study_identifier = " + sq(study) + ")\n" + + " AND s.sample_id IN (SELECT sid FROM samp) AND isFinite(s.segment_mean)\n" + + "),\n" + + "binned AS (\n" + + " SELECT sid, chrom, bin, avg(m) AS v FROM (\n" + + " SELECT sid, chrom, m,\n" + + " arrayJoin(range(toUInt64(greatest(0, ceil((st - " + half + ") / " + B + "))),\n" + + " toUInt64(greatest(0, floor((en - " + half + ") / " + B + ") + 1)))) AS bin\n" + + " FROM seg WHERE chrom IN (" + chroms.map(sq).join(",") + ") AND en >= st\n" + + " ) GROUP BY sid, chrom, bin\n" + + ")\n" + + "SELECT chrom, bin, count() AS profiled, countIf(v >= " + T + ") AS gain, countIf(v <= " + (-T) + ") AS loss\n" + + "FROM binned GROUP BY chrom, bin ORDER BY chrom, bin" + ); +} diff --git a/lib/views/genome/types.ts b/lib/views/genome/types.ts new file mode 100644 index 0000000..7c36c63 --- /dev/null +++ b/lib/views/genome/types.ts @@ -0,0 +1,45 @@ +// The genome view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Genome-wide copy-number frequency: the share of each cohort's segmented samples gained or lost + * in every bin along the genome, from the copy-number SEGMENTS (not the per-gene calls), with a + * per-bin Fisher test when exactly two cohorts are drawn. Counts only. + */ +export interface GenomeView { + note?: string; + kind: "genome"; cohorts?: string[]; + /** bin width in megabases, 1-50 (default 2) */ + binMb?: number; + /** |log2 segment mean| at or past which a bin is gained / lost (default 0.2) */ + threshold?: number; + /** draw chromosome X too (default false: its log ratio mostly records sex) */ + includeX?: boolean; +} +export interface GenomeSeries { + cohort: string; + /** the cohort's samples, and how many of them have any segment at all */ + samples: number; segmented: number; + /** per bin, aligned with GenomeResult.bins */ + profiled: number[]; gain: number[]; loss: number[]; +} +export interface GenomeResult { + build: "hg19" | "hg38"; binMb: number; threshold: number; includeX: boolean; + /** every bin, in genome order: its chromosome and start (bp) */ + bins: { chrom: string; start: number }[]; + series: GenomeSeries[]; + /** exactly two cohorts: Benjamini–Hochberg q per bin (null where either cohort has no sample) */ + test?: { gainQ: (number | null)[]; lossQ: (number | null)[]; significantGain: number; significantLoss: number }; + /** studies left out because they could not answer (a remote peer, or no segments at all) */ + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { genome: GenomeView } + interface ViewResultFields { + genome: { + genome?: GenomeResult; + }; + } + interface ViewResultMain { genome: "genome" } +} diff --git a/lib/figures/germline.ts b/lib/views/germline/figure.ts similarity index 95% rename from lib/figures/germline.ts rename to lib/views/germline/figure.ts index 28caf1b..dd06025 100644 --- a/lib/figures/germline.ts +++ b/lib/views/germline/figure.ts @@ -1,9 +1,10 @@ // Germline figure: per cohort, one row per gene — its carriers among the patients sequenced, and two // bars, the carriers' somatic second-hit rate against the non-carriers' somatic rate — then OS by // carrier status for one gene. -import type { DrawnResultOf, GermlineGene } from "../types.ts"; -import { palette, frame, caption, label, legend, textWidth, linear, axisBottom, bottomAxisHeight, seriesStyle, fmtInt, fmtPct, fmtNum, fmtP, pDigits, num, FS, SW, type SvgOpts } from "../svg-kit.ts"; -import { kmPanel } from "./km.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { palette, frame, caption, label, legend, textWidth, linear, axisBottom, bottomAxisHeight, seriesStyle, fmtInt, fmtPct, fmtNum, fmtP, pDigits, num, FS, SW, type SvgOpts } from "../../svg-kit.ts"; +import { kmPanel } from "../../figures/km.ts"; +import type { GermlineGene } from "./types.ts"; const PAD = 8, ROW = 30, BAR = 9; const CAR = seriesStyle(0).color, NON = seriesStyle(1).color; diff --git a/lib/views/germline.ts b/lib/views/germline/run.ts similarity index 95% rename from lib/views/germline.ts rename to lib/views/germline/run.ts index d72534a..963e934 100644 --- a/lib/views/germline.ts +++ b/lib/views/germline/run.ts @@ -1,11 +1,13 @@ // Germline view: per gene, the patients carrying a germline variant, the share of the patients // sequenced for the gene they make up, and how often their tumour has a somatic second hit in the // same gene (another mutation, or a deep deletion) against the somatic rate in non-carriers. -import type { ViewResult, GermlineView, GermlineGene, GermlineCounts } from "../types.ts"; -import { germCarriersSql, germCountsSql, germSurvivalSql, ascnStudiesSql } from "../federation-views.ts"; -import { fisherExact, benjaminiHochberg, kaplanMeier, logRankK, type SurvRecord } from "../stats.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, plural } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { ascnStudiesSql } from "../../federation-views.ts"; +import { fisherExact, benjaminiHochberg, kaplanMeier, logRankK, type SurvRecord } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, plural } from "../shared.ts"; +import type { GermlineView, GermlineGene, GermlineCounts } from "./types.ts"; +import { germCarriersSql, germCountsSql, germSurvivalSql } from "./sql.ts"; const GERM_MAX_GENES = 60, GERM_COHORT_LIMIT = 4, KM_MIN = 5; @@ -72,7 +74,7 @@ export async function runGermline(view: GermlineView, ctx: ViewRun): Promise ascnStudiesSql(s, d)).catch(() => [])).map((r) => String(r.study))); const panels: { cohort: string; patients: number; genes: GermlineGene[] }[] = []; - const survival: NonNullable = []; + const survival: NonNullable = []; for (const n of vNames) { const counts = new Map(genes.map((g) => [g, blank()])); const recs: [Map, Map] = [new Map(), new Map()]; diff --git a/lib/views/germline/sql.ts b/lib/views/germline/sql.ts new file mode 100644 index 0000000..9ebe68c --- /dev/null +++ b/lib/views/germline/sql.ts @@ -0,0 +1,88 @@ +// The germline view's SQL: what its runner (run.ts) asks each participant's database for. +import { pidOf, geneList } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; +import { finiteSql } from "../../sql-number.ts"; +import { clinicalTable } from "../../custom-data.ts"; + +/** Per gene: the cohort's patients with a germline call in it. Counts only. */ +export function germCarriersSql(cohortSql: string, study: string, db: string): string { + const gPid = pidOf("g.patient_unique_id", study); + return "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT g.hugo_gene_symbol AS gene, uniqExact(" + gPid + ") AS carriers\n" + + "FROM " + db + ".genomic_event_derived g\n" + + "WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation' AND " + GERMLINE + "\n" + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + "GROUP BY gene ORDER BY carriers DESC, gene LIMIT 500"; +} + +/** + * Per gene and combination of: profiled for mutations (mp), a germline call (carrier), a somatic + * mutation (som, any call not germline and not uncalled, on a covering panel), profiled for copy number + * (cp) and a deep deletion (del): the number of patients. Carriers whose tumour panel does not list the + * gene appear with mp = 0. Counts only. + */ +export function germCountsSql(cohortSql: string, study: string, db: string, genes: string[]): string { + return germCtes(cohortSql, study, db, genes) + ",\n" + + "fl AS (\n" + + " SELECT gene, toUInt8((pid, gene) IN (SELECT pid, gene FROM mprof)) AS mp, toUInt8((pid, gene) IN (SELECT pid, gene FROM germ)) AS carrier,\n" + + " toUInt8((pid, gene) IN (SELECT pid, gene FROM som)) AS som, toUInt8((pid, gene) IN (SELECT pid, gene FROM cprof)) AS cp,\n" + + " toUInt8((pid, gene) IN (SELECT pid, gene FROM del)) AS del\n" + + " FROM base\n" + + ")\n" + + "SELECT gene, mp, carrier, som, cp, del, count() AS n FROM fl GROUP BY gene, mp, carrier, som, cp, del"; +} + +/** Overall survival of the patients profiled for `gene`, by carrier status: counts of (carrier, months, dead). Counts only. */ +export function germSurvivalSql(cohortSql: string, study: string, db: string, gene: string): string { + const pid = pidOf("patient_unique_id", study); + return germCtes(cohortSql, study, db, [gene]) + ",\n" + + "os AS (\n" + + " SELECT " + pid + " AS pid, " + finiteSql("anyIf(attribute_value, attribute_name = 'OS_MONTHS')") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = 'OS_STATUS') LIKE '1%' AS dead\n" + + " FROM " + clinicalTable(db, ["OS_MONTHS", "OS_STATUS"]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN ('OS_MONTHS', 'OS_STATUS')\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "sv AS (\n" + + " SELECT toUInt8(mprof.pid IN (SELECT pid FROM germ)) AS carrier, os.months AS months, os.dead AS dead\n" + + " FROM mprof INNER JOIN os ON os.pid = mprof.pid WHERE os.months IS NOT NULL AND os.months >= 0\n" + + ")\n" + + "SELECT carrier, round(months, 2) AS t, dead, count() AS n FROM sv GROUP BY carrier, t, dead ORDER BY t"; +} + +// --------------------------------------------------------------------------- +// germline: carriers of a germline variant and their somatic second hits + +export const GERMLINE = "lowerUTF8(trimBoth(g.mutation_status)) = 'germline'"; + +/** (patient, gene) pairs a panel of theirs covers for one alteration type (whole-exome covers all), over `genes`. */ +function germProfiledCte(name: string, study: string, db: string, alterationType: string, list: string): string { + const sdPid = pidOf("sd.patient_unique_id", study); + return name + "p AS (\n" + + " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(alterationType) + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + name + " AS (\n" + + " SELECT DISTINCT x.pid AS pid, gp.gene AS gene FROM " + name + "p x JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = x.panel WHERE gp.gene IN (" + list + ")\n" + + " UNION DISTINCT\n" + + " SELECT pid, arrayJoin([" + list + "]) AS gene FROM " + name + "p WHERE panel = 'WES'\n" + + ")"; +} + +function germCtes(cohortSql: string, study: string, db: string, genes: string[]): string { + const gPid = pidOf("g.patient_unique_id", study), list = geneList(genes); + const calls = (name: string, cond: string) => name + " AS (\n" + + " SELECT DISTINCT " + gPid + " AS pid, g.hugo_gene_symbol AS gene FROM " + db + ".genomic_event_derived g\n" + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.hugo_gene_symbol IN (" + list + ") AND " + cond + "\n" + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + ")"; + return "WITH cohort AS (" + cohortSql + "),\n" + + germProfiledCte("mprof", study, db, "MUTATION_EXTENDED", list) + ",\n" + + germProfiledCte("cprof", study, db, "COPY_NUMBER_ALTERATION", list) + ",\n" + + calls("germ", "g.variant_type = 'mutation' AND " + GERMLINE) + ",\n" + + calls("som", "g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED' AND NOT (" + GERMLINE + ") AND NOT g.off_panel") + ",\n" + + calls("del", "g.variant_type = 'cna' AND g.cna_alteration <= -2 AND NOT g.off_panel") + ",\n" + + "base AS (SELECT pid, gene FROM mprof UNION DISTINCT SELECT pid, gene FROM germ)"; +} diff --git a/lib/views/germline/types.ts b/lib/views/germline/types.ts new file mode 100644 index 0000000..2defdd8 --- /dev/null +++ b/lib/views/germline/types.ts @@ -0,0 +1,47 @@ +// The germline view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { KmCurve, LogRankK } from "../../types.ts"; + +/** + * Germline carriers and their somatic second hits: per gene, the patients with a germline call, + * their share of the patients sequenced for the gene, and how often the tumour carries a second, + * somatic hit in the same gene, against the somatic rate in non-carriers; optionally OS by carrier + * status for one gene. Patient-level counts only. + */ +export interface GermlineView { + note?: string; + kind: "germline"; cohorts?: string[]; + /** the genes (at most 60); default every gene with at least minCarriers carriers in a cohort */ + genes?: string[]; + minCarriers?: number; + /** draw OS for one gene's carriers against non-carriers */ + survival?: { gene: string }; +} +export interface GermlineCounts { + /** patients sequenced for the gene (a mutation panel listing it, or WES), carriers among them, and carriers whose panel does not list it (left out) */ + sequenced: number; carriers: number; outsidePanel: number; + carrierSomatic: number; carrierCnaProfiled: number; carrierDeletion: number; carrierEither: number; + noncarriers: number; noncarrierSomatic: number; noncarrierCnaProfiled: number; noncarrierDeletion: number; noncarrierEither: number; +} +export interface GermlineGene extends GermlineCounts { + gene: string; + /** Fisher's exact test of carriers' second-hit rate against non-carriers' somatic rate, and BH q */ + p: number | null; q: number | null; +} +export interface GermlineResult { + genes: string[]; minCarriers: number; chosen?: string; + panels: { cohort: string; patients: number; genes: GermlineGene[] }[]; + survival?: { cohort: string; gene: string; curves: (Omit & { group: string })[]; logrank: LogRankK | null; noTest?: string }[]; + /** what was done about loss of heterozygosity */ + loh: string; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { germline: GermlineView } + interface ViewResultFields { + germline: { + germline?: GermlineResult; + }; + } + interface ViewResultMain { germline: "germline" } +} diff --git a/lib/figures/hla.ts b/lib/views/hla/figure.ts similarity index 97% rename from lib/figures/hla.ts rename to lib/views/hla/figure.ts index 86d0879..a3baaf2 100644 --- a/lib/figures/hla.ts +++ b/lib/views/hla/figure.ts @@ -1,10 +1,11 @@ // HLA figure: per cohort, the share of fully typed patients homozygous at 0, 1 or 2+ class I loci // and the share with HLA LOH, as 100% bars; then the endpoint by homozygosity and by HLA LOH. -import type { DrawnResultOf, HlaSplit, HlaHr } from "../types.ts"; +import type { DrawnResultOf } from "../../types.ts"; import { esc, palette, frame, caption, label, legend, paragraph, textWidth, textOn, fmtInt, fmtShare, fmtP, fmtRatio, fmtMonths, FS, SERIES, OTHER, r1, type SvgOpts -} from "../svg-kit.ts"; -import { kmPanel } from "./km.ts"; +} from "../../svg-kit.ts"; +import { kmPanel } from "../../figures/km.ts"; +import type { HlaSplit, HlaHr } from "./types.ts"; const PAD = 8, BAR = 16, ROWH = 24, GAP = 28; const HOM_COLORS = [SERIES[0], SERIES[1], SERIES[2]], LOSS = SERIES[5]; diff --git a/lib/views/hla.ts b/lib/views/hla/run.ts similarity index 97% rename from lib/views/hla.ts rename to lib/views/hla/run.ts index 459fb93..3a61b65 100644 --- a/lib/views/hla.ts +++ b/lib/views/hla/run.ts @@ -1,11 +1,13 @@ // HLA view: class I homozygosity and HLA loss of heterozygosity per cohort, and overall survival by // each (Chowell et al. 2018; McGranahan et al. 2017). Per-patient rows from patient-level studies. -import type { ViewResult, HlaView, HlaPanel, HlaSplit, HlaHr, HlaResult } from "../types.ts"; -import { hasViewAggregates, isPatientLevel } from "../federation.ts"; -import { hlaRowsSql, hlaAttributesSql, type HlaRowsOpts } from "../federation-views.ts"; -import { kaplanMeier, logRank, coxRegression, type SurvRecord } from "../stats.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, plural, refuse, ATTR_NAME } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { hasViewAggregates, isPatientLevel } from "../../federation.ts"; +import { kaplanMeier, logRank, coxRegression, type SurvRecord } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, plural, refuse, ATTR_NAME } from "../shared.ts"; +import type { HlaView, HlaPanel, HlaSplit, HlaHr, HlaResult } from "./types.ts"; +import { hlaRowsSql, hlaAttributesSql } from "./sql.ts"; +import type { HlaRowsOpts } from "./sql.ts"; const LOCI = ["A", "B", "C"] as const; /** fewer patients than this in either group and that split is not drawn */ diff --git a/lib/views/hla/sql.ts b/lib/views/hla/sql.ts new file mode 100644 index 0000000..2e6083f --- /dev/null +++ b/lib/views/hla/sql.ts @@ -0,0 +1,98 @@ +// The hla view's SQL: what its runner (run.ts) asks each participant's database for. +// hla: HLA class I genotype and HLA LOH per patient +import { pidOf } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; +import { clinicalTable } from "../../custom-data.ts"; +import { clinicalValueFoldSql } from "../../event-names.ts"; +import { finiteSql } from "../../sql-number.ts"; + +export interface HlaRowsOpts { + /** per locus A, B, C: the attributes that may hold its first allele, its second, or both as one "x/y" value */ + loci: { first: string[]; second: string[]; pair: string[] }[]; + /** allele-name fields compared (1, 2, or 99 for all) */ + fields: number; + lohAttributes: string[]; lohValues: string[]; + months: string; status: string; + covariates: string[]; +} + +/** an allele's name as its fields: hla_a_02_01_01_01, A*02:01 and A0201 all start ["02", "01"] */ +const hlaFieldsSql = (x: string) => { + const y = "replaceRegexpOne(lowerUTF8(" + x + "), '^(hla[-_]?)?[abc][*_]?', '')"; + return "if(match(" + y + ", '[_:]'), splitByRegexp('[_:]', " + y + "), [substring(" + y + ", 1, 2), substring(" + y + ", 3)])"; +}; + +/** + * One row per patient of the cohort: per locus whether its two alleles match at `fields` fields + * (hA, hB, hC; NULL when either allele is missing), the fewest fields any allele was typed at (nf), + * HLA LOH in any sample (loss; NULL when no sample has a call), the endpoint (m, ev) and each + * covariate as c0, c1, … (the patient's, or the first sample's). Values only: the patient id joins + * the CTEs and is never returned. Run only through PatientLevelResolver.viewRows. + */ +export function hlaRowsSql(cohortSql: string, study: string, db: string, o: HlaRowsOpts): string { + const names = [...o.loci.flatMap((l) => [...l.first, ...l.second, ...l.pair]), ...o.lohAttributes, o.months, o.status, ...o.covariates]; + for (const a of names) if (!/^[A-Za-z0-9_:/.-]{1,128}$/.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); + const pid = pidOf("patient_unique_id", study); + const inList = (xs: string[]) => xs.length ? "k IN (" + xs.map(sq).join(", ") + ")" : "0"; + const allele = (l: HlaRowsOpts["loci"][number], i: 1 | 2) => { + const own = "anyIf(v, " + inList(i === 1 ? l.first : l.second) + ")"; + return "if(" + own + " != '', " + own + ", splitByRegexp('\\\\s*[/,;]\\\\s*', anyIf(v, " + inList(l.pair) + "))[" + i + "])"; + }; + const genoAttrs = o.loci.flatMap((l) => [...l.first, ...l.second, ...l.pair]); + const fields = Math.max(1, Math.min(99, Math.floor(o.fields))); + const key = (x: string) => "arrayStringConcat(arraySlice(" + hlaFieldsSql(x) + ", 1, " + fields + "), ':')"; + const homAt = (i: number) => "if(k[" + (2 * i + 1) + "] = '' OR k[" + (2 * i + 2) + "] = '', NULL, toUInt8(k[" + (2 * i + 1) + "] = k[" + (2 * i + 2) + "]))"; + const covs = o.covariates; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "gt AS (\n" + + " SELECT pid, [" + o.loci.flatMap((l) => [allele(l, 1), allele(l, 2)]).join(",\n ") + "] AS raw\n" + + " FROM (SELECT " + pid + " AS pid, attribute_name AS k, trimBoth(attribute_value) AS v FROM " + clinicalTable(db, genoAttrs) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + genoAttrs.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort))\n" + + " GROUP BY pid\n" + + "),\n" + + "keys AS (\n" + + " SELECT pid, arrayMap(x -> if(x = '', '', " + key("x") + "), raw) AS k,\n" + + " arrayMin(arrayMap(x -> if(x = '', 99, length(" + hlaFieldsSql("x") + ")), raw)) AS nf0\n" + + " FROM gt\n" + + "),\n" + + "hom AS (SELECT pid, " + homAt(0) + " AS hA, " + homAt(1) + " AS hB, " + homAt(2) + " AS hC, toNullable(nf0) AS nf FROM keys),\n" + + "loh AS (\n" + + " SELECT " + pid + " AS pid, toNullable(max(" + clinicalValueFoldSql("attribute_value") + " IN (" + o.lohValues.map((v) => clinicalValueFoldSql(sq(v))).join(", ") + "))) AS loss\n" + + " FROM " + clinicalTable(db, o.lohAttributes) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + o.lohAttributes.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "os AS (\n" + + " SELECT " + pid + " AS pid,\n" + + " " + finiteSql("anyIf(attribute_value, attribute_name = " + sq(o.months) + ")") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = " + sq(o.status) + ") AS status\n" + + " FROM " + clinicalTable(db, [o.months, o.status]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN (" + sq(o.months) + ", " + sq(o.status) + ")\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")" + + (covs.length + ? ",\ncov AS (\n" + + " SELECT " + pid + " AS pid" + covs.map((c, i) => ",\n toNullable(argMinIf(trimBoth(attribute_value), sample_unique_id, attribute_name = " + sq(c) + ")) AS c" + i).join("") + "\n" + + " FROM " + clinicalTable(db, covs) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + covs.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")\n" + : "\n") + + "SELECT hom.hA AS hA, hom.hB AS hB, hom.hC AS hC, hom.nf AS nf, loh.loss AS loss,\n" + + " if(os.months >= 0, os.months, NULL) AS m, if(os.status LIKE '1%', 1, 0) AS ev" + covs.map((_, i) => ", cov.c" + i + " AS c" + i).join("") + "\n" + + "FROM cohort LEFT JOIN hom ON hom.pid = cohort.pid LEFT JOIN loh ON loh.pid = cohort.pid LEFT JOIN os ON os.pid = cohort.pid" + + (covs.length ? " LEFT JOIN cov ON cov.pid = cohort.pid" : "") + ); +} + +/** the attribute names in a study that mention HLA, with how many values each holds */ +export function hlaAttributesSql(_cohortSql: string, study: string, db: string): string { + return "SELECT attribute_name AS name, any(type) AS type, count() AS n FROM " + clinicalTable(db, []) + "\n" + + "WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name ILIKE '%hla%' AND trimBoth(attribute_value) != ''\n" + + "GROUP BY name ORDER BY name LIMIT 40"; +} diff --git a/lib/views/hla/types.ts b/lib/views/hla/types.ts new file mode 100644 index 0000000..73c9d6f --- /dev/null +++ b/lib/views/hla/types.ts @@ -0,0 +1,63 @@ +// The hla view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { KmCurve, LogRank } from "../../types.ts"; + +/** + * HLA class I genotype and HLA loss of heterozygosity against outcome (Chowell et al. 2018; + * McGranahan et al. 2017): per cohort, the share of fully typed patients homozygous at 0, 1 or + * 2+ of HLA-A, -B and -C, the share with HLA LOH in a tumour sample, and overall survival by + * homozygous-at-any-locus against fully heterozygous and by HLA LOH against none. + */ +export interface HlaView { + note?: string; + kind: "hla"; cohorts?: string[]; + /** fields of the allele name compared: 1 (allele group, A*02), 2 (protein, A*02:01; default) or "full" */ + resolution?: 1 | 2 | "full"; + /** per locus, the attributes holding its two alleles ([first, second]) or one holding both ("A*02:01/A*24:02"); default: the layouts MSK-IMPACT and the melanoma/NSCLC immunotherapy studies use */ + alleles?: { A?: string[]; B?: string[]; C?: string[] }; + /** sample attributes calling HLA LOH (default HLA_A1_LOH … HLA_C2_LOH, HLA_LOH) and the values that mean loss (default Loss, Yes, LOH, True) */ + lohAttributes?: string[]; lohValues?: string[]; + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** clinical attributes the adjusted Cox fit adds (at most 4; numbers as they are, categories against the commonest) */ + covariates?: string[]; +} +export interface HlaHr { hr: number; ciLow: number; ciHigh: number; p: number; n: number; events: number } +export interface HlaSplit { + /** the two groups' OS curves (exposed first), the log-rank test (stratified by study when pooled) and Cox HRs of exposed against not */ + curves: (Omit & { label: string })[]; + logrank: LogRank | null; + hr: HlaHr | null; + /** with covariates: the HR adjusted for them, on the patients with every covariate */ + adjusted: HlaHr | null; +} +export interface HlaPanel { + cohort: string; + /** cohort patients, patients with any class I allele recorded, and those typed at all three loci */ + patients: number; anyTyped: number; typed: number; + /** fully typed patients homozygous at 0, 1, 2 and 3 loci */ + homozygous: [number, number, number, number]; + /** per locus (A, B, C): patients typed there and homozygous there */ + loci: { locus: "A" | "B" | "C"; typed: number; homozygous: number }[]; + /** typed at fewer allele-name fields than the resolution asks (compared at what they have) */ + coarser: number; + /** patients with an HLA LOH call in some sample, and those with a loss */ + lohEvaluated: number; loh: number; + /** OS by homozygous at ≥ 1 locus vs none, and by HLA LOH vs none; null with why when not drawn */ + byHomozygosity: HlaSplit | null; byHomozygosityWhy?: string; + byLoh: HlaSplit | null; byLohWhy?: string; + /** patients without the endpoint, and (with covariates) without a covariate, left out of the fits */ + noOutcome: number; noCovariate: number; +} +export interface HlaResult { + resolution: 1 | 2 | "full"; endpoint: string; covariates: string[]; + panels: HlaPanel[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { hla: HlaView } + interface ViewResultFields { + hla: { + hla?: HlaResult; + }; + } + interface ViewResultMain { hla: "hla" } +} diff --git a/lib/views/hotspots.ts b/lib/views/hotspots/run.ts similarity index 94% rename from lib/views/hotspots.ts rename to lib/views/hotspots/run.ts index bfbdf71..b42edbe 100644 --- a/lib/views/hotspots.ts +++ b/lib/views/hotspots/run.ts @@ -1,9 +1,10 @@ // Hotspots view: a lollipop of mutation counts per protein position for each gene. -import { geneSymbols } from "../gene-tracks.ts"; -import { buildLollipopGene, fetchProteinInfo, checkHide } from "../lollipop.ts"; -import type { ViewResult, HotspotsView, HotspotGene } from "../types.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { overLimit } from "./shared.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import { buildLollipopGene, fetchProteinInfo, checkHide } from "../../lollipop.ts"; +import type { ViewResult } from "../../types.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { overLimit } from "../shared.ts"; +import type { HotspotsView, HotspotGene } from "./types.ts"; export async function runHotspots(view: DocView, c: ViewRun): Promise { const { doc, cohorts, resolvers, vNames, participationFor } = c; diff --git a/lib/views/hotspots/types.ts b/lib/views/hotspots/types.ts new file mode 100644 index 0000000..ad1b745 --- /dev/null +++ b/lib/views/hotspots/types.ts @@ -0,0 +1,65 @@ +// The hotspots view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +export interface HotspotsView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; kind: "hotspots"; genes: string[]; cohorts?: string[]; minCount?: number; + /** + * The cells of the Driver/VUS × alteration class grid left out of the plot ("missense.vus", + * "truncating.driver", …): the grid beside it selects them. Applied where the figure is drawn, + * so a change re-runs nothing; the counts in the grid stay whole. + */ + hide?: string[] } +/** Recurrent protein changes in one gene, with the cohort's mutated total. */ +/** One drawn stem: a codon, its height (distinct patient + change pairs), and how it is coloured. */ +export interface LollipopPosition { + pos: number; n: number; color: string; impact: string; driver: boolean; + /** every change at this codon, by raw rows then name — the label and tooltip read these; each with its + * protein impact class and driver status, so a selection can leave it out (no class: a site on older code) */ + changes: { change: string; n: number; rows: number; impact?: string; driver?: boolean }[]; + /** the raw rows here by mutation type and driver status — the colour, recomputed when cells are hidden */ + types?: { mtype: string; driver: boolean; rows: number }[]; +} +export interface LollipopDomain { accession: string; name: string; start: number; end: number; color: string } +/** + * One gene's lollipop, drawn the way cBioPortal's results-page mutation tab draws it (see + * lib/lollipop.ts): one stem per codon, height = distinct (patient, protein change) pairs. + */ +export interface HotspotGene { + gene: string; + /** patients with any mutation in the gene, and distinct (patient, change) pairs */ + mutated: number; mutations: number; + positions: LollipopPosition[]; + domains: LollipopDomain[]; + proteinLength: number; + /** genome-nexus: the canonical transcript; observed: the service was unreachable, so the highest codon seen */ + lengthSource: "genome-nexus" | "observed"; transcript?: string; + /** pairs not drawn because they have no codon (or one past the protein) */ + unpositioned: number; + /** pairs a site withheld under its suppression floor */ + withheld: number; + label?: { pos: number; text: string }; + /** the commonest changes, for text summaries (chat, MCP) */ + topChanges: { change: string; pos: number | null; n: number; pct: number }[]; + /** samples with a somatic mutation in the gene, and samples in the cohort */ + somaticSamples: number; cohortSamples: number; + /** + * Mutation rows by protein impact type, putative driver vs VUS — the portal's badge tally + * beside the lollipop. Every row counts, drawn or not; `other` covers silent and unknown types. + */ + tally: Record<"missense" | "truncating" | "inframe" | "splice" | "fusion" | "other", { driver: number; vus: number }>; + /** the transcript's other identifiers, as the portal's gene summary lists them */ + ids?: { refseq?: string; ensembl?: string; ccds?: string; uniprotAccession?: string; uniprotName?: string }; +} + +declare module "../../types.ts" { + interface ViewSpecs { hotspots: HotspotsView } + interface ViewResultFields { + hotspots: { + hotspots?: HotspotGene[]; + /** the cells the view hides, as applied to its figure (`hotspots` itself stays whole, so the app can re-filter it) */ + hide?: string[]; + }; + } + interface ViewResultMain { hotspots: "hotspots" } +} diff --git a/lib/figures/incidence.ts b/lib/views/incidence/figure.ts similarity index 95% rename from lib/figures/incidence.ts rename to lib/views/incidence/figure.ts index b71db9e..a2aaa8f 100644 --- a/lib/figures/incidence.ts +++ b/lib/views/incidence/figure.ts @@ -3,15 +3,15 @@ import { palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, legend, label, textWidth, seriesStyle, fmtInt, fmtNum, fmtPct, fmtCI, fmtP, pDigits, FS, SW, type SvgOpts -} from "../svg-kit.ts"; -import type { DrawnResultOf } from "../types.ts"; -import { Z_975 } from "../stats.ts"; -import { stepPath } from "../chart/marks.ts"; -import { plotHeight } from "./common.ts"; -import { timeScale, timeTitle } from "../chart/time.ts"; -import { paragraph } from "../chart/text.ts"; -import { lineSwatch } from "../chart/legend.ts"; -import { r1 } from "../chart/marks.ts"; +} from "../../svg-kit.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { Z_975 } from "../../stats.ts"; +import { stepPath } from "../../chart/marks.ts"; +import { plotHeight } from "../../figures/common.ts"; +import { timeScale, timeTitle } from "../../chart/time.ts"; +import { paragraph } from "../../chart/text.ts"; +import { lineSwatch } from "../../chart/legend.ts"; +import { r1 } from "../../chart/marks.ts"; /** the competing event's line: thin and dotted, so it never reads as a seventh cohort's dashed one */ const COMPETING_DASH = "2 2.5"; diff --git a/lib/views/incidence/method.ts b/lib/views/incidence/method.ts new file mode 100644 index 0000000..664ea61 --- /dev/null +++ b/lib/views/incidence/method.ts @@ -0,0 +1,124 @@ +// The incidence view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, pFmt, pyTable, RESULT_INIT, VERIFIED, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +/** python/golden/gray_null.py verbatim — the Python port of our seeded Gray permutation test; + * test/incidence-golden.test.ts holds the two to the same text and the port to our exact p. */ +export const GRAY_NULL_PY = "import math\n\n\ndef mulberry32(seed):\n a = seed & 0xFFFFFFFF\n\n def rnd():\n nonlocal a\n a = (a + 0x6D2B79F5) & 0xFFFFFFFF\n t = a\n t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF\n t ^= (t + ((t ^ (t >> 7)) * (t | 61) & 0xFFFFFFFF)) & 0xFFFFFFFF\n return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296\n\n return rnd\n\n\ndef gray_statistic(t, cause, entry, group, K, order, entry_order):\n \"\"\"Sum over groups of (observed - expected)^2 / expected cause-1 events, expected from the pooled\n subdistribution hazard with each group's risk set Y_k (1 - F1_k) / S_k (Gray 1988, weight 1).\n order / entry_order: the records' indices sorted by exit and by entry time.\"\"\"\n n = len(t)\n Y, S, F = [0.0] * K, [1.0] * K, [0.0] * K\n z, E = [0.0] * K, [0.0] * K\n i = ei = 0\n while i < n:\n tj = t[order[i]]\n while ei < n and entry[entry_order[ei]] < tj:\n Y[group[entry_order[ei]]] += 1\n ei += 1\n d1, d2, cc = [0.0] * K, [0.0] * K, [0.0] * K\n while i < n and t[order[i]] == tj:\n r = order[i]\n i += 1\n if cause[r] == 1:\n d1[group[r]] += 1\n elif cause[r] == 2:\n d2[group[r]] += 1\n else:\n cc[group[r]] += 1\n R = [Y[k] * (1 - F[k]) / S[k] if Y[k] > 0 and S[k] > 0 else 0.0 for k in range(K)]\n D1, Rtot = 0.0, 0.0\n for k in range(K):\n D1 += d1[k]\n Rtot += R[k]\n if D1 > 0 and Rtot > 0:\n for k in range(K):\n e = R[k] * D1 / Rtot\n z[k] += d1[k] - e\n E[k] += e\n for k in range(K):\n if Y[k] > 0:\n F[k] += S[k] * d1[k] / Y[k]\n S[k] *= 1 - (d1[k] + d2[k]) / Y[k]\n Y[k] -= d1[k] + d2[k] + cc[k]\n stat = 0.0\n for k in range(K):\n if E[k] > 0:\n stat += z[k] * z[k] / E[k]\n return stat\n\n\ndef gray_permutation_test(groups, permutations=2000, seed=1):\n \"\"\"groups: per cohort, a list of (t, cause, entry or None). Returns (statistic, p): p = (b + 1)/(m + 1)\n over `permutations` seeded shuffles of the cohort labels across the pooled records.\"\"\"\n K = len(groups)\n rows = [(r[0], r[1], -math.inf if r[2] is None else r[2], k) for k, g in enumerate(groups) for r in g]\n t = [r[0] for r in rows]\n cause = [r[1] for r in rows]\n entry = [r[2] for r in rows]\n labels = [r[3] for r in rows]\n order = sorted(range(len(t)), key=lambda i: t[i])\n entry_order = sorted(range(len(t)), key=lambda i: entry[i])\n observed = gray_statistic(t, cause, entry, labels, K, order, entry_order)\n rnd = mulberry32(seed)\n perm = list(labels)\n n = len(perm)\n beyond = 0\n for _ in range(permutations):\n for i in range(n - 1, 0, -1):\n j = math.floor(rnd() * (i + 1))\n perm[i], perm[j] = perm[j], perm[i]\n if gray_statistic(t, cause, entry, perm, K, order, entry_order) >= observed - 1e-12:\n beyond += 1\n return observed, (beyond + 1) / (permutations + 1)\n"; + +export interface IncidenceTrace { + eventLabel: string; + anchor?: string; + horizons: number[]; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + cohorts: { name: string; n: number; events: number; competing: number; censored: number; late: number; + excluded: { prior: number; unobserved: number; noFollowup: number; noAnchor: number } }[]; + /** every (entry, t, cause) count the queries returned for patients who enter the risk set */ + counts: { cohort: string; study: string; entry: number; t: number; cause: number; n: number }[]; + gray?: { statistic: number; p: number; permutations: number }; + /** the view's estimate and interval at each horizon — for the script check */ + horizonResults: { cohort: string; months: number; f1: number; lo: number; hi: number }[]; +} + +export function incidenceMethod(t: IncidenceTrace): MethodRecord { + const hasEntry = t.counts.some((r) => r.entry > 0); + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Classify and count each patient's outcome", + detail: "The incidence query above. Per patient, months from " + (t.anchor ? "the " + t.anchor : "the study's day 0") + + " (days ÷ 30.4375, rounded to 4 decimals) to the first " + t.eventLabel + " after it (cause 1), death without it (cause 2, from OS_STATUS), or last follow-up (censored, OS_MONTHS)" + + (t.anchor ? ", and the delay from the anchor to day 0 as a delayed entry when the anchor comes first" : "") + + "; then counted by (entry, months, cause). Counts only.", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + })); + steps.push({ kind: "transform", title: "Set aside patients who cannot enter the risk set", + detail: "Named and counted rather than treated as event-free. A patient whose first event was recorded after their last follow-up is kept, as censored or dead at follow-up.", + params: t.cohorts.map((c) => [c.name, c.excluded.prior + " already had it " + (t.anchor ? "at or before the anchor" : "by day 0") + + (c.excluded.unobserved ? " · " + c.excluded.unobserved + " had it before observation began" : "") + + " · " + c.excluded.noFollowup + " without follow-up" + (t.anchor ? " · " + c.excluded.noAnchor + " with no anchor event" : "") + + (c.late ? " · " + c.late + " recorded late, kept" : "")] as [string, string]) }); + steps.push({ kind: "statistic", title: "Estimate cumulative incidence by Aalen–Johansen", + detail: "Death is a competing risk: at each time F1 rises by S(t−)·d1/R, with S the all-cause Kaplan–Meier survival and R those at risk" + + (hasEntry ? " (entered strictly before t: delayed entry honoured)" : "") + + ". Standard error by the delta method (Aalen 1978); 95% interval F1 ± 1.959964·SE, clamped to [0, 1]. At each horizon the curve's value there, beside 1 − Kaplan–Meier of the event alone, which counts the dead as still at risk and overstates incidence.", + params: [["horizons (months)", t.horizons.join(", ")], + ...t.cohorts.map((c) => [c.name, c.n.toLocaleString() + " at risk · " + c.events + " events · " + c.competing + " deaths first · " + c.censored + " censored"] as [string, string])] }); + if (t.gray) steps.push({ kind: "statistic", title: "Gray-type test by permutation", + detail: "Per cohort, observed cause-1 events minus those expected from the pooled subdistribution hazard, the subdistribution risk set being Y·(1 − F1)/S (Gray 1988, weight 1), summed as Σ (O − E)²/E over cohorts. This is not Gray's variance-standardised χ², so its p does not come from a χ² table: the cohort labels are shuffled across the pooled records " + + t.gray.permutations.toLocaleString() + " times by a generator seeded with 1, and p = (b + 1)/(m + 1). Exact under the null that every cohort's records, entry times included, come from one distribution.", + params: [["statistic / p", t.gray.statistic.toFixed(4) + " / " + pFmt(t.gray.p)]] }); + const python = [ + "# Recompute: cumulative incidence with statsmodels" + (hasEntry ? " (or, with delayed entry, the short function below — statsmodels takes no entry times and lifelines jitters tied ones)" : "") + + "; the Gray-type test with the port of our seeded permutation test below, which reproduces our p exactly (test/incidence-golden.test.ts).", + "import io", + "import numpy as np", + "import pandas as pd", + ...(hasEntry ? [] : ["from statsmodels.duration.survfunc import CumIncidenceRight"]), + "", + ...pyTable("counts", "What the incidence queries returned for the patients who enter the risk set: n per (entry, months, cause); cause 1 the event, 2 death first, 0 censored", + ["cohort", "study", "entry", "t", "cause", "n"], t.counts.map((r) => [r.cohort, r.study, r.entry, r.t, r.cause, r.n]), "incidence_counts.csv"), + "df = counts.loc[counts.index.repeat(counts[\"n\"])].reset_index(drop=True) # one row per patient", + "horizons = " + JSON.stringify(t.horizons), + RESULT_INIT, + "", + ...(hasEntry ? [ + "def aalen_johansen(t, cause, entry):", + " \"\"\"F1, F2 and the delta-method SE of F1 at each time anyone leaves; at risk = entered strictly before t, not yet left.\"\"\"", + " t, cause, entry = map(np.asarray, (t, cause, entry))", + " times = np.unique(t)", + " S, F1, F2, hist, out = 1.0, 0.0, 0.0, [], []", + " for x in times:", + " R = np.sum((entry < x) & (t >= x))", + " d1, d2 = np.sum((t == x) & (cause == 1)), np.sum((t == x) & (cause == 2))", + " if R <= 0:", + " continue", + " Sprev, d = S, d1 + d2", + " F1 += Sprev * d1 / R; F2 += Sprev * d2 / R; S *= 1 - d / R", + " hist.append((F1, Sprev, d, d1, R))", + " if d > 0:", + " v = sum(((F1 - hF1) ** 2 * hd / (hR * (hR - hd)) if hR - hd > 0 else 0) + hS ** 2 * hd1 * (hR - hd1) / hR ** 3 - 2 * (F1 - hF1) * hS * hd1 / hR ** 2", + " for hF1, hS, hd, hd1, hR in hist if hd > 0)", + " out.append((x, F1, F2, np.sqrt(max(v, 0))))", + " return pd.DataFrame(out, columns=[\"t\", \"f1\", \"f2\", \"se1\"])", + "", + ] : []), + "for cohort, g in df.groupby(\"cohort\", sort=False):", + ...(hasEntry ? [ + " cif = aalen_johansen(g[\"t\"], g[\"cause\"], np.where(g[\"entry\"] > 0, g[\"entry\"], -np.inf))", + ] : [ + " c = CumIncidenceRight(g[\"t\"].to_numpy(), g[\"cause\"].to_numpy())", + " cif = pd.DataFrame({\"t\": c.times, \"f1\": c.cinc[0], \"se1\": c.cinc_se[0]})", + ]), + " for h in horizons:", + " at = cif[cif[\"t\"] <= h].tail(1)", + " f1, se = (float(at[\"f1\"].iloc[0]), float(at[\"se1\"].iloc[0])) if len(at) else (0.0, 0.0)", + " lo, hi = max(0, f1 - 1.959963984540054 * se), min(1, f1 + 1.959963984540054 * se) # 95%: the exact normal quantile", + " print(f\"{cohort} at {h} months: {f1:.4f} (95% CI {lo:.4f}-{hi:.4f})\")", + " RESULT[f\"incidence:{cohort}:{h}:f1\"], RESULT[f\"incidence:{cohort}:{h}:lo\"], RESULT[f\"incidence:{cohort}:{h}:hi\"] = f1, lo, hi", + ...(t.gray ? [ + "", + "# ---- the port of our seeded Gray-type permutation test (python/golden/gray_null.py) ----", + ...GRAY_NULL_PY.trimEnd().split("\n"), + "# ----", + "", + "cohorts = " + JSON.stringify(t.cohorts.map((c) => c.name)), + "# records in the order the test sees them: by time, then cause, then entry (none before any)", + "groups = [[(r.t, r.cause, r.entry if r.entry > 0 else None)", + " for r in df[df[\"cohort\"] == c].sort_values([\"t\", \"cause\", \"entry\"], kind=\"stable\").itertuples()] for c in cohorts]", + "stat, p = gray_permutation_test(groups, permutations=" + t.gray.permutations + ", seed=1) # pure Python: minutes for a large cohort", + "print(f\"Gray-type statistic {stat:.4f}, permutation p {p:.4g}\")", + "RESULT[\"gray:statistic\"], RESULT[\"gray:p\"] = stat, p", + ] : []), + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: ["aalenJohansen", "cifAt", "kaplanMeier", ...(t.gray ? ["grayPermutationTest"] : [])], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "Cumulative incidence and its standard error match statsmodels " + VERIFIED.cif.version + " CumIncidenceRight to 1e-12, and with delayed entry lifelines' AalenJohansenFitter" + + (t.gray ? "; the Gray-type statistic and permutation p match the script's port exactly" : "") + " (test/incidence-golden.test.ts).", + python: withResult(python), + check: { + ...checks("incidence:", t.horizonResults, (h) => h.cohort + ":" + h.months, [["f1", "f1", { abs: 1e-9 }], ["lo", "lo", { abs: 1e-9 }], ["hi", "hi", { abs: 1e-9 }]]), + // the port reproduces the permutation test exactly + ...(t.gray ? { "gray:statistic": { v: t.gray.statistic, rel: 1e-12 }, "gray:p": { v: t.gray.p, abs: 0 } } : {}), + }, + }; +} diff --git a/lib/views/incidence.ts b/lib/views/incidence/run.ts similarity index 95% rename from lib/views/incidence.ts rename to lib/views/incidence/run.ts index 070a341..46cf2af 100644 --- a/lib/views/incidence.ts +++ b/lib/views/incidence/run.ts @@ -1,10 +1,12 @@ // Incidence view: cumulative incidence with death as a competing risk -import type { ViewResult, IncidenceView, IncidenceCurve } from "../types.ts"; -import { incidenceCountsSql } from "../federation-views.ts"; -import { Z_975, aalenJohansen, cifAt, grayPermutationTest, kaplanMeier, type CrRecord } from "../stats.ts"; -import { incidenceMethod, type IncidenceTrace } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse } from "./shared.ts"; +import type { ViewResult, IncidenceCurve } from "../../types.ts"; +import { incidenceCountsSql } from "../../federation-views.ts"; +import { Z_975, aalenJohansen, cifAt, grayPermutationTest, kaplanMeier, type CrRecord } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse } from "../shared.ts"; +import type { IncidenceView } from "./types.ts"; +import { incidenceMethod } from "./method.ts"; +import type { IncidenceTrace } from "./method.ts"; const INCIDENCE_COHORT_LIMIT = 6; diff --git a/lib/views/incidence/types.ts b/lib/views/incidence/types.ts new file mode 100644 index 0000000..56a129e --- /dev/null +++ b/lib/views/incidence/types.ts @@ -0,0 +1,36 @@ +// The incidence view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { IncidenceCurve } from "../../types.ts"; + +/** + * Cumulative incidence of a timeline event with death as a competing risk (Aalen–Johansen, with + * the survival view's left truncation), Gray's test between cohorts, and — beside it — what + * 1 − Kaplan–Meier would have claimed, which treats the dead as still at risk and so overstates + * incidence whenever death is common. Counts only: (entry, time, cause) tallies per site. + */ +export interface IncidenceView { + note?: string; + kind: "incidence"; cohorts?: string[]; + /** the event of interest: its first occurrence after the anchor */ + event: { eventType: string; attrs?: { key: string; value: string }[]; label?: string }; + /** the clock's start (default: the study's day 0), as in the survival view */ + anchor?: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; + /** months at which incidence is read off and compared (default [12, 24, 60]) */ + horizons?: number[]; +} +export interface IncidenceResult { + eventLabel: string; anchoredTo?: string; + curves: IncidenceCurve[]; + horizons: { months: number; cells: { cohort: string; f1: number; lo: number; hi: number; naive: number; atRisk: number }[] }[]; + gray?: import("../../stats.ts").GrayTest; + excludedStudies?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { incidence: IncidenceView } + interface ViewResultFields { + incidence: { + incidence?: IncidenceResult; + }; + } + interface ViewResultMain { incidence: "incidence" } +} diff --git a/lib/views/index.ts b/lib/views/index.ts index 1803ef4..e4542c9 100644 --- a/lib/views/index.ts +++ b/lib/views/index.ts @@ -1,54 +1,54 @@ // Every view kind's runner, by kind. import { plainResult, type ViewResult, type ViewKind } from "../types.ts"; import type { DocView, ViewRun, ViewRunner } from "./run.ts"; -import { runCompare } from "./compare.ts"; -import { runSurvival } from "./survival.ts"; -import { runCox } from "./cox.ts"; -import { runLogistic } from "./logistic.ts"; -import { runCourse } from "./course.ts"; -import { runSequence } from "./sequence.ts"; -import { runHotspots } from "./hotspots.ts"; -import { runPlot } from "./plot.ts"; -import { runCoexpression } from "./coexpression.ts"; -import { runClinical } from "./clinical.ts"; -import { runCooccurrence } from "./cooccurrence.ts"; -import { runOncoprint } from "./oncoprint.ts"; -import { runAttrition } from "./attrition.ts"; -import { runGenome } from "./genome.ts"; -import { runPathways } from "./pathways.ts"; -import { runDiffexpr } from "./diffexpr.ts"; -import { runPaired } from "./paired.ts"; -import { runClonality } from "./clonality.ts"; -import { runIncidence } from "./incidence.ts"; -import { runLabs } from "./labs.ts"; -import { runRiskmodel } from "./riskmodel.ts"; -import { runCancertypes } from "./cancertypes.ts"; -import { runOverlap } from "./overlap.ts"; -import { runEnrichment } from "./enrichment.ts"; -import { runAssays } from "./assays.ts"; -import { runComposition } from "./composition.ts"; -import { runCorrelates } from "./correlates.ts"; -import { runTable } from "./table.ts"; -import { runMutations } from "./mutations.ts"; -import { runAlterations } from "./alterations.ts"; -import { runTimeline } from "./timeline.ts"; -import { runAcquired } from "./acquired.ts"; -import { runCtdna } from "./ctdna.ts"; -import { runExclusivity } from "./exclusivity.ts"; -import { runTxendpoints } from "./txendpoints.ts"; -import { runMultistate } from "./multistate.ts"; -import { runRmst } from "./rmst.ts"; -import { runPredictive } from "./predictive.ts"; -import { runCutpoint } from "./cutpoint.ts"; -import { runHla } from "./hla.ts"; -import { runForest } from "./forest.ts"; -import { runAdjusted } from "./adjusted.ts"; -import { runInstability } from "./instability.ts"; -import { runActionability } from "./actionability.ts"; -import { runDrivernegative } from "./drivernegative.ts"; -import { runOrganotropism } from "./organotropism.ts"; -import { runMolsubtype } from "./molsubtype.ts"; -import { runGermline } from "./germline.ts"; +import { runCompare } from "./compare/run.ts"; +import { runSurvival } from "./survival/run.ts"; +import { runCox } from "./cox/run.ts"; +import { runLogistic } from "./logistic/run.ts"; +import { runCourse } from "./course/run.ts"; +import { runSequence } from "./sequence/run.ts"; +import { runHotspots } from "./hotspots/run.ts"; +import { runPlot } from "./plot/run.ts"; +import { runCoexpression } from "./coexpression/run.ts"; +import { runClinical } from "./clinical/run.ts"; +import { runCooccurrence } from "./cooccurrence/run.ts"; +import { runOncoprint } from "./oncoprint/run.ts"; +import { runAttrition } from "./attrition/run.ts"; +import { runGenome } from "./genome/run.ts"; +import { runPathways } from "./pathways/run.ts"; +import { runDiffexpr } from "./diffexpr/run.ts"; +import { runPaired } from "./paired/run.ts"; +import { runClonality } from "./clonality/run.ts"; +import { runIncidence } from "./incidence/run.ts"; +import { runLabs } from "./labs/run.ts"; +import { runRiskmodel } from "./riskmodel/run.ts"; +import { runCancertypes } from "./cancertypes/run.ts"; +import { runOverlap } from "./overlap/run.ts"; +import { runEnrichment } from "./enrichment/run.ts"; +import { runAssays } from "./assays/run.ts"; +import { runComposition } from "./composition/run.ts"; +import { runCorrelates } from "./correlates/run.ts"; +import { runTable } from "./table/run.ts"; +import { runMutations } from "./mutations/run.ts"; +import { runAlterations } from "./alterations/run.ts"; +import { runTimeline } from "./timeline/run.ts"; +import { runAcquired } from "./acquired/run.ts"; +import { runCtdna } from "./ctdna/run.ts"; +import { runExclusivity } from "./exclusivity/run.ts"; +import { runTxendpoints } from "./txendpoints/run.ts"; +import { runMultistate } from "./multistate/run.ts"; +import { runRmst } from "./rmst/run.ts"; +import { runPredictive } from "./predictive/run.ts"; +import { runCutpoint } from "./cutpoint/run.ts"; +import { runHla } from "./hla/run.ts"; +import { runForest } from "./forest/run.ts"; +import { runAdjusted } from "./adjusted/run.ts"; +import { runInstability } from "./instability/run.ts"; +import { runActionability } from "./actionability/run.ts"; +import { runDrivernegative } from "./drivernegative/run.ts"; +import { runOrganotropism } from "./organotropism/run.ts"; +import { runMolsubtype } from "./molsubtype/run.ts"; +import { runGermline } from "./germline/run.ts"; /** * A runner that lets a throw stand for its error: an error is reported as the view's, with its diff --git a/lib/figures/instability.ts b/lib/views/instability/figure.ts similarity index 97% rename from lib/figures/instability.ts rename to lib/views/instability/figure.ts index 60d5935..4603082 100644 --- a/lib/figures/instability.ts +++ b/lib/views/instability/figure.ts @@ -2,9 +2,10 @@ // histograms, then each arm's gain and loss frequency as an arm × cohort heatmap. import { esc, palette, frame, caption, linear, axisBottom, bottomAxisHeight, legend, label, textWidth, fmtInt, fmtNum, fmtP, seriesStyle, FS, SW, type SvgOpts -} from "../svg-kit.ts"; -import { CN_GAIN, CN_LOSS } from "./common.ts"; -import type { DrawnResultOf, InstabilitySpread } from "../types.ts"; +} from "../../svg-kit.ts"; +import { CN_GAIN, CN_LOSS } from "../../figures/common.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { InstabilitySpread } from "./types.ts"; const PAD = 8, GUT = 12, BOX_ROW = 22, BOX_H = 12, MIN_CELL = 13, STRIP = 12; diff --git a/lib/views/instability.ts b/lib/views/instability/run.ts similarity index 94% rename from lib/views/instability.ts rename to lib/views/instability/run.ts index dfb0ae1..41d4610 100644 --- a/lib/views/instability.ts +++ b/lib/views/instability/run.ts @@ -1,9 +1,11 @@ // Instability view: fraction of the genome altered, arm-level calls and the aneuploidy score, from segments -import type { ViewResult, InstabilityView, InstabilityPanel, InstabilitySpread } from "../types.ts"; -import { genomeCoverageSql, studyGenomeSql, instabilityScoresSql, instabilityArmsSql, instabilityArms } from "../federation-views.ts"; -import { kruskalWallis, mannWhitneyU, quantilesFromHistogram, type Histogram } from "../stats.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, plural, refuse } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { genomeCoverageSql, studyGenomeSql } from "../../federation-views.ts"; +import { kruskalWallis, mannWhitneyU, quantilesFromHistogram, type Histogram } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, plural, refuse } from "../shared.ts"; +import type { InstabilityView, InstabilityPanel, InstabilitySpread } from "./types.ts"; +import { instabilityScoresSql, instabilityArmsSql, instabilityArms } from "./sql.ts"; const COHORT_LIMIT = 6; diff --git a/lib/views/instability/sql.ts b/lib/views/instability/sql.ts new file mode 100644 index 0000000..2ab748a --- /dev/null +++ b/lib/views/instability/sql.ts @@ -0,0 +1,92 @@ +// The instability view's SQL: what its runner (run.ts) asks each participant's database for. +import { GENOME_CHROMS, CHROM_LENGTHS, pidOf } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; + +/** the arms scored: 39 autosomal arms (and Xp, Xq with includeX), in genome order */ +export function instabilityArms(includeX: boolean): string[] { + return GENOME_CHROMS.filter((c) => includeX || c !== "X").flatMap((c) => ACROCENTRIC.includes(c) ? [c + "q"] : [c + "p", c + "q"]); +} + +/** FGA in 1-point bins × aneuploidy score, with how many arms each sample had measured: counts of samples. */ +export function instabilityScoresSql(cohortSql: string, study: string, db: string, o: InstabilityOpts): string { + return ( + instabilityCtes(cohortSql, study, db, o) + "\n" + + "SELECT toInt32(least(100, floor(100 * alt / greatest(1, cov)))) AS fga_bin, score, measured, count() AS n\n" + + "FROM per GROUP BY fga_bin, score, measured ORDER BY fga_bin, score" + ); +} + +/** Per arm: the samples that measured it, and those with it gained or lost. */ +export function instabilityArmsSql(cohortSql: string, study: string, db: string, o: InstabilityOpts): string { + return ( + instabilityCtes(cohortSql, study, db, o) + "\n" + + "SELECT arm, countIf(measured) AS n_measured, countIf(measured AND gain) AS n_gain, countIf(measured AND loss) AS n_loss FROM calls GROUP BY arm" + ); +} + +// --------------------------------------------------------------------------- +// instability: genome-wide instability scores per sample, from the same segments + +/** + * hg19 centromeres: the boundary between band p11.1 (p11.11 on chr11) and q11 in UCSC's hg19 + * cytoBand table (hgdownload.soe.ucsc.edu/goldenPath/hg19/database/cytoBand.txt.gz). The p arm + * is [0, centromere), the q arm [centromere, chromosome length) (CHROM_LENGTHS.hg19). + */ +export const HG19_CENTROMERES: Record = { + "1": 125000000, "2": 93300000, "3": 91000000, "4": 50400000, "5": 48400000, "6": 61000000, "7": 59900000, + "8": 45600000, "9": 49000000, "10": 40200000, "11": 53700000, "12": 35800000, "13": 17900000, "14": 17600000, + "15": 19000000, "16": 36600000, "17": 24000000, "18": 17200000, "19": 26500000, "20": 27500000, "21": 13200000, + "22": 14700000, "X": 60600000 +}; +/** the acrocentric p arms, which carry no unique sequence to measure — left out, as Taylor et al. 2018 did */ +const ACROCENTRIC = ["13", "14", "15", "21", "22"]; + +export interface InstabilityOpts { threshold: number; armShare: number; includeX: boolean } + +/** + * Per segmented sample (in SQL, never returned): its FGA — the share of its segmented length on + * the scored chromosomes whose |segment mean| is past the threshold — and, per arm, the share of + * the arm's length gained and lost; an arm is gained (lost) when that share passes armShare, and + * measured when segments cover at least armShare of it. Aneuploidy score = arms gained or lost. + */ +function instabilityCtes(cohortSql: string, study: string, db: string, o: InstabilityOpts): string { + const T = Number(o.threshold), S = Number(o.armShare); + const chroms = GENOME_CHROMS.filter((c) => o.includeX || c !== "X"); + const arr = (xs: (string | number)[]) => "[" + xs.map((x) => typeof x === "number" ? String(x) : sq(x)).join(",") + "]"; + const cen = "transform(chrom, " + arr([...chroms]) + ", " + arr(chroms.map((c) => HG19_CENTROMERES[c])) + ", 0)"; + const len = "transform(chrom, " + arr([...chroms]) + ", " + arr(chroms.map((c) => CHROM_LENGTHS.hg19[c])) + ", 0)"; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "samp AS (\n" + + " SELECT internal_id AS sid FROM " + db + ".sample_derived\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "seg AS (\n" + + " SELECT s.sample_id AS sid, multiIf(s.chr IN ('23', 'X', 'chrX'), 'X', replaceOne(s.chr, 'chr', '')) AS chrom,\n" + + " toInt64(s.start) AS st, toInt64(s.end) AS en, s.segment_mean AS m\n" + + " FROM " + db + ".copy_number_seg s\n" + + " WHERE s.cancer_study_id = (SELECT cancer_study_id FROM " + db + ".cancer_study WHERE cancer_study_identifier = " + sq(study) + ")\n" + + " AND s.sample_id IN (SELECT sid FROM samp) AND isFinite(s.segment_mean)\n" + + "),\n" + + "parts AS (\n" + + " SELECT sid, chrom, m, arm, greatest(toInt64(0), least(en, hi) - greatest(st, lo)) AS bp, hi - lo AS arm_len FROM (\n" + + " SELECT sid, chrom, m, st, en, " + cen + " AS c, " + len + " AS l,\n" + + " arrayJoin([(concat(chrom, 'p'), toInt64(0), toInt64(c)), (concat(chrom, 'q'), toInt64(c), toInt64(l))]) AS a,\n" + + " a.1 AS arm, a.2 AS lo, a.3 AS hi\n" + + " FROM seg WHERE chrom IN (" + chroms.map(sq).join(",") + ") AND en > st\n" + + " ) WHERE NOT (arm IN (" + ACROCENTRIC.map((c) => sq(c + "p")).join(",") + "))\n" + + "),\n" + + "arms AS (\n" + + " SELECT sid, arm, any(arm_len) AS alen, sum(bp) AS cov, sumIf(bp, m >= " + T + ") AS gbp, sumIf(bp, m <= " + (-T) + ") AS lbp\n" + + " FROM parts GROUP BY sid, arm\n" + + "),\n" + + "calls AS (\n" + + " SELECT sid, arm, cov >= " + S + " * alen AS measured, gbp > " + S + " * alen AS gain, lbp > " + S + " * alen AS loss FROM arms\n" + + "),\n" + + "per AS (\n" + + " SELECT a.sid AS sid, sum(a.cov) AS cov, sum(a.gbp + a.lbp) AS alt,\n" + + " countIf(c.gain OR c.loss) AS score, countIf(c.measured) AS measured\n" + + " FROM arms a INNER JOIN calls c ON c.sid = a.sid AND c.arm = a.arm GROUP BY a.sid\n" + + ")" + ); +} diff --git a/lib/views/instability/types.ts b/lib/views/instability/types.ts new file mode 100644 index 0000000..ebec8c2 --- /dev/null +++ b/lib/views/instability/types.ts @@ -0,0 +1,47 @@ +// The instability view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Genomic instability from copy-number segments (hg19): per sample, the fraction of the genome + * altered and arm-level gains and losses, whose count is the aneuploidy score (Taylor et al. 2018); + * per cohort their distributions, a rank test between cohorts, and each arm's gain/loss frequency. + */ +export interface InstabilityView { + note?: string; + kind: "instability"; cohorts?: string[]; + /** |log2 segment mean| at or past which a segment is gained or lost (default 0.2) */ + threshold?: number; + /** an arm is called when more than this share of its length is past the threshold (default 0.5) */ + armShare?: number; + /** score chromosome X too (default false: its log ratio mostly records sex) */ + includeX?: boolean; +} +export interface InstabilitySpread { p5: number | null; q1: number | null; median: number | null; q3: number | null; p95: number | null; mean: number | null } +export interface InstabilityPanel { + cohort: string; + /** the cohort's samples, those with segments, and those with every scored arm measured */ + samples: number; segmented: number; allArms: number; + /** samples by aneuploidy score (index = arms altered), and by FGA in 1-point bins (index = percent) */ + score: number[]; fga: number[]; + scoreSpread: InstabilitySpread; fgaSpread: InstabilitySpread; + /** per arm (in `arms` order): the samples that measured it, and those gaining or losing it */ + arms: { arm: string; measured: number; gain: number; loss: number }[]; +} +export interface InstabilityResult { + build: "hg19"; threshold: number; armShare: number; includeX: boolean; + arms: string[]; + panels: InstabilityPanel[]; + /** two cohorts: Mann–Whitney; more: Kruskal–Wallis — on the aneuploidy score and on FGA */ + test: { name: "Mann–Whitney U" | "Kruskal–Wallis"; scoreP: number; fgaP: number } | null; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { instability: InstabilityView } + interface ViewResultFields { + instability: { + instability?: InstabilityResult; + }; + } + interface ViewResultMain { instability: "instability" } +} diff --git a/lib/figures/labs.ts b/lib/views/labs/figure.ts similarity index 97% rename from lib/figures/labs.ts rename to lib/views/labs/figure.ts index 0093474..76b31cd 100644 --- a/lib/figures/labs.ts +++ b/lib/views/labs/figure.ts @@ -2,11 +2,11 @@ import { palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, label, textWidth, seriesStyle, fmtInt, fmtPct, fmtP, FS, SW, type SvgOpts -} from "../svg-kit.ts"; -import type { DrawnResultOf } from "../types.ts"; -import { timeScale, timeTitle } from "../chart/time.ts"; -import { paragraph } from "../chart/text.ts"; -import { r1 } from "../chart/marks.ts"; +} from "../../svg-kit.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { timeScale, timeTitle } from "../../chart/time.ts"; +import { paragraph } from "../../chart/text.ts"; +import { r1 } from "../../chart/marks.ts"; /** The labs course draws a bin only from this many patients; the engine's caption says so. */ export const LAB_MIN_POINT_N = 5; diff --git a/lib/views/labs/method.ts b/lib/views/labs/method.ts new file mode 100644 index 0000000..79d921b --- /dev/null +++ b/lib/views/labs/method.ts @@ -0,0 +1,82 @@ +// The labs view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface LabsTrace { + test: string; + anchoredTo: string; + occurrence: "FIRST" | "LAST"; + baselineDays: number; fromWeeks: number; toWeeks: number; binWeeks: number; responseDrop: number; + pctMin: number; pctMax: number; pctBin: number; + minPointN: number; + waterfallCap: number; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + panels: { cohort: string; anchored: number; baseline: number; positiveBaseline: number; evaluable: number; responders: number; + waterfallWithheld: number; waterfallCapped: number }[]; + /** per cohort, summed over its studies: patients per (week bin, % change bin midpoint) */ + course: { cohort: string; week: number; v: number; n: number }[]; + /** what the view reported — for the script check */ + results: { course: { cohort: string; week: number; q1: number | null; median: number | null; q3: number | null }[]; responseP?: number }; +} + +export function labsMethod(t: LabsTrace): MethodRecord { + const two = t.panels.length === 2; + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Count patients' % change from baseline", + detail: "The labs:course and labs:best queries above (and labs:waterfall, one bar per patient, from a patient-level participant). Counts per bin and per rule, no individual value except the waterfall's bars.", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + })); + steps.push({ kind: "transform", title: "Anchor, baseline and change", + detail: "Day 0 is each patient's " + t.occurrence.toLowerCase() + " " + t.anchoredTo + " (event type, key and value matched ignoring case); a patient without one is not anchored. " + + t.test + " readings are those whose result parses as a number. The baseline is the mean of a patient's readings on the last day with any in the " + t.baselineDays + + " days up to and including day 0; a patient without one has no baseline, and one whose baseline is 0 or below is left out, since a % change from it means nothing. Every later reading becomes 100·(value − baseline)/baseline; readings on the baseline day itself are not used.", + params: t.panels.map((pn) => [pn.cohort, pn.anchored.toLocaleString() + " anchored → " + pn.baseline.toLocaleString() + " with a baseline → " + + pn.positiveBaseline.toLocaleString() + " with a positive one → " + pn.evaluable.toLocaleString() + " with a reading after day 0"] as [string, string]) }); + steps.push({ kind: "transform", title: "Bin the course", + detail: "Weeks " + t.fromWeeks + " to " + t.toWeeks + " around day 0 in " + t.binWeeks + "-week bins; a patient tested several times in a bin counts once, at their mean. % change in " + t.pctBin + "-point bins from " + + t.pctMin + " to " + t.pctMax + ", values beyond either end in the end bin. The figure draws a bin only from " + t.minPointN + " patients." }); + steps.push({ kind: "statistic", title: "Median and interquartile range per week", + detail: "From each bin's histogram, with the bin midpoints as values and linear interpolation between ranks — exact to within a " + t.pctBin + "-point bin." }); + steps.push({ kind: "statistic", title: "Best response", + detail: "Each patient's lowest % change after day 0 within the window; a responder fell by at least " + t.responseDrop + "%, counted from the unbinned values." + + (two ? " The two cohorts' response rates are compared with a two-sided Fisher's exact test." : ""), + params: t.panels.map((pn) => [pn.cohort, pn.responders.toLocaleString() + " responders of " + pn.evaluable.toLocaleString() + + (pn.waterfallWithheld ? " · " + pn.waterfallWithheld + " bars withheld (aggregate-only participant)" : "") + + (pn.waterfallCapped ? " · " + pn.waterfallCapped + " bars past the " + t.waterfallCap.toLocaleString() + "-bar limit" : "")] as [string, string]) }); + const python = [ + "# Recompute the course's quartiles from the binned counts" + (two ? ", and the response test with scipy " + VERIFIED.fisher.version : "") + ".", + "import io", + "import numpy as np", + "import pandas as pd", + ...(two ? ["from scipy.stats import fisher_exact"] : []), + "", + RESULT_INIT, + ...pyTable("course", "Per cohort and week bin: patients whose mean % change fell in each " + t.pctBin + "-point bin (v is the bin's midpoint)", + ["cohort", "week", "v", "n"], t.course.map((r) => [r.cohort, r.week, r.v, r.n]), "labs_course.csv"), + "for (cohort, week), g in course.groupby([\"cohort\", \"week\"], sort=False):", + " values = np.repeat(g[\"v\"].to_numpy(), g[\"n\"].to_numpy())", + " q1, median, q3 = np.percentile(values, [25, 50, 75])", + " print(f\"{cohort} week {week}: n={len(values)}, median {median:.1f}%, IQR {q1:.1f} to {q3:.1f}\")", + " for name, x in ((\"q1\", q1), (\"median\", median), (\"q3\", q3)):", + " RESULT[f\"labs:{cohort}:{week}:{name}\"] = x", + "responders = " + JSON.stringify(Object.fromEntries(t.panels.map((pn) => [pn.cohort, [pn.responders, pn.evaluable]]))) + " # cohort: [responders, evaluable], as the query counted them", + ...(two ? [ + "(ra, ea), (rb, eb) = responders.values()", + "RESULT[\"labs:response:p\"] = fisher_exact([[ra, ea - ra], [rb, eb - rb]]).pvalue", + "print(\"response rates\", {c: f\"{r}/{e}\" for c, (r, e) in responders.items()}, \"Fisher p =\", RESULT[\"labs:response:p\"])", + ] : []), + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: ["quantilesFromHistogram", ...(two ? ["fisherExact"] : [])], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "Quantiles from histograms match numpy percentile on the expanded values" + (two ? ", and Fisher's exact test scipy " + VERIFIED.fisher.version + " fisher_exact" : "") + + ", to a relative 1e-6 (test/moments-golden.test.ts, test/tables-golden.test.ts).", + python: withResult(python), + check: { + // quartiles are reported to 3 decimals + ...checks("labs:", t.results.course, (c) => c.cohort + ":" + c.week, + [["q1", "q1", { abs: 5e-4 + 1e-9 }], ["median", "median", { abs: 5e-4 + 1e-9 }], ["q3", "q3", { abs: 5e-4 + 1e-9 }]]), + ...(t.results.responseP != null ? { "labs:response:p": { v: t.results.responseP } } : {}), + }, + }; +} diff --git a/lib/views/labs.ts b/lib/views/labs/run.ts similarity index 94% rename from lib/views/labs.ts rename to lib/views/labs/run.ts index 6317479..769d831 100644 --- a/lib/views/labs.ts +++ b/lib/views/labs/run.ts @@ -1,12 +1,15 @@ // Labs view: a lab value's course and response from a treatment start -import type { ViewResult, LabsView, LabsPanel } from "../types.ts"; -import { isPatientLevel } from "../federation.ts"; -import { labCourseSql, labBestSql, labWaterfallSql, labTestsSql, LAB_PCT_BIN, LAB_PCT_MIN, LAB_PCT_MAX, LAB_PCT_BINS } from "../federation-views.ts"; -import { fisherExact, quantilesFromHistogram } from "../stats.ts"; -import { LAB_MIN_POINT_N } from "../figures/labs.ts"; -import { labsMethod, type LabsTrace } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { isPatientLevel } from "../../federation.ts"; +import { labTestsSql } from "../../federation-views.ts"; +import { fisherExact, quantilesFromHistogram } from "../../stats.ts"; +import { LAB_MIN_POINT_N } from "./figure.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse } from "../shared.ts"; +import type { LabsView, LabsPanel } from "./types.ts"; +import { labCourseSql, labBestSql, labWaterfallSql, LAB_PCT_BINS, LAB_PCT_BIN, LAB_PCT_MIN, LAB_PCT_MAX } from "./sql.ts"; +import { labsMethod } from "./method.ts"; +import type { LabsTrace } from "./method.ts"; const WATERFALL_CAP = 5000; const LAB_KEY = /^[A-Za-z0-9_ .\/-]{1,80}$/; diff --git a/lib/views/labs/sql.ts b/lib/views/labs/sql.ts new file mode 100644 index 0000000..8f638b2 --- /dev/null +++ b/lib/views/labs/sql.ts @@ -0,0 +1,120 @@ +// The labs view's SQL: what its runner (run.ts) asks each participant's database for. +import type { IncidenceAnchor } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; +import { foldedEqualsSql } from "../../event-names.ts"; +import { finiteSql } from "../../sql-number.ts"; + +/** + * The course: per time bin, the histogram of % change from baseline (each patient's mean within + * the bin, so a patient tested weekly weighs no more than one tested monthly). The baseline + * reading itself — 0% by definition — is not part of the course. Counts only. + */ +export function labCourseSql(cohortSql: string, study: string, db: string, lab: LabSpec, anchor: IncidenceAnchor, o: LabOpts): string { + return ( + labCtes(cohortSql, study, db, lab, anchor, o) + "\n" + + "SELECT tbin, pbin, count() AS n FROM (\n" + + " SELECT pid, toInt32(floor(rel / " + Math.max(1, Math.floor(o.binDays)) + ")) AS tbin, " + pctBin("avg(pc)") + " AS pbin\n" + + " FROM chg GROUP BY pid, tbin\n" + + ") GROUP BY tbin, pbin ORDER BY tbin, pbin" + ); +} + +/** + * Best response: each patient's lowest % change after the anchor (within the window), binned; + * how many fell by at least `responseDrop` percent, counted exactly rather than from the bins; + * and the counts behind the denominators — patients anchored, with a baseline, with a positive + * baseline, and with any value after the anchor. + */ +export function labBestSql(cohortSql: string, study: string, db: string, lab: LabSpec, anchor: IncidenceAnchor, o: LabOpts, responseDrop: number): string { + const best = "(SELECT pid, min(pc) AS best FROM chg WHERE rel > 0 GROUP BY pid)"; + return ( + labCtes(cohortSql, study, db, lab, anchor, o) + "\n" + + "SELECT 'best' AS what, " + pctBin("best") + " AS pbin, count() AS n FROM " + best + " GROUP BY pbin\n" + + "UNION ALL SELECT 'responders' AS what, 0 AS pbin, countIf(best <= " + (-Math.abs(Number(responseDrop))) + ") AS n FROM " + best + "\n" + + "UNION ALL SELECT 'anchored' AS what, 0 AS pbin, count() AS n FROM anch\n" + + "UNION ALL SELECT 'baseline' AS what, 0 AS pbin, count() AS n FROM base\n" + + "UNION ALL SELECT 'positive_baseline' AS what, 0 AS pbin, countIf(b > 0) AS n FROM base\n" + + "UNION ALL SELECT 'after' AS what, 0 AS pbin, uniqExactIf(pid, rel > 0) AS n FROM chg" + ); +} + +/** + * Each patient's best % change, one row per patient and no identifier — the values behind a + * waterfall. Patient-level: only PatientLevelResolver.viewRows runs it, the same entitlement + * that lets a plot draw its points. + */ +export function labWaterfallSql(cohortSql: string, study: string, db: string, lab: LabSpec, anchor: IncidenceAnchor, o: LabOpts, cap: number): string { + return ( + labCtes(cohortSql, study, db, lab, anchor, o) + "\n" + + "SELECT round(best, 1) AS best FROM (SELECT pid, min(pc) AS best FROM chg WHERE rel > 0 GROUP BY pid)\n" + + "ORDER BY best LIMIT " + Math.max(1, Math.floor(cap)) + ); +} + +// --------------------------------------------------------------------------- +// labs: a lab value's course from treatment start, as % change from baseline + +export interface LabSpec { + /** the event type holding lab results, and the key/value naming this test (e.g. TEST = PSA) */ + eventType: string; testKey: string; test: string; + /** the attribute holding the numeric result */ + resultKey: string; +} +export interface LabOpts { + /** baseline: the last value within this many days before (or on) the anchor */ + baselineDays: number; + /** window around the anchor, and bin width, in days */ + fromDays: number; toDays: number; binDays: number; +} +/** + * % change is binned in 2-point steps CENTRED on −100, −98, … +300 (so an unchanged value sits + * exactly at 0, not at a bin's edge); values above +300 share the top bin. + */ +export const LAB_PCT_BIN = 2, LAB_PCT_MIN = -100, LAB_PCT_MAX = 300; +export const LAB_PCT_BINS = (LAB_PCT_MAX - LAB_PCT_MIN) / LAB_PCT_BIN + 1; + + +function labCtes(cohortSql: string, study: string, db: string, lab: LabSpec, anchor: IncidenceAnchor, o: LabOpts): string { + const pick = anchor.occurrence === "LAST" ? "max" : "min"; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "anch AS (\n" + + " SELECT p.stable_id AS pid, " + pick + "(ce.start_date) AS day\n" + + " FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", anchor.eventType) + "\n" + + (anchor.key ? " AND ce.clinical_event_id IN (SELECT clinical_event_id FROM " + db + ".clinical_event_data WHERE " + + foldedEqualsSql("key", anchor.key) + " AND " + foldedEqualsSql("value", String(anchor.value ?? "")) + ")\n" : "") + + " AND p.stable_id IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "lab AS (\n" + + " SELECT p.stable_id AS pid, ce.start_date AS day, " + finiteSql("r.value") + " AS v\n" + + " FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " JOIN " + db + ".clinical_event_data t ON t.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("t.key", lab.testKey) + " AND " + foldedEqualsSql("t.value", lab.test) + "\n" + + " JOIN " + db + ".clinical_event_data r ON r.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("r.key", lab.resultKey) + "\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", lab.eventType) + "\n" + + " AND p.stable_id IN (SELECT pid FROM anch) AND " + finiteSql("r.value") + " IS NOT NULL\n" + + "),\n" + + "base AS (\n" + + // the baseline is the mean of the readings on the last day with any, so several readings that day + // give one answer rather than whichever row came first + " SELECT lab.pid AS pid, max(lab.day) AS bday,\n" + + " arrayAvg(arrayMap((x) -> assumeNotNull(x.1), arrayFilter((x) -> x.2 = bday, groupArray((lab.v, lab.day))))) AS b\n" + + " FROM lab INNER JOIN anch ON anch.pid = lab.pid\n" + + " WHERE lab.day >= anch.day - " + Math.floor(o.baselineDays) + " AND lab.day <= anch.day GROUP BY lab.pid\n" + + "),\n" + + "chg AS (\n" + + " SELECT lab.pid AS pid, lab.day - anch.day AS rel, 100 * (lab.v - base.b) / base.b AS pc\n" + + " FROM lab INNER JOIN anch ON anch.pid = lab.pid INNER JOIN base ON base.pid = lab.pid\n" + + " WHERE base.b > 0 AND lab.day != base.bday\n" + + " AND lab.day - anch.day >= " + Math.floor(o.fromDays) + " AND lab.day - anch.day <= " + Math.floor(o.toDays) + "\n" + + ")" + ); +} + +export const pctBin = (x: string) => + "toInt32(floor((least(" + LAB_PCT_MAX + ", greatest(" + LAB_PCT_MIN + ", " + x + ")) - (" + LAB_PCT_MIN + ") + " + (LAB_PCT_BIN / 2) + ") / " + LAB_PCT_BIN + "))"; diff --git a/lib/views/labs/types.ts b/lib/views/labs/types.ts new file mode 100644 index 0000000..06e5bac --- /dev/null +++ b/lib/views/labs/types.ts @@ -0,0 +1,53 @@ +// The labs view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +export interface LabsView { + note?: string; + kind: "labs"; cohorts?: string[]; + /** the test, as its event attribute names it (e.g. "PSA", "CEA") */ + test: string; + /** where lab results live (defaults: event type Lab_Test, the test in TEST, the value in RESULT) */ + eventType?: string; testKey?: string; resultKey?: string; + /** the clock's start: the treatment whose effect is read */ + anchor: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; + /** baseline is the last value this many days before, or on, the anchor (default 90) */ + baselineDays?: number; + /** the window, in weeks around the anchor (default −12 to 52), and its bin width (default 4) */ + fromWeeks?: number; toWeeks?: number; binWeeks?: number; + /** a responder's best fall from baseline, in percent (default 50, PCWG3's PSA50) */ + responseDrop?: number; +} +export interface LabsPanel { + cohort: string; + /** the denominators: anchored, with a baseline, with a positive baseline, with a value after the anchor */ + anchored: number; baseline: number; positiveBaseline: number; evaluable: number; + /** per bin: its start in weeks, the patients measured, and the quartiles of % change */ + course: { week: number; n: number; q1: number | null; median: number | null; q3: number | null }[]; + /** best % change after the anchor, in 2-point bins centred on −100, −98, … +300 (the last holds everything above) */ + best: number[]; + responders: number; + /** each patient's best % change, sorted — present only from patient-level participants */ + waterfall?: number[]; + /** patients whose best could not be drawn as a bar (an aggregate-only participant's) */ + waterfallWithheld?: number; + /** evaluable patients whose bars were cut by the waterfall's bar limit (drawn ones are the lowest) */ + waterfallCapped?: number; +} +export interface LabsResult { + test: string; anchoredTo: string; responseDrop: number; + fromWeeks: number; toWeeks: number; binWeeks: number; baselineDays: number; + panels: LabsPanel[]; + /** two cohorts: Fisher on responders */ + responseP?: number; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { labs: LabsView } + interface ViewResultFields { + labs: { + labs?: LabsResult; + }; + } + interface ViewResultMain { labs: "labs" } +} diff --git a/lib/views/logistic/method.ts b/lib/views/logistic/method.ts new file mode 100644 index 0000000..11a6479 --- /dev/null +++ b/lib/views/logistic/method.ts @@ -0,0 +1,71 @@ +// The logistic view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type FitTrace, type MethodRecord, type MethodStep, fetchSteps, codingStep, plural, py, VERIFIED, RESULT_INIT, pyCategoricalDtypes, pyComment, pyTerms, GOLDEN_TEST, withResult, checks } from "../../method.ts"; +import type { AxisKind } from "../../plot.ts"; +import { LOGISTIC_MAX_ITERATIONS } from "../../stats.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface LogisticTrace extends FitTrace { + outcomeKind: AxisKind; + outcomeEvent?: string; + outcomeOther: string[]; + /** what the fit reported, per term — for the script check */ + coefficients: { label: string; beta: number; se: number; p: number }[]; +} + +export function logisticMethod(t: LogisticTrace, o: { outcomeLabel: string; queryLabel: string }): MethodRecord { + const steps: MethodStep[] = fetchSteps(t, o.queryLabel, + "Patients with a value for " + o.outcomeLabel + " and for every covariate are kept."); + steps.push({ + kind: "transform", + title: "Code the outcome", + detail: t.outcomeKind === "num" + ? o.outcomeLabel + " is numeric and already coded 0/1; it is used as is." + : "1 if " + o.outcomeLabel + " = “" + t.outcomeEvent + "”, else 0" + + (t.outcomeOther.length ? " — every other observed value (" + t.outcomeOther.join(", ") + ") counts as not the event." : "."), + }); + steps.push(codingStep(t)); + steps.push({ + kind: "statistic", + title: "Fit a logistic regression", + detail: "Maximum likelihood for a binomial GLM with a logit link and an intercept, by Newton–Raphson with step-halving.", + params: [ + ["patients / events", t.fit.n.toLocaleString() + " / " + t.fit.events.toLocaleString()], + ["terms", t.columns.length + " plus an intercept (not drawn)"], + ["standard errors", "from the Fisher information at the estimate"], + ["95% interval", "exp(β ± 1.959964·SE), Wald"], + ["p-value", "Wald χ² on 1 df, (β/SE)²"], + ["fit", t.fit.iterations < LOGISTIC_MAX_ITERATIONS ? "converged in " + plural(t.fit.iterations, "iteration") + : "stopped at the " + LOGISTIC_MAX_ITERATIONS + "-iteration limit without converging"], + ["log likelihood", t.fit.loglik.toFixed(4)], + ], + }); + const y = t.outcomeKind === "num" ? "df[\"y\"].astype(int)" : "(df[\"y\"] == " + py(t.outcomeEvent!) + ").astype(int)"; + const python = [ + "# Refit with statsmodels (" + VERIFIED.logistic.version + " is the version our fit is tested against).", + "# Input: the rows the " + o.queryLabel + " query above returns — columns y, c0, c1, …", + t.perStudy.length > 1 ? "# It runs once per study: concatenate the results." : "# Save them as CSV.", + "import numpy as np", + "import pandas as pd", + "import statsmodels.api as sm", + "", + RESULT_INIT, + "df = pd.read_csv(\"logistic_rows.csv\"" + pyCategoricalDtypes(t, t.outcomeKind === "num" ? [] : ["y"]) + ")", + "y = " + y + " # the outcome: " + (t.outcomeKind === "num" ? "already 0/1" : "event = " + pyComment(t.outcomeEvent)), + "X = pd.DataFrame({", + pyTerms(t, " "), + "})", + "fit = sm.Logit(y, sm.add_constant(X)).fit(method=\"newton\")", + "print(fit.summary()) # coefficients, SEs and p-values", + "print(np.exp(fit.conf_int())) # the odds-ratio 95% intervals drawn in the forest plot", + "for term in X.columns:", + " RESULT[\"logistic:\" + term + \":beta\"], RESULT[\"logistic:\" + term + \":se\"], RESULT[\"logistic:\" + term + \":p\"] = fit.params[term], fit.bse[term], fit.pvalues[term]", + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: ["logisticRegression"], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "Matches " + VERIFIED.logistic.library + " " + VERIFIED.logistic.version + " " + VERIFIED.logistic.fn + + " to a relative 1e-6 on coefficients, SEs, p-values and intervals (" + GOLDEN_TEST + ").", + python: withResult(python), + check: checks("logistic:", t.coefficients, (c) => c.label, [["beta", "beta"], ["se", "se"], ["p", "p", { rel: 1e-5, abs: 1e-14 }]]), + }; +} diff --git a/lib/views/logistic.ts b/lib/views/logistic/run.ts similarity index 94% rename from lib/views/logistic.ts rename to lib/views/logistic/run.ts index d07dbf3..f27e657 100644 --- a/lib/views/logistic.ts +++ b/lib/views/logistic/run.ts @@ -1,14 +1,16 @@ // Logistic view: a multivariate logistic regression on the Cox view's covariate coding, fit // from per-patient rows inside each local study (patient-level participants only). -import { logisticRegression, type LogisticRow } from "../stats.ts"; -import type { ViewResult, LogisticView, LogisticCoefficient } from "../types.ts"; -import { isPatientLevel } from "../federation.ts"; -import { axisLabel, validateAxis, type AxisKind } from "../plot.ts"; -import { logisticMethod, type CoxTrace } from "../method.ts"; -import { buildCoxColumns, notProfiledNote } from "./covariates.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { overLimit, studyCatalogs } from "./shared.ts"; -import { resolveProfileAxis } from "../plot-profile.ts"; +import { logisticRegression, type LogisticRow } from "../../stats.ts"; +import type { ViewResult } from "../../types.ts"; +import { isPatientLevel } from "../../federation.ts"; +import { axisLabel, validateAxis, type AxisKind } from "../../plot.ts"; +import { type CoxTrace } from "../../method.ts"; +import { buildCoxColumns, notProfiledNote } from "../covariates.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { overLimit, studyCatalogs } from "../shared.ts"; +import { resolveProfileAxis } from "../../plot-profile.ts"; +import type { LogisticView, LogisticCoefficient } from "./types.ts"; +import { logisticMethod } from "./method.ts"; export async function runLogistic(view: DocView, c: ViewRun): Promise { const { doc, cohorts, resolvers, vNames, vStudies, participationFor } = c; diff --git a/lib/views/logistic/types.ts b/lib/views/logistic/types.ts new file mode 100644 index 0000000..27f0961 --- /dev/null +++ b/lib/views/logistic/types.ts @@ -0,0 +1,61 @@ +// The logistic view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { SplitBy, CoxCovariate } from "../../types.ts"; +import type { PlotAxis } from "../../plot.ts"; + +/** + * Multivariate logistic regression: a BINARY outcome (not time-to-event) regressed on several + * covariates at once, reported as odds ratios with a forest plot — the same shape as `cox`, but + * for "did X happen" rather than "when did X happen". Reuses the exact same covariate/dummy- + * coding machinery as `cox` (buildCoxColumns): a categorical covariate is dummy-coded the same + * way, with the same reference-level rules (a mutated-vs-not axis defaults to "Not mutated"; + * others default to the most frequent level; `reference` overrides either). Fit from real + * per-patient rows within each LOCAL study, same boundary as `cox`. + */ +export interface LogisticView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; + kind: "logistic"; cohorts?: string[]; splitBy?: SplitBy; + /** the binary outcome, as a plot axis (clinical, molecular, or alteration status) */ + outcome: PlotAxis; + /** + * Which observed value of a CATEGORICAL outcome counts as the event (1) — every other observed + * value is collapsed into "not event" (0), and which values got collapsed is disclosed in the + * caveat rather than left implicit. Required for a categorical outcome; refused (not guessed) + * if omitted, or if it names a value the outcome never actually takes. Not used, and must be + * omitted, for a NUMERIC outcome — that one must already be coded exactly 0/1, refused + * otherwise (a continuous number silently coerced to 0/1 would be a guess about a threshold + * nobody asked for). + */ + outcomeEvent?: string; + /** one or more covariates to fit simultaneously; each becomes one or more forest-plot rows */ + covariates: CoxCovariate[]; +} +export interface LogisticCoefficient { + /** covariate name, or ": " for a categorical level */ + label: string; + /** the baseline level this coefficient is measured against, for a categorical covariate */ + reference?: string; + /** this coefficient's own level (the non-reference side), for a categorical covariate */ + level?: string; + axisIndex?: number; + beta: number; se: number; or: number; ciLow: number; ciHigh: number; p: number; +} +export interface LogisticResult { + coefficients: LogisticCoefficient[]; + n: number; events: number; + /** a categorical outcome's other observed values, collapsed into "not event" — named so that + * is a disclosed choice, not a silent one */ + outcomeOther?: string[]; + /** studies whose patients were left out of the fit for lacking patient-level access */ + excludedRemote?: { study: string; patients: number }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { logistic: LogisticView } + interface ViewResultFields { + logistic: { + logistic?: LogisticResult; + }; + } + interface ViewResultMain { logistic: "logistic" } +} diff --git a/lib/figures/molsubtype.ts b/lib/views/molsubtype/figure.ts similarity index 96% rename from lib/figures/molsubtype.ts rename to lib/views/molsubtype/figure.ts index ed7e72b..418d2da 100644 --- a/lib/figures/molsubtype.ts +++ b/lib/views/molsubtype/figure.ts @@ -1,8 +1,9 @@ // Molecular-subtype figure: each cohort's classified patients as one stacked bar of subtype shares, // and beneath it one bar per group of the `by` attribute; then overall survival by subtype. -import type { DrawnResultOf, MolsubtypeCounts } from "../types.ts"; -import { palette, frame, caption, label, legend, textWidth, seriesStyle, textOn, fmtInt, fmtPct, fmtNum, fmtP, num, FS, type SvgOpts } from "../svg-kit.ts"; -import { kmPanel } from "./km.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { palette, frame, caption, label, legend, textWidth, seriesStyle, textOn, fmtInt, fmtPct, fmtNum, fmtP, num, FS, type SvgOpts } from "../../svg-kit.ts"; +import { kmPanel } from "../../figures/km.ts"; +import type { MolsubtypeCounts } from "./types.ts"; const PAD = 8, BAR_H = 18, GAP = 6; const NAMES: Record = { POLEmut: "POLE-ultramutated", MMRd: "MMR-deficient", p53abn: "p53-abnormal", NSMP: "NSMP" }; diff --git a/lib/views/molsubtype.ts b/lib/views/molsubtype/run.ts similarity index 97% rename from lib/views/molsubtype.ts rename to lib/views/molsubtype/run.ts index 86278a1..e109e05 100644 --- a/lib/views/molsubtype.ts +++ b/lib/views/molsubtype/run.ts @@ -1,11 +1,13 @@ // Molecular-subtype view: a published molecular classifier applied to each patient, as derived // subtypes — their shares per cohort, by an attribute, and overall survival by subtype. One // classifier so far (endometrial); another is a new CLASSIFIERS entry with its own SQL. -import type { ViewResult, MolsubtypeView, MolsubtypePanel, MolsubtypeCounts } from "../types.ts"; -import { molCountsSql, molSurvivalSql, molMsiAttributesSql, type MolEndometrialOpts, type MolMsiRule } from "../federation-views.ts"; -import { chiSquareTest, kaplanMeier, logRankK, type SurvRecord } from "../stats.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, plural, ATTR_NAME } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { chiSquareTest, kaplanMeier, logRankK, type SurvRecord } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, plural, ATTR_NAME } from "../shared.ts"; +import type { MolsubtypeView, MolsubtypePanel, MolsubtypeCounts } from "./types.ts"; +import { molCountsSql, molSurvivalSql, molMsiAttributesSql } from "./sql.ts"; +import type { MolEndometrialOpts, MolMsiRule } from "./sql.ts"; /** * Pathogenic POLE exonuclease-domain hotspots: the eleven of León-Castillo et al., "Interpretation of @@ -115,7 +117,7 @@ export async function runMolsubtype(view: MolsubtypeView, ctx: ViewRun): Promise const k = cl.subtypes.length; const panels: MolsubtypePanel[] = []; - const survival: NonNullable = []; + const survival: NonNullable = []; const wantSurvival = view.survival !== false; let noSurvival: string | undefined; for (const [ci, n] of vNames.entries()) { diff --git a/lib/views/molsubtype/sql.ts b/lib/views/molsubtype/sql.ts new file mode 100644 index 0000000..443aac0 --- /dev/null +++ b/lib/views/molsubtype/sql.ts @@ -0,0 +1,128 @@ +// The molsubtype view's SQL: what its runner (run.ts) asks each participant's database for. +import { pidOf, orgAttr } from "../../federation-views.ts"; +import { finiteSql } from "../../sql-number.ts"; +import { clinicalTable } from "../../custom-data.ts"; +import { sq } from "../../compiler.ts"; +import { clinicalValueFoldSql } from "../../event-names.ts"; +import { driverJoin } from "../../federation.ts"; + +/** Patients per (class, why, group). Counts only. */ +export function molCountsSql(cohortSql: string, study: string, db: string, o: MolEndometrialOpts): string { + return molEndometrialCtes(cohortSql, study, db, o) + "\n" + + "SELECT cls.cls AS cls, cls.why AS why, grp.g AS grp, count() AS n FROM cls LEFT JOIN grp ON grp.pid = cls.pid GROUP BY cls, why, grp"; +} + +/** Overall survival per class: counts of (class, months, dead) from OS_MONTHS / OS_STATUS. Counts only. */ +export function molSurvivalSql(cohortSql: string, study: string, db: string, o: MolEndometrialOpts): string { + const pid = pidOf("patient_unique_id", study); + return molEndometrialCtes(cohortSql, study, db, o) + ",\n" + + "os AS (\n" + + " SELECT " + pid + " AS pid, " + finiteSql("anyIf(attribute_value, attribute_name = 'OS_MONTHS')") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = 'OS_STATUS') LIKE '1%' AS dead\n" + + " FROM " + clinicalTable(db, ["OS_MONTHS", "OS_STATUS"]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN ('OS_MONTHS', 'OS_STATUS')\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")\n" + + "SELECT cls.cls AS cls, round(os.months, 2) AS t, os.dead AS dead, count() AS n\n" + + "FROM cls INNER JOIN os ON os.pid = cls.pid WHERE os.months IS NOT NULL AND os.months >= 0 AND cls.cls != 'NC' GROUP BY cls, t, dead ORDER BY t"; +} + +/** Which MSI attributes the cohort's samples carry, with how many values and patients. Counts only. */ +export function molMsiAttributesSql(cohortSql: string, study: string, db: string): string { + const pid = pidOf("patient_unique_id", study); + return "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT attribute_name AS attribute, uniqExact(" + pid + ") AS patients, uniqExact(attribute_value) AS nvalues, arraySort(groupUniqArray(8)(attribute_value)) AS examples\n" + + "FROM " + clinicalTable(db, []) + "\n" + + "WHERE cancer_study_identifier = " + sq(study) + " AND positionCaseInsensitive(attribute_name, 'MSI') > 0 AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + "GROUP BY attribute ORDER BY patients DESC"; +} + +// --------------------------------------------------------------------------- +// molsubtype: a published molecular classifier applied per patient + +export interface MolMsiRule { + attribute: string; + /** a categorical MSI call: the values meaning MSI-high and those meaning stable (case-insensitive) */ + high?: string[]; stable?: string[]; + /** a numeric score: MSI-high at or above `threshold`, stable below `stableBelow`, in between indeterminate */ + threshold?: number; stableBelow?: number; +} +export interface MolEndometrialOpts { + /** POLE exonuclease-domain hotspots (protein changes) */ + poleHotspots: string[]; + msi: MolMsiRule; + /** an abnormal p53: an OncoKB-driver TP53 mutation at this pin, or (null) any non-synonymous TP53 mutation */ + p53Pin: string | null; + /** a patient attribute to cross the subtypes with */ + by: string | null; +} + +/** TP53 mutation classes counted as abnormal without OncoKB: protein-changing calls */ +const MOL_TP53_CLASSES = ["Missense_Mutation", "Nonsense_Mutation", "Frame_Shift_Del", "Frame_Shift_Ins", "Splice_Site", "In_Frame_Del", "In_Frame_Ins", "Nonstop_Mutation", "Translation_Start_Site", "Splice_Region"]; + +/** + * `cls`: each cohort patient's endometrial molecular class — the TCGA / ProMisE / WHO 2020 hierarchy, + * first match wins: 'POLEmut' (a POLE hotspot called in any sample), 'MMRd' (MSI-high in any sample), + * 'p53abn' (an abnormal TP53 mutation), 'NSMP' (none of these) — and 'NC' (not classifiable) with + * `why` when an input the hierarchy needs before deciding is missing: not sequenced, not profiled for + * POLE or TP53 (panel-aware), or no MSI value. Also `grp`, the `by` attribute per patient. + */ +function molEndometrialCtes(cohortSql: string, study: string, db: string, o: MolEndometrialOpts): string { + const sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study), pid = pidOf("patient_unique_id", study); + const attr = orgAttr(o.msi.attribute); + const hot = o.poleHotspots.map((h) => { if (!/^[A-Z][0-9]{1,5}[A-Z*]$/.test(h)) throw new Error("bad POLE hotspot '" + String(h).slice(0, 20) + "'"); return sq(h); }).join(", "); + const v = clinicalValueFoldSql("attribute_value"); + const msiState = o.msi.threshold != null + ? "multiIf(" + finiteSql("trimBoth(attribute_value)") + " IS NULL OR " + finiteSql("trimBoth(attribute_value)") + " < 0, 0, " + finiteSql("trimBoth(attribute_value)") + " >= " + Number(o.msi.threshold) + + ", 2, " + finiteSql("trimBoth(attribute_value)") + " < " + Number(o.msi.stableBelow ?? o.msi.threshold) + ", 1, 0)" + : "multiIf(" + v + " IN (" + (o.msi.high || []).map((x) => sq(x.trim().toLowerCase())).join(", ") + "), 2, " + v + " IN (" + (o.msi.stable || []).map((x) => sq(x.trim().toLowerCase())).join(", ") + "), 1, 0)"; + const by = o.by ? orgAttr(o.by) : null; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "pp AS (\n" + + " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = 'MUTATION_EXTENDED' AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "cov AS (\n" + + " SELECT pp.pid AS pid, max(pp.panel = 'WES' OR gp.gene = 'POLE') AS pole, max(pp.panel = 'WES' OR gp.gene = 'TP53') AS tp53\n" + + " FROM pp LEFT JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = pp.panel AND gp.gene IN ('POLE', 'TP53')\n" + + " GROUP BY pid\n" + + "),\n" + + "mut AS (\n" + + " SELECT " + gPid + " AS pid,\n" + + " max(g.hugo_gene_symbol = 'POLE' AND g.mutation_variant IN (" + hot + ")) AS pole_hot,\n" + + " max(g.hugo_gene_symbol = 'TP53' AND " + (o.p53Pin ? "ann.is_driver" : "g.mutation_type IN (" + MOL_TP53_CLASSES.map(sq).join(", ") + ")") + ") AS p53\n" + + " FROM " + db + ".genomic_event_derived g\n" + (o.p53Pin ? driverJoin(o.p53Pin) : "") + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED'\n" + + " AND lowerUTF8(g.mutation_status) != 'germline' AND NOT g.off_panel AND g.hugo_gene_symbol IN ('POLE', 'TP53')\n" + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "msi AS (\n" + + " SELECT " + pid + " AS pid, max(" + msiState + ") AS m\n" + + " FROM " + clinicalTable(db, [attr]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(attr) + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "cls AS (\n" + + " SELECT c.pid AS pid,\n" + + " multiIf(cov.pid = '', 'NC', mut.pole_hot = 1, 'POLEmut', cov.pole = 0, 'NC', msi.m = 2, 'MMRd', msi.m = 0, 'NC', cov.tp53 = 0, 'NC', mut.p53 = 1, 'p53abn', 'NSMP') AS cls,\n" + + " multiIf(cov.pid = '', 'not sequenced', mut.pole_hot = 1, '', cov.pole = 0, 'not profiled for POLE', msi.m = 2, '', msi.m = 0, 'no MSI call', cov.tp53 = 0, 'not profiled for TP53', '') AS why\n" + + " FROM (SELECT DISTINCT pid FROM cohort) c\n" + + " LEFT JOIN cov ON cov.pid = c.pid LEFT JOIN mut ON mut.pid = c.pid LEFT JOIN msi ON msi.pid = c.pid\n" + + "),\n" + + "grp AS (\n" + + (by + ? " SELECT " + pid + " AS pid, if(uniqExact(" + v + ") > 1, 'Multiple', any(trimBoth(attribute_value))) AS g\n" + + " FROM " + clinicalTable(db, [by]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(by) + " AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + : " SELECT pid, '' AS g FROM cohort\n") + + ")" + ); +} diff --git a/lib/views/molsubtype/types.ts b/lib/views/molsubtype/types.ts new file mode 100644 index 0000000..2f52fa9 --- /dev/null +++ b/lib/views/molsubtype/types.ts @@ -0,0 +1,57 @@ +// The molsubtype view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { KmCurve, LogRankK } from "../../types.ts"; + +/** + * A published molecular classifier applied per patient as derived subtypes — the endometrial + * classification (TCGA 2013 / ProMisE / WHO 2020): POLE-ultramutated, else MMR-deficient (MSI-high), + * else p53-abnormal, else no specific molecular profile, with "not classifiable" where an input is + * missing. Per cohort: subtype shares, subtype by an attribute, and OS by subtype. Counts only. + */ +export interface MolsubtypeView { + note?: string; + kind: "molsubtype"; cohorts?: string[]; + /** the classifier (only "endometrial" so far) */ + classifier?: "endometrial"; + /** the MSI call: a categorical attribute's MSI-high and stable values, or a numeric score's cut-offs (default MSI_TYPE Instable / Stable, else MSI_SCORE ≥ 10 / < 3) */ + msi?: { attribute?: string; high?: string[]; stable?: string[]; threshold?: number; stableBelow?: number }; + /** an abnormal p53: an OncoKB-driver TP53 mutation (default) or any protein-changing TP53 mutation */ + p53?: "driver" | "nonsynonymous"; + /** a patient attribute to cross the subtypes with (e.g. ANCESTRY_LABEL, STAGE_HIGHEST_RECORDED) */ + by?: string; + /** groups of `by` drawn (largest first; default 8), the rest pooled into a named Other */ + top?: number; + /** draw OS by subtype (default true) */ + survival?: boolean; +} +export interface MolsubtypeCounts { counts: number[]; classified: number; notClassifiable: number; why: Record } +export interface MolsubtypePanel extends MolsubtypeCounts { + cohort: string; patients: number; + groups?: (MolsubtypeCounts & { label: string })[]; + /** cohort's classified patients by group: chi-squared over group × subtype */ + byTest?: import("../../stats.ts").ChiSquareTest; + pooled?: string[]; +} +export interface MolsubtypeResult { + classifier: string; classifierLabel: string; + /** the subtypes in hierarchy order */ + subtypes: string[]; + msi: string; p53: string; poleHotspots: string[]; + by: string | null; + panels: MolsubtypePanel[]; + /** classified patients' subtype shares between cohorts (two or more) */ + cohortTest?: import("../../stats.ts").ChiSquareTest; + /** OS by subtype, per cohort (classified patients with OS_MONTHS) */ + survival?: { cohort: string; curves: (Omit & { subtype: string })[]; logrank: LogRankK | null; noTest?: string }[]; + noSurvival?: string; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { molsubtype: MolsubtypeView } + interface ViewResultFields { + molsubtype: { + molsubtype?: MolsubtypeResult; + }; + } + interface ViewResultMain { molsubtype: "molsubtype" } +} diff --git a/lib/figures/multistate.ts b/lib/views/multistate/figure.ts similarity index 97% rename from lib/figures/multistate.ts rename to lib/views/multistate/figure.ts index 4e7c966..8fe7ce1 100644 --- a/lib/figures/multistate.ts +++ b/lib/views/multistate/figure.ts @@ -1,14 +1,15 @@ // Multistate figure: per cohort the share of patients in each state over time (Aalen–Johansen, // stacked), the Nelson–Aalen cumulative hazard of each transition with one line per cohort, and a // table of each transition's events, rate and hazard ratio against the first cohort. -import type { DrawnResultOf, MultistateTransitionKey } from "../types.ts"; +import type { DrawnResultOf } from "../../types.ts"; import { palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, legend, label, textWidth, seriesStyle, fmtInt, fmtNum, fmtPct, fmtRatio, fmtP, FS, SW, SERIES, OTHER, type SvgOpts, -} from "../svg-kit.ts"; -import { stepPath, r1 } from "../chart/marks.ts"; -import { timeScale } from "../chart/time.ts"; -import { lineSwatch } from "../chart/legend.ts"; +} from "../../svg-kit.ts"; +import { stepPath, r1 } from "../../chart/marks.ts"; +import { timeScale } from "../../chart/time.ts"; +import { lineSwatch } from "../../chart/legend.ts"; +import type { MultistateTransitionKey } from "./types.ts"; /** the three states: alive without progression, alive after it, dead */ export const MS_STATES = [ diff --git a/lib/views/multistate.ts b/lib/views/multistate/run.ts similarity index 96% rename from lib/views/multistate.ts rename to lib/views/multistate/run.ts index d95e994..9930016 100644 --- a/lib/views/multistate.ts +++ b/lib/views/multistate/run.ts @@ -2,12 +2,14 @@ // on the timeline's clock — transition rates, Nelson–Aalen cumulative hazards, Aalen–Johansen state // occupation and a Cox hazard ratio per transition against the first cohort. Each participant // returns id-free (prevalent, t_progression, t_end, dead, n) counts. -import type { ViewResult, MultistateView, MultistateCohort, MultistateCox, MultistateRate, MultistateTransitionKey } from "../types.ts"; -import { multistateCountsSql, labTestsSql, CLOCK_SLACK_DAYS, DAYS_PER_MONTH, SEQUENCING_EVENT } from "../federation-views.ts"; -import { illnessDeath, coxRegression, COX_MAX_ITERATIONS, Z_975, type IllnessDeathRecord, type CoxRow } from "../stats.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, plural } from "./shared.ts"; -import { progressionEvent, progressionError, progressionCondition, clockFailures, CLOCK_TOLERANCE } from "../timeline-endpoints.ts"; +import type { ViewResult } from "../../types.ts"; +import { labTestsSql, CLOCK_SLACK_DAYS, DAYS_PER_MONTH } from "../../federation-views.ts"; +import { illnessDeath, coxRegression, COX_MAX_ITERATIONS, Z_975, type IllnessDeathRecord, type CoxRow } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, plural } from "../shared.ts"; +import { progressionEvent, progressionError, progressionCondition, clockFailures, CLOCK_TOLERANCE } from "../../timeline-endpoints.ts"; +import type { MultistateView, MultistateCohort, MultistateCox, MultistateRate, MultistateTransitionKey } from "./types.ts"; +import { multistateCountsSql, SEQUENCING_EVENT } from "./sql.ts"; const MS_COHORT_LIMIT = 4; /** curves are drawn while at least this many of a cohort's patients are still followed */ diff --git a/lib/views/multistate/sql.ts b/lib/views/multistate/sql.ts new file mode 100644 index 0000000..99612fb --- /dev/null +++ b/lib/views/multistate/sql.ts @@ -0,0 +1,60 @@ +// The multistate view's SQL: what its runner (run.ts) asks each participant's database for. +// multistate: the illness–death model (start → progression → death) from the timeline's day 0 +import { type ProgressionEvent, osCte, lastDayCte, progressionCte, cohortEventsFrom, eventHasKv, DAYS_PER_MONTH, CLOCK_SLACK_DAYS } from "../../federation-views.ts"; +import { foldedEqualsSql } from "../../event-names.ts"; + +/** the event type whose day-0 record says the timeline's clock starts at sequencing */ +export const SEQUENCING_EVENT = "Sequencing"; + +/** + * Per cohort, counts of patient paths through the illness–death model on the timeline's clock, in + * months from day 0: `prevalent` (a progression event on or before day 0, so the patient starts in + * the progression state), `tp` the first progression after day 0 strictly before the end of + * follow-up (0 when none), `te` the end of follow-up — OS_MONTHS, the timeline's last follow-up + * day being OS_MONTHS × DAYS_PER_MONTH — and `dead` (OS_STATUS 1:…). Flags beside them, for the + * caveat: `afterend` a progression recorded only on or after the end of follow-up (not counted), + * `assessed` any record of the progression key at all (a patient never imaged cannot show + * progression), `seq0` a Sequencing event on day 0 (what day 0 is), and `late` a timeline event + * more than CLOCK_SLACK_DAYS past the end of follow-up (the clock check). 'nofollowup' patients + * (no OS, or OS_MONTHS ≤ 0) are counted apart. Every cohort patient is in exactly one row's n. + */ +export function multistateCountsSql(cohortSql: string, study: string, db: string, pr: ProgressionEvent): string { + return "WITH cohort AS (" + cohortSql + "),\n" + [ + osCte(study, db), + lastDayCte(study, db), + progressionCte(study, db, pr), + "pp AS (SELECT pid, countIf(day <= 0) AS n_pre, countIf(day > 0) AS n_after, minIf(day, day > 0) AS first_after FROM prog GROUP BY pid)", + "assessed AS (\n" + + " SELECT DISTINCT p.stable_id AS pid\n" + cohortEventsFrom(db, study, "ce") + + " AND " + foldedEqualsSql("ce.event_type", pr.eventType) + "\n" + + eventHasKv(db, "ce", pr.key, "1") + + ")", + "seq0 AS (\n" + + " SELECT DISTINCT p.stable_id AS pid\n" + cohortEventsFrom(db, study, "ce") + + " AND " + foldedEqualsSql("ce.event_type", SEQUENCING_EVENT) + " AND ce.start_date = 0\n" + + ")", + "per AS (\n" + + " SELECT os.months AS months, os.months IS NOT NULL AS has_os, os.dead AS dead,\n" + + " pp.pid != '' AND pp.n_pre > 0 AS prevalent, pp.pid != '' AND pp.n_after > 0 AS has_after, toFloat64(pp.first_after) AS fa,\n" + + " a.pid != '' AS assessed, s.pid != '' AS seq0, lastday.d AS ld\n" + + " FROM (SELECT DISTINCT pid FROM cohort) c\n" + + " LEFT JOIN os ON os.pid = c.pid\n" + + " LEFT JOIN pp ON pp.pid = c.pid\n" + + " LEFT JOIN assessed a ON a.pid = c.pid\n" + + " LEFT JOIN seq0 s ON s.pid = c.pid\n" + + " LEFT JOIN lastday ON lastday.pid = c.pid\n" + + ")", + "cls AS (\n" + + " SELECT multiIf(NOT has_os OR months <= 0, 'nofollowup', 'record') AS kind,\n" + + " NOT prevalent AND has_after AND fa < months * " + DAYS_PER_MONTH + " AS hit,\n" + + " NOT prevalent AND has_after AND fa >= months * " + DAYS_PER_MONTH + " AS afterend,\n" + + " if(hit, fa / " + DAYS_PER_MONTH + ", 0) AS tp, months AS te,\n" + + " prevalent, dead, assessed, seq0, ld > months * " + DAYS_PER_MONTH + " + " + CLOCK_SLACK_DAYS + " AS late\n" + + " FROM per\n" + + ")", + ].join(",\n") + "\n" + + "SELECT kind, if(kind = 'record', toUInt8(prevalent), 0) AS prevalent, if(kind = 'record', round(tp, 4), 0) AS tp,\n" + + " if(kind = 'record', round(te, 4), 0) AS te, if(kind = 'record', toUInt8(dead), 0) AS dead,\n" + + " toUInt8(afterend) AS afterend, toUInt8(assessed) AS assessed, toUInt8(seq0) AS seq0, toUInt8(late) AS late, count() AS n\n" + + "FROM cls GROUP BY kind, prevalent, tp, te, dead, afterend, assessed, seq0, late ORDER BY te"; +} diff --git a/lib/views/multistate/types.ts b/lib/views/multistate/types.ts new file mode 100644 index 0000000..411bb83 --- /dev/null +++ b/lib/views/multistate/types.ts @@ -0,0 +1,72 @@ +// The multistate view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +export interface MultistateView { + note?: string; + kind: "multistate"; cohorts?: string[]; + /** the progression event (default event type Diagnosis, key PROGRESSION, values Y / Yes), and an optional probability floor */ + progression?: { eventType?: string; key?: string; values?: string[]; probabilityKey?: string; minProbability?: number }; + /** patients with a progression on or before day 0: start in the progression state (include, default) or are left out (exclude) */ + prevalent?: "include" | "exclude"; + /** months at which the state occupation is tabled (default [12, 24, 60]) */ + horizons?: number[]; + /** administrative censoring, months */ + cutoffMonths?: number; +} +/** a transition of the illness–death model: 01 start → progression, 02 start → death, 12 progression → death */ +export type MultistateTransitionKey = "01" | "02" | "12"; +export interface MultistateRate { + key: MultistateTransitionKey; events: number; personMonths: number; + /** events per 100 person-months in the state left, with its 95% interval (log scale, Poisson) */ + rate: number; lo: number | null; hi: number | null; +} +export interface MultistateCohort { + cohort: string; + /** the cohort's patients; those in the model (n), and of them those starting in the progression state */ + cohortPatients: number; n: number; prevalent: number; + excluded: { noFollowup: number; prevalent: number }; + /** in the model: patients with no record of the progression key at all, a progression only on or after the end of follow-up, a Sequencing event on day 0, a timeline past follow-up */ + notAssessed: number; afterEnd: number; seq0: number; late: number; + /** censored in the start and in the progression state */ + censored: { start: number; progression: number }; + rates: MultistateRate[]; + /** Aalen–Johansen state occupation: p0 alive without progression, p1 alive after progression, p2 dead */ + occupation: { t: number; p0: number; p1: number; p2: number }[]; + /** Nelson–Aalen cumulative hazard per transition, with its 95% interval (log scale) */ + cumhaz: Record; + /** curves are drawn to here: the last time at least 10 of the cohort's patients are still followed */ + drawnTo: number; + /** occupation at each horizon, with the number still followed; null past drawnTo */ + atHorizon: { months: number; p0: number | null; p1: number | null; p2: number | null; atRisk: number }[]; +} +export interface MultistateCox { + key: MultistateTransitionKey; + /** each cohort's hazard ratio against the first (a Cox model per transition, stratified by study) */ + rows: { cohort: string; hr: number; lo: number; hi: number; p: number }[]; + n: number; events: number; + /** why it was not fitted */ + noFit?: string; +} +export interface MultistateResult { + progression: { eventType: string; key: string; values: string[]; probabilityKey?: string; minProbability?: number }; + prevalent: "include" | "exclude"; horizons: number[]; cutoffMonths?: number; + /** the figures' time axis end: the latest cohort's drawnTo */ + tMax: number; + /** "first sequencing" when day 0 is a Sequencing event for most patients, else "the timeline's day 0" */ + origin: string; + cohorts: MultistateCohort[]; + /** reference cohort for the hazard ratios */ + reference: string; + cox: MultistateCox[]; + excludedStudies?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { multistate: MultistateView } + interface ViewResultFields { + multistate: { + multistate?: MultistateResult; + }; + } + interface ViewResultMain { multistate: "multistate" } +} diff --git a/lib/views/mutations/method.ts b/lib/views/mutations/method.ts new file mode 100644 index 0000000..103bc0e --- /dev/null +++ b/lib/views/mutations/method.ts @@ -0,0 +1,13 @@ +// The mutations view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type TableLists, type MethodRecord, tableSteps } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export function mutationsMethod(t: { gene: string; cohorts: string[]; oncokbPin: string | null; queryText: string; sortName: string | null; desc: boolean; shown: number; + lists: TableLists; excluded: { study: string; reason: string; patients: number }[]; instances: number }): MethodRecord { + const steps = tableSteps({ label: "mutations", what: t.gene + " mutation calls", queryText: t.queryText, shown: t.shown, lists: t.lists, instances: t.instances, excluded: t.excluded, + source: "every called " + t.gene + " mutation in each cohort's samples (uncalled calls left out), with the sample's CANCER_TYPE_DETAILED and MUTATION_COUNT where recorded" + + (t.oncokbPin ? " and the OncoKB " + t.oncokbPin + " call for its protein change" : "") + "; allele frequency = variant / (variant + reference) reads.", + order: (t.sortName ? "by " + t.sortName + (t.desc ? " descending" : "") + ", missing last" : "drivers first, then by codon") + ", then sample" }); + return { steps, implementation: { functions: ["mutationRowSource", "pageSql", "countsSql"], file: "lib/mutation-table.ts, lib/table-query.ts", commit: BUILD_COMMIT }, + verification: "A listing: no statistic is computed, so there is no script — the queries are the numbers." }; +} diff --git a/lib/views/mutations.ts b/lib/views/mutations/run.ts similarity index 90% rename from lib/views/mutations.ts rename to lib/views/mutations/run.ts index 452734b..b22b779 100644 --- a/lib/views/mutations.ts +++ b/lib/views/mutations/run.ts @@ -1,12 +1,13 @@ // Mutations view: the mutation table for one gene, on lib/table-query.ts -import type { ViewResult, MutationsView } from "../types.ts"; -import { geneSymbols } from "../gene-tracks.ts"; -import { MUTATION_COLUMNS, MUTATION_DEFAULT_COLUMNS, mutationRowSource, alterationOf } from "../mutation-table.ts"; -import { checkQuery, describeQuery, shownColumns } from "../table-query.ts"; -import { mutationsMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { TABLE_ROWS_MAX, rowHolders, runRows, tableTail, viewQuery, type Rows } from "./rows.ts"; -import { overLimit, refuse } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import { MUTATION_COLUMNS, MUTATION_DEFAULT_COLUMNS, mutationRowSource, alterationOf } from "../../mutation-table.ts"; +import { checkQuery, describeQuery, shownColumns } from "../../table-query.ts"; +import type { ViewRun } from "../run.ts"; +import { TABLE_ROWS_MAX, rowHolders, runRows, tableTail, viewQuery, type Rows } from "../rows.ts"; +import { overLimit, refuse } from "../shared.ts"; +import type { MutationsView } from "./types.ts"; +import { mutationsMethod } from "./method.ts"; /** A row's value in a column, from the query's row (numbers as numbers, the annotation spelled out as the SQL spells it). */ function mutationValue(r: Record, id: string, pin: string | null): string | number | null { diff --git a/lib/views/mutations/types.ts b/lib/views/mutations/types.ts new file mode 100644 index 0000000..3cc332a --- /dev/null +++ b/lib/views/mutations/types.ts @@ -0,0 +1,31 @@ +// The mutations view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { TableRows } from "../../types.ts"; +import type { TableQuery } from "../../table-query.ts"; + +/** + * The mutation table (cBioPortal's Mutations tab, under the lollipop): one gene's mutations in each + * cohort, a row per call with the portal's columns; searchable, filterable, sortable, paged, and + * downloadable whole. Its rows name samples, so only a patient-level participant contributes them. + */ +export interface MutationsView extends TableQuery { + note?: string; + kind: "mutations"; cohorts?: string[]; + gene: string; + /** rows per page (default 25, at most 1000); every page and the download are slices of one order */ + rows?: number; +} +export interface MutationsResult extends TableRows { + gene: string; + /** the OncoKB version the annotation column reads, or null when none is pinned */ + oncokbPin: string | null; +} + +declare module "../../types.ts" { + interface ViewSpecs { mutations: MutationsView } + interface ViewResultFields { + mutations: { + mutations?: MutationsResult; + }; + } + interface ViewResultMain { mutations: "mutations" } +} diff --git a/lib/views/oncoprint.ts b/lib/views/oncoprint/run.ts similarity index 93% rename from lib/views/oncoprint.ts rename to lib/views/oncoprint/run.ts index e0e7b9e..2475624 100644 --- a/lib/views/oncoprint.ts +++ b/lib/views/oncoprint/run.ts @@ -1,13 +1,14 @@ // Oncoprint view: genes by patients, built from patient-level participants only (its columns // ARE patients) — the rest are named as left out. -import { buildContract, PIDS_SQL, type PidsCarrier } from "../oncoprint.ts"; -import { geneSymbols } from "../gene-tracks.ts"; -import { varianceFromMoments } from "../stats.ts"; -import { contractToSvg } from "../oncoprint-svg.ts"; -import type { ViewResult, OncoprintView, ZReference } from "../types.ts"; -import { isPatientLevel } from "../federation.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { overLimit } from "./shared.ts"; +import { buildContract, PIDS_SQL, type PidsCarrier } from "../../oncoprint.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import { varianceFromMoments } from "../../stats.ts"; +import { contractToSvg } from "../../oncoprint-svg.ts"; +import type { ViewResult } from "../../types.ts"; +import { isPatientLevel } from "../../federation.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { overLimit } from "../shared.ts"; +import type { OncoprintView, ZReference } from "./types.ts"; export async function runOncoprint(view: DocView, c: ViewRun): Promise { const { doc, cohorts, resolvers, vNames, vStudies, participationFor } = c; diff --git a/lib/views/oncoprint/types.ts b/lib/views/oncoprint/types.ts new file mode 100644 index 0000000..5a6c57a --- /dev/null +++ b/lib/views/oncoprint/types.ts @@ -0,0 +1,71 @@ +// The oncoprint view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { SplitBy, DisplaySpec, CohortDef } from "../../types.ts"; +import type { GeneTrackSpec } from "../../gene-tracks.ts"; + +export interface HeatmapSpec { + profileType: string; genes?: string[]; + /** + * "federation" z-scores the drawn values against a reference distribution + * pooled from EVERY participating study — including instances that disclose + * no patients and so contribute no columns. Default "cohort" leaves values + * as the profile stores them. + */ + reference?: "cohort" | "federation"; +} +/** Pooled reference distribution for one (profileType, gene), from per-site moments. */ +export interface ZReference { mean: number; sd: number; n: number; studies: string[] } +/** A sort key: "genomic" (staircase), "id", or a track label; "-label" or {dir:"desc"} descends. */ +export type SortKeySpec = string | { track: string; dir?: "asc" | "desc" }; +export interface OncoprintView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; + /** rows: a gene symbol, or {label?, genes} for one merged row over several genes */ + kind: "oncoprint"; genes: GeneTrackSpec[]; cohorts?: string[]; splitBy?: SplitBy; + clinical?: string[]; heatmap?: HeatmapSpec[]; sort?: SortKeySpec[]; display?: DisplaySpec; + /** what a column is (default "patient"); "sample" draws every sample of the cohort's patients */ + unit?: "patient" | "sample"; + /** mRNA / protein evidence drawn into the gene cells (see OncoprintSpec.overlays) */ + overlays?: { kind: "mrna" | "protein"; profileType: string; threshold?: number }[]; + /** + * Document cohorts drawn as binary rows: a column is filled when that patient is in the + * cohort. Any cohort definition can be a row, which is what this engine has that the portal + * does not. Single-study and patient-level, like the definitions themselves. + */ + derivedCohorts?: string[]; +} + +export interface OncoprintSpec { + study?: string; studies?: string[]; + /** rows: a gene symbol, or {label?, genes} for one merged row over several genes */ + genes: GeneTrackSpec[]; clinical?: string[]; heatmap?: HeatmapSpec[]; sort?: SortKeySpec[]; display?: DisplaySpec; + /** what a column is (default "patient") */ + unit?: "patient" | "sample"; + /** + * Molecular evidence drawn INTO the gene cells rather than as tracks: mRNA as an outline, + * protein as thin bars. Values at or beyond ±threshold (default 2, the portal's z-score rule) + * set the high/low bit. Expression is not an alteration, so it never enters the altered %. + */ + overlays?: { kind: "mrna" | "protein"; profileType: string; threshold?: number }[]; + derivedEvents?: { label: string; expression: CohortDef }[]; + cohort?: { dsl: CohortDef } | null; + layout?: { groupBy: { kind: "cohorts"; groups: { name: string; dsl: CohortDef }[] } } | null; + conventions?: { lineGap?: number }; + /** "|" -> pooled reference, supplied by the analysis layer */ + zReference?: Record; +} + +declare module "../../types.ts" { + interface ViewSpecs { oncoprint: OncoprintView } + interface ViewResultFields { + oncoprint: { + /** + * only for `runAnalysis(doc, {oncoprint:"spec"})`: what to POST to + * /api/oncoprint to get the figure. Carries cohort DEFINITIONS, never compiled SQL. + */ + spec?: OncoprintSpec; + svg?: string; nColumns?: number; + genes?: { gene: string; driverPct: number | null; anyPct: number | null }[]; + }; + } + interface ViewResultMain { oncoprint: never } +} diff --git a/lib/figures/organotropism.ts b/lib/views/organotropism/figure.ts similarity index 97% rename from lib/figures/organotropism.ts rename to lib/views/organotropism/figure.ts index aa15bb5..d571df6 100644 --- a/lib/figures/organotropism.ts +++ b/lib/views/organotropism/figure.ts @@ -1,10 +1,11 @@ // Organotropism figure: a gene × site heatmap of the Mantel–Haenszel odds ratio of alteration with // metastasis to each site (outlined where q < 0.05), the strongest associations as a table, and, // from dated timeline sites, the cumulative incidence of one site by one gene's status. -import type { DrawnResultOf, OrganotropismCell } from "../types.ts"; -import { palette, frame, caption, label, textWidth, wrapWidth, fmtInt, fmtNum, fmtPct, fmtP, pDigits, esc, num, FS, SW, type SvgOpts } from "../svg-kit.ts"; -import { numericColor } from "../oncoprint-style.ts"; -import { kmPanel } from "./km.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { palette, frame, caption, label, textWidth, wrapWidth, fmtInt, fmtNum, fmtPct, fmtP, pDigits, esc, num, FS, SW, type SvgOpts } from "../../svg-kit.ts"; +import { numericColor } from "../../oncoprint-style.ts"; +import { kmPanel } from "../../figures/km.ts"; +import type { OrganotropismCell } from "./types.ts"; const PAD = 8, LOG2_CLAMP = 1, TABLE_ROWS = 10, MIN_CELL = 18, MAX_CELL = 56, HEAD_MAX = 120; diff --git a/lib/views/organotropism.ts b/lib/views/organotropism/run.ts similarity index 97% rename from lib/views/organotropism.ts rename to lib/views/organotropism/run.ts index a5b74fd..3ee8e18 100644 --- a/lib/views/organotropism.ts +++ b/lib/views/organotropism/run.ts @@ -1,14 +1,14 @@ // Organotropism view: for each organ a cohort's patients metastasised to and each gene, whether the // gene's alteration goes with metastasis there within cancer types (Cochran–Mantel–Haenszel), and, // from dated timeline sites, the cumulative incidence of one site by one gene's status. -import type { ViewResult, OrganotropismView, OrganotropismCell, OrganotropismSite, OrganotropismIncidence, IncidenceCurve } from "../types.ts"; -import { - orgSourcesSql, orgSitesSql, orgOtherValuesSql, orgTopGenesSql, orgCountsSql, orgIncidenceSql, - type OrgSource, type OrgSourceKind, type EnrichAlteration, -} from "../federation-views.ts"; -import { cochranMantelHaenszel, benjaminiHochberg, aalenJohansen, cifAt, grayPermutationTest, Z_975, type CrRecord } from "../stats.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, plural, ATTR_NAME } from "./shared.ts"; +import type { ViewResult, IncidenceCurve } from "../../types.ts"; +import { type EnrichAlteration } from "../../federation-views.ts"; +import { cochranMantelHaenszel, benjaminiHochberg, aalenJohansen, cifAt, grayPermutationTest, Z_975, type CrRecord } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, plural, ATTR_NAME } from "../shared.ts"; +import type { OrganotropismView, OrganotropismCell, OrganotropismSite, OrganotropismIncidence } from "./types.ts"; +import { orgSourcesSql, orgSitesSql, orgOtherValuesSql, orgTopGenesSql, orgCountsSql, orgIncidenceSql } from "./sql.ts"; +import type { OrgSource, OrgSourceKind } from "./sql.ts"; const ORG_MAX_GENES = 20, ORG_MAX_SITES = 30; const ORG_DEFAULTS = { prefix: "DMETS_DX_", eventType: "Diagnosis", key: "TUMOR_SITE", attribute: "METASTATIC_SITE" }; diff --git a/lib/views/organotropism/sql.ts b/lib/views/organotropism/sql.ts new file mode 100644 index 0000000..5cc888d --- /dev/null +++ b/lib/views/organotropism/sql.ts @@ -0,0 +1,236 @@ +// The organotropism view's SQL: what its runner (run.ts) asks each participant's database for. +import { pidOf, orgAttr, incidenceCountsSql, type EnrichAlteration, geneList, ENRICH_PANEL_TYPE, enrichEventCond } from "../../federation-views.ts"; +import { clinicalTable } from "../../custom-data.ts"; +import { sq } from "../../compiler.ts"; +import { foldedEqualsSql, clinicalValueFoldSql, nameFoldSql } from "../../event-names.ts"; +import { driverJoin } from "../../federation.ts"; + +/** Which site sources the cohort's patients carry: patients with a flag attribute (and how many + * flags), with a site on the timeline, and with a biopsy-site value. Counts only. */ +export function orgSourcesSql(cohortSql: string, study: string, db: string, o: { prefix: string; eventType: string; key: string; attribute: string }): string { + const pid = pidOf("patient_unique_id", study), prefix = orgAttr(o.prefix), attr = orgAttr(o.attribute); + return ( + "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT\n" + + " (SELECT uniqExact(" + pid + ") FROM " + clinicalTable(db, []) + " WHERE cancer_study_identifier = " + sq(study) + + " AND startsWith(attribute_name, " + sq(prefix) + ") AND " + pid + " IN (SELECT pid FROM cohort)) AS flag_patients,\n" + + " (SELECT uniqExact(attribute_name) FROM " + clinicalTable(db, []) + " WHERE cancer_study_identifier = " + sq(study) + + " AND startsWith(attribute_name, " + sq(prefix) + ") AND " + pid + " IN (SELECT pid FROM cohort)) AS flags,\n" + + " (SELECT uniqExact(p.stable_id) FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", o.eventType) + " AND " + foldedEqualsSql("d.key", o.key) + + " AND trimBoth(d.value) != '' AND p.stable_id IN (SELECT pid FROM cohort)) AS timeline_patients,\n" + + " (SELECT uniqExact(" + pid + ") FROM " + clinicalTable(db, [attr]) + " WHERE cancer_study_identifier = " + sq(study) + + " AND attribute_name = " + sq(attr) + " AND trimBoth(attribute_value) != '' AND " + pid + " IN (SELECT pid FROM cohort)) AS sample_patients\n" + + "FROM system.one" + ); +} + +/** Per site: patients evaluable for it and those with a metastasis there. Counts only. */ +export function orgSitesSql(cohortSql: string, study: string, db: string, src: OrgSource): string { + return ( + "WITH cohort AS (" + cohortSql + "),\n" + orgSiteCtes(study, db, src) + "\n" + + "SELECT ps.site AS site, any(sl.label) AS label, count() AS evaluable, sum(ps.has) AS with_site\n" + + "FROM ps INNER JOIN sl ON sl.site = ps.site GROUP BY site" + ); +} + +/** Sample source: the biopsy-site values that ORG_SITE_MAP sends to "Other", with their patients. Counts only. */ +export function orgOtherValuesSql(cohortSql: string, study: string, db: string, attribute: string): string { + const pid = pidOf("patient_unique_id", study), attr = orgAttr(attribute); + return ( + "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT trimBoth(attribute_value) AS value, uniqExact(" + pid + ") AS patients\n" + + "FROM " + clinicalTable(db, [attr]) + "\n" + + "WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(attr) + " AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort) AND " + orgSiteMapExpr("attribute_value") + " = 'Other'\n" + + "GROUP BY value ORDER BY patients DESC, value LIMIT 30" + ); +} + +/** The cohort's most altered genes (patients with the alteration on a covering panel), at most `limit`. Counts only. */ +export function orgTopGenesSql(cohortSql: string, study: string, db: string, g: Omit, limit: number): string { + return ( + "WITH cohort AS (" + cohortSql + "),\n" + orgGeneCtes(study, db, { ...g, genes: null }) + "\n" + + "SELECT gene, count() AS altered FROM alt GROUP BY gene ORDER BY altered DESC, gene LIMIT " + Math.max(1, Math.min(200, Math.floor(limit))) + ); +} + +/** + * Per (site, gene, stratum): the patients evaluable for the site AND profiled for the gene, those + * of them with a metastasis there, those altered, and those both — the four margins of one 2×2 + * per stratum (a patient's `stratifyBy` value; "Multiple" when their samples disagree, '' when none + * is recorded). Counts only. + */ +export function orgCountsSql(cohortSql: string, study: string, db: string, src: OrgSource, g: OrgGenes & { genes: string[] }, stratifyBy: string | null): string { + const pid = pidOf("patient_unique_id", study); + const strat = stratifyBy ? orgAttr(stratifyBy) : null; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + orgSiteCtes(study, db, src) + ",\n" + orgGeneCtes(study, db, g) + ",\n" + + "st AS (\n" + + (strat + ? " SELECT " + pid + " AS pid, if(uniqExact(" + clinicalValueFoldSql("attribute_value") + ") > 1, 'Multiple', any(trimBoth(attribute_value))) AS s\n" + + " FROM " + clinicalTable(db, [strat]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(strat) + " AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + : " SELECT pid, '' AS s FROM cohort\n") + + ")\n" + + "SELECT ps.site AS site, prof.gene AS gene, st.s AS stratum, count() AS n, sum(ps.has) AS with_site,\n" + + " countIf(alt.pid != '') AS altered, countIf(ps.has = 1 AND alt.pid != '') AS both\n" + + "FROM ps INNER JOIN prof ON prof.pid = ps.pid\n" + + " LEFT JOIN alt ON alt.pid = ps.pid AND alt.gene = prof.gene\n" + + " LEFT JOIN st ON st.pid = ps.pid\n" + + "GROUP BY site, gene, stratum" + ); +} + +/** + * Cumulative incidence of the first timeline site event (src's event type, key = `siteValue`) with + * death competing, for the cohort's patients profiled for `gene` and altered in it (`altered`) or + * not: incidenceCountsSql over that half of the cohort. Counts only. + */ +export function orgIncidenceSql(cohortSql: string, study: string, db: string, src: OrgSource, g: OrgGenes & { genes: [string] }, altered: boolean, siteValue: string): string { + const half = "SELECT pid FROM (" + cohortSql + ") WHERE pid IN (\n WITH cohort AS (" + cohortSql + "),\n" + orgGeneCtes(study, db, g) + "\n" + + " SELECT pid FROM prof)\n AND pid " + (altered ? "" : "NOT ") + "IN (\n WITH cohort AS (" + cohortSql + "),\n" + orgGeneCtes(study, db, g) + "\n SELECT pid FROM alt)"; + return incidenceCountsSql(half, study, db, { eventType: src.eventType || "Diagnosis", attrs: [{ key: src.key || "TUMOR_SITE", value: siteValue }] }, null); +} + +// --------------------------------------------------------------------------- +// organotropism: which genes go with metastasis to which organ + +export type OrgSourceKind = "flags" | "timeline" | "sample"; +export interface OrgSource { + kind: OrgSourceKind; + /** flags: the per-organ attributes' common prefix (e.g. DMETS_DX_), valued Yes / No */ + prefix?: string; + /** timeline: the event type and the key whose value names the site */ + eventType?: string; key?: string; + /** sample: the attribute naming a metastasis sample's biopsy site, mapped through ORG_SITE_MAP */ + attribute?: string; +} + +/** + * The organ a free-text biopsy site is counted under: the first rule whose pattern matches the + * lower-cased value; anything else is "Other". The rules are ours, written against the public + * METASTATIC_SITE vocabularies (msk_met_2021, msk_chord_2024, msk_impact_50k_2026), and every + * result lists the values that fell to "Other". + */ +export const ORG_SITE_MAP: [string, string][] = [ + ["Lymph node", "lymph|\\bln\\b|node|axilla"], + ["CNS/Brain", "brain|cereb|csf|epidural|dura\\b|dural|mening|spinal cord|cns"], + ["Liver", "liver|hepat"], + ["Lung", "^lung|pulmon|\\blung\\b"], + ["Pleura", "pleura"], + ["Bone", "bone|spine|vertebr|\\brib\\b|femur|iliac|sacr|stern|scapula|humer|skull|acetab|clavic|tibia|ischi|pubic|calvari"], + ["Adrenal gland", "adrenal"], + ["Peritoneum / abdomen", "periton|omentum|mesenter|abdom|diaphragm|intra-abdominal|ascites"], + ["Bowel / GI tract", "bowel|colon|rect|intestin|duoden|ileum|jejun|append|stomach|gastr|esophag|biliary|gallbladder|pancrea"], + ["Skin / soft tissue", "skin|scalp|soft tissue|chest wall|subcut|muscle|\\bback\\b|paraspinal|breast"], + ["Genital organs", "ovar|uter|vagin|adnex|endomet|cervix|fallop|genital|testi|prostat|vulva"], + ["Kidney / urinary tract", "kidney|renal|bladder|ureter|urethr|\\but\\b"], + ["Head and neck", "head|neck|thyroid|parotid|oral|tongue|larynx|orbit"], + ["Mediastinum / heart", "mediastin|pericard|heart"], +]; + +/** A biopsy-site value's organ, in SQL (ORG_SITE_MAP's rules over the lower-cased, trimmed value). */ +export function orgSiteMapExpr(expr: string): string { + const v = "lowerUTF8(trimBoth(" + expr + "))"; + return "multiIf(" + ORG_SITE_MAP.map(([organ, re]) => "match(" + v + ", " + sq(re) + "), " + sq(organ)).join(", ") + ", 'Other')"; +} +const ORG_YES = "('yes', 'y', 'true', '1')", ORG_NO = "('no', 'n', 'false', '0')"; + +/** + * `ps`: one row per (patient, site) the source can speak to, `has` 1 when the patient has a + * metastasis there; `sl`: each site's label. Flags: a patient is evaluable for a site when its + * flag is recorded Yes or No. Timeline: a patient with any site recorded is evaluable for every + * site seen. Sample: every cohort patient is evaluable; a site is one a sample was taken from. + */ +function orgSiteCtes(study: string, db: string, src: OrgSource): string { + const pid = pidOf("patient_unique_id", study); + if (src.kind === "flags") { + const prefix = orgAttr(src.prefix || "DMETS_DX_"); + return ( + "fl AS (\n" + + " SELECT " + pid + " AS pid, substring(attribute_name, " + (prefix.length + 1) + ") AS site,\n" + + " max(" + clinicalValueFoldSql("attribute_value") + " IN " + ORG_YES + ") AS has,\n" + + " max(" + clinicalValueFoldSql("attribute_value") + " IN " + ORG_YES + " OR " + clinicalValueFoldSql("attribute_value") + " IN " + ORG_NO + ") AS known\n" + + " FROM " + clinicalTable(db, []) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND startsWith(attribute_name, " + sq(prefix) + ") AND length(attribute_name) > " + prefix.length + "\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid, site\n" + + "),\n" + + "ps AS (SELECT pid, site, toUInt8(has) AS has FROM fl WHERE known),\n" + + "sl AS (SELECT DISTINCT site, site AS label FROM ps)" + ); + } + if (src.kind === "timeline") { + return ( + "evr AS (\n" + + " SELECT DISTINCT p.stable_id AS pid, " + nameFoldSql("d.value") + " AS site, trimBoth(d.value) AS raw\n" + + " FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", src.eventType || "Diagnosis") + "\n" + + " AND " + foldedEqualsSql("d.key", src.key || "TUMOR_SITE") + " AND trimBoth(d.value) != ''\n" + + " AND p.stable_id IN (SELECT pid FROM cohort)\n" + + "),\n" + + "ev AS (SELECT DISTINCT pid, site FROM evr),\n" + + "sl AS (SELECT site, min(raw) AS label FROM evr GROUP BY site),\n" + + "ps AS (\n" + + " SELECT e.pid AS pid, s.site AS site, toUInt8((e.pid, s.site) IN (SELECT pid, site FROM ev)) AS has\n" + + " FROM (SELECT DISTINCT pid FROM ev) e CROSS JOIN (SELECT site FROM sl) s\n" + + ")" + ); + } + const attr = orgAttr(src.attribute || "METASTATIC_SITE"); + return ( + "sm AS (\n" + + " SELECT DISTINCT " + pid + " AS pid, " + orgSiteMapExpr("attribute_value") + " AS site\n" + + " FROM " + clinicalTable(db, [attr]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(attr) + " AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "sl AS (SELECT DISTINCT site, site AS label FROM sm),\n" + + "ps AS (\n" + + " SELECT c.pid AS pid, s.site AS site, toUInt8((c.pid, s.site) IN (SELECT pid, site FROM sm)) AS has\n" + + " FROM (SELECT DISTINCT pid FROM cohort) c CROSS JOIN (SELECT site FROM sl) s\n" + + ")" + ); +} + +export interface OrgGenes { genes: string[] | null; alteration: EnrichAlteration; driverPin: string | null } + +/** `prof` (patient, gene) for every listed gene a patient's panel covers for the alteration type (whole-exome + * covers all); `alt` the same pairs with the alteration called on a covering panel (OncoKB drivers only with + * driverPin). Without a gene list, `alt` holds every gene. Both read `cohort`. */ +function orgGeneCtes(study: string, db: string, g: OrgGenes): string { + const sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study); + const list = g.genes ? geneList(g.genes) : null; + return ( + "pp AS (\n" + + " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(ENRICH_PANEL_TYPE[g.alteration]) + "\n" + + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + (list + ? "prof AS (\n" + + " SELECT DISTINCT pp.pid AS pid, gp.gene AS gene FROM pp JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = pp.panel WHERE gp.gene IN (" + list + ")\n" + + " UNION DISTINCT\n" + + " SELECT pid, arrayJoin([" + list + "]) AS gene FROM pp WHERE panel = 'WES'\n" + + "),\n" + : "") + + "alt AS (\n" + + " SELECT DISTINCT " + gPid + " AS pid, g.hugo_gene_symbol AS gene\n" + + " FROM " + db + ".genomic_event_derived g\n" + (g.driverPin ? driverJoin(g.driverPin) : "") + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND " + enrichEventCond(g.alteration) + " AND NOT g.off_panel\n" + + (list ? " AND g.hugo_gene_symbol IN (" + list + ")\n" : "") + (g.driverPin ? " AND ann.is_driver\n" : "") + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + ")" + ); +} diff --git a/lib/views/organotropism/types.ts b/lib/views/organotropism/types.ts new file mode 100644 index 0000000..deb4b1e --- /dev/null +++ b/lib/views/organotropism/types.ts @@ -0,0 +1,78 @@ +// The organotropism view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { IncidenceCurve } from "../../types.ts"; + +/** + * Metastatic organotropism: for each organ a cohort's patients metastasised to and each gene, + * whether the gene's alteration goes with metastasis there, stratified by cancer type + * (Cochran–Mantel–Haenszel); with dated sites on the timeline, the cumulative incidence of + * metastasis to one site by one gene's status, death competing. Counts only. + */ +export interface OrganotropismView { + note?: string; + kind: "organotropism"; cohorts?: string[]; + /** where the sites come from (default auto: per-organ flags, else the timeline, else a biopsy-site attribute) */ + source?: "auto" | "flags" | "timeline" | "sample"; + /** flags: the per-organ Yes/No attributes' prefix (default DMETS_DX_, MSK-MET's) */ + flagPrefix?: string; + /** timeline: the event type and key whose value names a site (default Diagnosis / TUMOR_SITE) */ + timeline?: { eventType?: string; key?: string }; + /** sample: the biopsy-site attribute of metastasis samples (default METASTATIC_SITE), mapped to organs by our disclosed rules */ + siteAttribute?: string; + /** only these sites (labels as the result names them); default every site with enough patients */ + sites?: string[]; + /** the genes (at most 20); default the `top` most altered in the cohort */ + genes?: string[]; + /** how many most-altered genes when `genes` is not given (default 12, at most 20) */ + top?: number; + /** what counts as altered (default mutation) and whether only OncoKB drivers count (default driver) */ + alteration?: "mutation" | "cna" | "amp" | "homdel" | "sv"; + measure?: "any" | "driver"; + /** the stratifying covariate (default CANCER_TYPE); null for none */ + stratifyBy?: string | null; + /** a cell is tested only with at least this many altered patients and this many with the site (default 5) */ + minPatients?: number; + /** timeline source: the cumulative-incidence panel's site and gene (default the grid's most significant cell); null for none */ + incidence?: { site?: string; gene?: string } | null; +} +export interface OrganotropismSite { site: string; label: string; evaluable: number; withSite: number } +export interface OrganotropismCell { + site: string; gene: string; + /** patients evaluable for the site and profiled for the gene, pooled over strata: with the site (and of them altered), without it (and altered) */ + withSite: number; withAltered: number; without: number; withoutAltered: number; + /** Mantel–Haenszel common odds ratio over the strata, its log2, CMH chi-squared p and BH q; null when not tested */ + or: number | null; log2Or: number | null; p: number | null; q: number | null; strata: number; +} +export interface OrganotropismIncidence { + site: string; gene: string; + curves: IncidenceCurve[]; + horizons: { months: number; cells: { cohort: string; f1: number; lo: number; hi: number; atRisk: number }[] }[]; + gray?: import("../../stats.ts").GrayTest; + /** patients whose first site event came after their last follow-up (the clock check) */ + late: number; records: number; +} +export interface OrganotropismResult { + source: "flags" | "timeline" | "sample"; sourceLabel: string; + alteration: string; measure: "any" | "driver"; stratifyBy: string | null; minPatients: number; + patients: number; + sites: OrganotropismSite[]; + genes: { gene: string; altered?: number }[]; + /** every (site, gene) cell, tested or not */ + cells: OrganotropismCell[]; + tested: number; significant: number; + /** sample source: the biopsy-site values counted as "Other", with their patients */ + otherValues?: { value: string; patients: number }[]; + incidence?: OrganotropismIncidence; + /** why there is no incidence panel */ + noIncidence?: string; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { organotropism: OrganotropismView } + interface ViewResultFields { + organotropism: { + organotropism?: OrganotropismResult; + }; + } + interface ViewResultMain { organotropism: "organotropism" } +} diff --git a/lib/figures/overlap.ts b/lib/views/overlap/figure.ts similarity index 99% rename from lib/figures/overlap.ts rename to lib/views/overlap/figure.ts index e652d89..176dd93 100644 --- a/lib/figures/overlap.ts +++ b/lib/views/overlap/figure.ts @@ -2,8 +2,8 @@ import { palette, frame, caption, label, legend, textWidth, packLines, linear, axisLeft, leftAxisWidth, seriesStyle, fmtInt, esc, num, FS, SW, OTHER, type SvgOpts -} from "../svg-kit.ts"; -import type { ViewResultOf, DrawnResultOf } from "../types.ts"; +} from "../../svg-kit.ts"; +import type { ViewResultOf, DrawnResultOf } from "../../types.ts"; type OvResult = NonNullable["overlap"]>; type Pal = ReturnType; @@ -29,7 +29,6 @@ function distanceFor(r1: number, r2: number, area: number): number { /** a set's fill: its series colour, and past six the same hue lighter */ const setFill = (i: number) => { const s = seriesStyle(i); return { color: s.color, opacity: 0.55 * s.tint }; }; - const VENN_TOP = 24; /** diff --git a/lib/views/overlap/method.ts b/lib/views/overlap/method.ts new file mode 100644 index 0000000..7b10332 --- /dev/null +++ b/lib/views/overlap/method.ts @@ -0,0 +1,15 @@ +// The overlap view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import type { MethodRecord, MethodStep } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export function overlapMethod(t: { cohorts: string[]; perSite: { study: string; cohorts: string[]; patients: number }[]; union: number; intersections: number }): MethodRecord { + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Count patients per combination of cohorts", + detail: "The overlap query above: every patient in any of " + s.cohorts.join(", ") + " gets a bitmask of the cohorts it is in, and the study returns one count per bitmask. Counts only.", + scope: s.study, rowsOut: s.patients, + })); + steps.push({ kind: "transform", title: "Add the counts across studies and name each combination", + detail: "Each study's bits are mapped back to the view's cohort order; combinations add exactly, and a patient can only overlap within one study.", + params: [["patients in any cohort", t.union.toLocaleString("en-US")], ["combinations with patients", String(t.intersections)]] }); + return { steps, implementation: { functions: [], file: "lib/views/overlap/run.ts", commit: BUILD_COMMIT } }; +} diff --git a/lib/views/overlap.ts b/lib/views/overlap/run.ts similarity index 92% rename from lib/views/overlap.ts rename to lib/views/overlap/run.ts index 9ef3c01..c765f2c 100644 --- a/lib/views/overlap.ts +++ b/lib/views/overlap/run.ts @@ -1,9 +1,10 @@ // Overlap view: how the cohorts' patients intersect -import type { ViewResult, OverlapView } from "../types.ts"; -import { overlapCountsSql } from "../federation-views.ts"; -import { overlapMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, plural, refuse } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, plural, refuse } from "../shared.ts"; +import type { OverlapView } from "./types.ts"; +import { overlapCountsSql } from "./sql.ts"; +import { overlapMethod } from "./method.ts"; const OVERLAP_COHORT_LIMIT = 6; diff --git a/lib/views/overlap/sql.ts b/lib/views/overlap/sql.ts new file mode 100644 index 0000000..faf0ef1 --- /dev/null +++ b/lib/views/overlap/sql.ts @@ -0,0 +1,21 @@ +// The overlap view's SQL: what its runner (run.ts) asks each participant's database for. +// overlap: how several cohorts' patients intersect +import { pidOf } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; + +/** + * Per membership pattern — a bitmask, bit i set when the patient is in cohort i — how many of the + * study's patients in any of the cohorts have exactly that pattern. 2^k − 1 counts at most, and + * nothing else; the caller names each bit. + */ +export function overlapCountsSql(cohortSqls: string[], study: string, db: string): string { + if (!cohortSqls.length) throw new Error("overlapCountsSql needs at least one cohort"); + const ctes = cohortSqls.map((c, i) => "c" + i + " AS (" + c + ")").join(",\n"); + const union = cohortSqls.map((_, i) => "SELECT pid FROM c" + i).join(" UNION DISTINCT "); + const mask = cohortSqls.map((_, i) => "(pid IN (SELECT pid FROM c" + i + ")) * " + (1 << i)).join(" + "); + // each patient's samples ride along, so the same patterns are counted in samples too + return "WITH " + ctes + ",\nu AS (" + union + "),\nm AS (SELECT pid, " + mask + " AS mask FROM u),\n" + + "s AS (SELECT " + pidOf("patient_unique_id", study) + " AS pid, count() AS ns FROM " + db + ".sample_derived\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM u) GROUP BY pid)\n" + + "SELECT mask, count() AS n, sum(s.ns) AS samples FROM m LEFT JOIN s ON s.pid = m.pid GROUP BY mask ORDER BY n DESC"; +} diff --git a/lib/views/overlap/types.ts b/lib/views/overlap/types.ts new file mode 100644 index 0000000..654775a --- /dev/null +++ b/lib/views/overlap/types.ts @@ -0,0 +1,30 @@ +// The overlap view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Overlap (cBioPortal's group-comparison Overlap tab): how the cohorts' patients intersect, as an + * UpSet plot — every combination of cohorts with how many patients are in exactly those. Counts only. + */ +export interface OverlapView { + note?: string; + kind: "overlap"; cohorts?: string[]; +} +export interface OverlapResult { + /** the cohorts, in bit order, with each one's own size in patients and in their samples */ + sets: { name: string; size: number; samples?: number }[]; + /** patients (and their samples) in exactly the named cohorts and no other, largest first */ + intersections: { members: string[]; n: number; samples?: number }[]; + /** patients in at least one cohort */ + union: number; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { overlap: OverlapView } + interface ViewResultFields { + overlap: { + overlap?: OverlapResult; + }; + } + interface ViewResultMain { overlap: "overlap" } +} diff --git a/lib/figures/paired.ts b/lib/views/paired/figure.ts similarity index 98% rename from lib/figures/paired.ts rename to lib/views/paired/figure.ts index ad5490a..259ebb5 100644 --- a/lib/figures/paired.ts +++ b/lib/views/paired/figure.ts @@ -2,8 +2,8 @@ import { esc, palette, frame, caption, linear, axisBottom, bottomAxisHeight, legend, label, textWidth, wrapWidth, fmtInt, fmtPct, pDigits, textOn, FS, SW, OTHER, SERIES, type SvgOpts -} from "../svg-kit.ts"; -import type { DrawnResultOf } from "../types.ts"; +} from "../../svg-kit.ts"; +import type { DrawnResultOf } from "../../types.ts"; const PAD = 8, GUT = 14, ROW = 20, BAR = 14, MIN_BAR_W = 200; diff --git a/lib/views/paired/method.ts b/lib/views/paired/method.ts new file mode 100644 index 0000000..3c351d6 --- /dev/null +++ b/lib/views/paired/method.ts @@ -0,0 +1,71 @@ +// The paired view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, driverMeaning, ALTERED_MEANING, type MethodStep, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface PairedTrace { + pairBy: { attribute: string; a: string[]; b: string[] }; + /** each study's own sides, when they differ from `pairBy` */ + perStudy?: Record; + labels?: { a: string; b: string }; + measure: "driver" | "any"; + oncokbPin: string | null; + /** present when every altered gene was tested and the `top` most one-sided drawn */ + scan?: { top: number }; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + panels: { cohort: string; patients: number; tested?: number }[]; + counts: { cohort: string; study: string; gene: string; pairs: number; both: number; aOnly: number; bOnly: number }[]; + /** what the view reported per cohort and gene — for the script check */ + results: { cohort: string; gene: string; p: number; q?: number }[]; +} + +export function pairedMethod(t: PairedTrace): MethodRecord { + const aL = t.labels?.a ?? t.pairBy.a.join("/"), bL = t.labels?.b ?? t.pairBy.b.join("/"); + const what = t.measure === "driver" ? driverMeaning(t.oncokbPin).replace(/^driver: /, "") : ALTERED_MEANING.replace(/^altered: /, ""); + const steps: MethodStep[] = t.perSite.map((s) => { + const sp = t.perStudy?.[s.study] || t.pairBy; + return { + kind: "fetch" as const, title: "Count each gene's state in paired samples", + detail: "The paired:patients and paired:genes queries above. Patients with both a " + sp.attribute + " = " + sp.a.join("/") + " sample (" + aL + ") and a " + + sp.attribute + " = " + sp.b.join("/") + " sample (" + bL + "); per gene" + (t.scan ? " — every gene altered (" + what + ") in some such patient" : "") + + ", among those profiled for it on both sides by each side's own panels: altered (" + what + ") on both sides, on the " + aL + + " side only, or the " + bL + " side only. A patient with several samples on one side counts as altered there if any is. Counts only.", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + }; + }); + if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's counts across its studies", detail: "The counts add exactly." }); + steps.push({ kind: "statistic", title: "Exact McNemar test per gene", + detail: "Only the discordant patients carry information about direction: of the " + aL + "-only and " + bL + "-only patients, each is a fair coin under the null, so p is twice the smaller binomial tail (capped at 1). Concordance is both ÷ altered anywhere.", + params: t.panels.map((p) => [p.cohort, p.patients.toLocaleString() + " patients with both kinds of sample"] as [string, string]) }); + steps.push({ kind: "statistic", title: "Benjamini–Hochberg across genes, per cohort", detail: "q-values over the genes with at least one discordant patient, separately for each cohort." + + (t.scan ? " In a scan that is every such gene, not only the ones drawn." : "") }); + if (t.scan) steps.push({ kind: "transform", title: "Draw the " + t.scan.top + " most one-sided genes", + detail: "Of the genes with a discordant patient, ordered by McNemar p, then by the size of the imbalance between " + aL + "-only and " + bL + + "-only patients, then by the number of discordant patients, then by name; the first " + t.scan.top + " are drawn and reported.", + params: t.panels.map((p) => [p.cohort, (p.tested ?? 0).toLocaleString() + " genes tested"] as [string, string]) }); + const python = [ + "# Recompute with statsmodels " + VERIFIED.mcnemar.version + ", the version our code is tested against.", + "import io", + "import pandas as pd", + "from statsmodels.stats.contingency_tables import mcnemar", + "from statsmodels.stats.multitest import multipletests", + "", + RESULT_INIT, + ...pyTable("counts", "What the paired:genes queries returned per cohort and study: patients altered on both sides, on the " + aL + " side only, on the " + bL + " side only, of those profiled on both", + ["cohort", "study", "gene", "pairs", "both", "a_only", "b_only"], t.counts.map((r) => [r.cohort, r.study, r.gene, r.pairs, r.both, r.aOnly, r.bOnly]), "paired_counts.csv"), + "g = counts.groupby([\"cohort\", \"gene\"], sort=False)[[\"pairs\", \"both\", \"a_only\", \"b_only\"]].sum().reset_index()", + "g[\"concordance\"] = g[\"both\"] / (g[\"both\"] + g[\"a_only\"] + g[\"b_only\"])", + "g[\"p\"] = [mcnemar([[r.both, r.a_only], [r.b_only, 0]], exact=True).pvalue if r.a_only + r.b_only else 1.0 for r in g.itertuples()]", + "for cohort, c in g.groupby(\"cohort\", sort=False):", + " tested = c[c[\"a_only\"] + c[\"b_only\"] > 0]", + " g.loc[tested.index, \"q\"] = multipletests(tested[\"p\"], method=\"fdr_bh\")[1] if len(tested) else []", + "print(g.to_string(index=False))", + "for r in g.itertuples():", + " RESULT[f\"paired:{r.cohort}:{r.gene}:p\"] = r.p", + " if r.a_only + r.b_only:", + " RESULT[f\"paired:{r.cohort}:{r.gene}:q\"] = r.q", + ].join("\n") + "\n"; + return { steps, implementation: { functions: ["mcnemarExact", "benjaminiHochberg"], file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "The exact McNemar test matches statsmodels " + VERIFIED.mcnemar.version + " mcnemar(exact=True) and Benjamini–Hochberg multipletests, to a relative 1e-6 (test/tables-golden.test.ts).", + python: withResult(python), + check: checks("paired:", t.results, (r) => r.cohort + ":" + r.gene, [["p", "p"], ["q", "q"]]) }; +} diff --git a/lib/views/paired.ts b/lib/views/paired/run.ts similarity index 95% rename from lib/views/paired.ts rename to lib/views/paired/run.ts index 934fcdd..9973921 100644 --- a/lib/views/paired.ts +++ b/lib/views/paired/run.ts @@ -1,11 +1,14 @@ // Paired view: primary against metastasis (or any two kinds of sample), within patients -import type { ViewResult, PairedView, PairedPanel } from "../types.ts"; -import { pairedCountsSql, pairedPatientsSql, sampleAttributeValuesSql } from "../federation-views.ts"; -import { benjaminiHochberg, mcnemarExact } from "../stats.ts"; -import { geneSymbols } from "../gene-tracks.ts"; -import { pairedMethod, type PairedTrace } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, ATTR_NAME, answering, plural, refuse } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { sampleAttributeValuesSql } from "../../federation-views.ts"; +import { benjaminiHochberg, mcnemarExact } from "../../stats.ts"; +import { geneSymbols } from "../../gene-tracks.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, ATTR_NAME, answering, plural, refuse } from "../shared.ts"; +import type { PairedView, PairedPanel } from "./types.ts"; +import { pairedCountsSql, pairedPatientsSql } from "./sql.ts"; +import { pairedMethod } from "./method.ts"; +import type { PairedTrace } from "./method.ts"; const PAIRED_TOP_DEFAULT = 20, PAIRED_TOP_MAX = 40; diff --git a/lib/views/paired/sql.ts b/lib/views/paired/sql.ts new file mode 100644 index 0000000..476f975 --- /dev/null +++ b/lib/views/paired/sql.ts @@ -0,0 +1,55 @@ +// The paired view's SQL: what its runner (run.ts) asks each participant's database for. +import { sideCountsTail, pidOf } from "../../federation-views.ts"; +import { clinicalValueFoldSql } from "../../event-names.ts"; +import { sq } from "../../compiler.ts"; +import { clinicalTable } from "../../custom-data.ts"; + +/** + * Per gene, among patients with a sample on each side AND profiled for the gene on each side + * (by that side's own panels — a gene one side never assayed says nothing about concordance): + * altered on both sides, on side a only, on side b only. A patient with several samples on a + * side is altered on that side if any of them is. Counts only. + * + * `genes` null scans: every gene with a counted alteration in a paired patient's sample, and only + * the genes with at least one patient altered on some side come back (a gene altered nowhere + * carries no information about concordance or direction). + */ +export function pairedCountsSql(cohortSql: string, study: string, db: string, genes: string[] | null, by: PairBy, + pin: string | null, measure: "driver" | "any"): string { + return pairedSidesCtes(cohortSql, study, db, by) + ",\n" + sideCountsTail(study, db, genes, pin, measure); +} + +/** How many of the cohort's patients have a sample on each side, and how many samples each side holds. */ +export function pairedPatientsSql(cohortSql: string, study: string, db: string, by: PairBy): string { + return ( + pairedSidesCtes(cohortSql, study, db, by) + "\n" + + "SELECT uniqExact(pid) AS patients, countIf(side = 'a') AS a_samples, countIf(side = 'b') AS b_samples FROM ps" + ); +} + +// --------------------------------------------------------------------------- +// paired: one patient's two kinds of sample, gene by gene + +export interface PairBy { attribute: string; a: string[]; b: string[] } + +/** + * The cohort's samples split into side 'a' and side 'b' by a SAMPLE-level attribute (folded on + * case and whitespace in both the data and the values, the one fold every clinical predicate + * uses), restricted to patients with at least one sample on each side. + */ +function pairedSidesCtes(cohortSql: string, study: string, db: string, by: PairBy): string { + const fold = (x: string) => clinicalValueFoldSql(x); + const lit = (vs: string[]) => vs.map((v) => fold(sq(v))).join(","); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "sides AS (\n" + + " SELECT sample_unique_id AS suid, " + pidOf("patient_unique_id", study) + " AS pid,\n" + + " multiIf(" + fold("attribute_value") + " IN (" + lit(by.a) + "), 'a', " + fold("attribute_value") + " IN (" + lit(by.b) + "), 'b', '') AS side\n" + + " FROM " + clinicalTable(db, [by.attribute]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(by.attribute) + "\n" + + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "paired AS (SELECT pid FROM sides GROUP BY pid HAVING countIf(side = 'a') > 0 AND countIf(side = 'b') > 0),\n" + + "ps AS (SELECT suid, pid, side FROM sides WHERE side != '' AND pid IN (SELECT pid FROM paired))" + ); +} diff --git a/lib/views/paired/types.ts b/lib/views/paired/types.ts new file mode 100644 index 0000000..4373491 --- /dev/null +++ b/lib/views/paired/types.ts @@ -0,0 +1,70 @@ +// The paired view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Paired samples: among patients with a sample of each of two kinds (by default primary and + * metastasis), gene by gene, how often an alteration is on both, only the first or only the + * second — counted only where BOTH sides were profiled for the gene — with an exact McNemar test + * of whether discordance runs one way. Counts only. + * + * Without `genes` it scans: every gene altered in some paired patient is tested, and the `top` + * most discordant are drawn — the way to ask "what is acquired after treatment" without + * guessing the genes first. + */ +export interface PairSides { attribute: string; a: string | string[]; b: string | string[] } +export interface PairedView { + note?: string; + kind: "paired"; cohorts?: string[]; + /** the genes to test; omitted or empty scans every gene altered in a paired patient */ + genes?: string[]; + /** with no `genes`: how many of the most discordant genes to draw (default 20, at most 40) */ + top?: number; + /** + * a sample-level attribute and the values on each side (default SAMPLE_TYPE: Primary / Metastasis). + * `studies` gives a study its own attribute and values — studies label the same two kinds of + * sample differently (PRE-TX / POST-TX, pre-treatment / post-treatment) — and the counts are + * pooled across them; a study not listed uses the attribute and values at the top. + */ + pairBy?: Partial & { + studies?: Record; + /** what to call each side in the figure and text (default: its values) */ + labels?: { a: string; b: string }; + }; + /** count OncoKB drivers only (default) or any alteration */ + measure?: "driver" | "any"; +} +export interface PairedGene { + gene: string; + /** patients with each side profiled for this gene */ + pairs: number; both: number; aOnly: number; bOnly: number; neither: number; + /** both ÷ (both + aOnly + bOnly): of the patients altered anywhere, altered on both sides */ + concordance: number | null; + /** exact McNemar p on aOnly vs bOnly, and BH q across the genes of this cohort */ + p: number; q: number; +} +export interface PairedPanel { + cohort: string; patients: number; aSamples: number; bSamples: number; genes: PairedGene[]; + /** a scan: the genes with at least one discordant patient, which q is taken across (only `genes` are drawn) */ + tested?: number; +} +export interface PairedResult { + /** the sides as each answering study resolved them; `labels` names them in the figure and text */ + pairBy: { attribute: string; a: string[]; b: string[] }; + perStudy?: Record; + labels: { a: string; b: string }; + /** present when the view scanned rather than testing named genes */ + scan?: { top: number }; + measure: "driver" | "any"; + panels: PairedPanel[]; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { paired: PairedView } + interface ViewResultFields { + paired: { + paired?: PairedResult; + }; + } + interface ViewResultMain { paired: "paired" } +} diff --git a/lib/figures/pathways.ts b/lib/views/pathways/figure.ts similarity index 97% rename from lib/figures/pathways.ts rename to lib/views/pathways/figure.ts index 7a33f27..315a870 100644 --- a/lib/figures/pathways.ts +++ b/lib/views/pathways/figure.ts @@ -5,12 +5,13 @@ // their pathway's row; inside a tile each cohort has a line of its own (a mini bar on the // pathway bars' scale, and its percentage), so the numbers never run together however many // cohorts there are. -import type { PathwayRow, PathwayCell, DrawnResultOf } from "../types.ts"; +import type { DrawnResultOf } from "../../types.ts"; import { palette, frame, caption, label, legend, textWidth, linear, seriesStyle, fmtPct, fmtQ, fmtInt, esc, num, FS, SW, type SvgOpts -} from "../svg-kit.ts"; -import { CN_GAIN, CN_LOSS } from "./common.ts"; +} from "../../svg-kit.ts"; +import { CN_GAIN, CN_LOSS } from "../../figures/common.ts"; +import type { PathwayRow, PathwayCell } from "./types.ts"; const PAD = 8, COL_GAP = 16, TILE_GAP = 6, BAR_H = 10, BAR_PITCH = 14, MINI = 26, LINE = 12, HEAD = 34; diff --git a/lib/views/pathways/method.ts b/lib/views/pathways/method.ts new file mode 100644 index 0000000..288eec7 --- /dev/null +++ b/lib/views/pathways/method.ts @@ -0,0 +1,65 @@ +// The pathways view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, driverMeaning, ALTERED_MEANING, type MethodStep, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface PathwaysTrace { + measure: "driver" | "any"; + oncokbPin: string | null; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + sets: { name: string; source: string; genes: string[] }[]; + cohorts: string[]; + setCounts: { cohort: string; set: string; profiled: number; altered: number }[]; + geneCounts: { cohort: string; gene: string; profiled: number; altered: number }[]; + /** what the view reported (two cohorts only): each pathway's p and q, and the q of each top gene — for the script check */ + results: { pathways: { name: string; p?: number; q?: number }[]; genes: { gene: string; q: number }[] }; +} + +export function pathwaysMethod(t: PathwaysTrace): MethodRecord { + const two = t.cohorts.length === 2; + const what = t.measure === "driver" ? driverMeaning(t.oncokbPin).replace(/^driver: /, "") : ALTERED_MEANING.replace(/^altered: /, ""); + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Count patients altered per pathway and per gene", + detail: "The geneCounts and pathways:sets queries above. Per pathway: patients profiled for at least one member gene (the denominator) and of those, patients with " + what + + " in at least one member. Per member gene: the same over the patients profiled for that gene. Counts only.", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + params: [["pathways / sets", t.sets.map((x) => x.name + " (" + x.genes.length + ")").join(", ")]], + })); + if (new Set(t.perSite.map((s) => s.cohort)).size < t.perSite.length) steps.push({ kind: "transform", title: "Add each cohort's counts across its studies", detail: "The counts add exactly." }); + if (two) { + steps.push({ kind: "statistic", title: "Fisher's exact test per pathway and per member gene", detail: "Two-sided, altered vs not among the profiled, between the two cohorts." }); + steps.push({ kind: "statistic", title: "Benjamini–Hochberg", detail: "Across the pathways drawn, and separately across every member gene tested, each gene once even when it sits in two pathways." }); + } + const python = [ + "# Recompute" + (two ? " with scipy " + VERIFIED.fisher.version + " and statsmodels " + VERIFIED.bh.version + ", the versions our code is tested against" : "") + ".", + "import io", + "import pandas as pd", + ...(two ? ["from scipy.stats import fisher_exact", "from statsmodels.stats.multitest import multipletests"] : []), + "", + RESULT_INIT, + ...pyTable("sets", "Per cohort and pathway: patients profiled for at least one member gene, and altered in at least one", ["cohort", "set", "profiled", "altered"], + t.setCounts.map((r) => [r.cohort, r.set, r.profiled, r.altered]), "pathway_counts.csv"), + ...pyTable("genes", "Per cohort and member gene: patients profiled for it, and altered in it", ["cohort", "gene", "profiled", "altered"], + t.geneCounts.map((r) => [r.cohort, r.gene, r.profiled, r.altered]), "gene_counts.csv"), + "cohorts = " + JSON.stringify(t.cohorts), + "for name, table in ((\"pathway\", sets.groupby([\"set\", \"cohort\"]).sum()), (\"gene\", genes.groupby([\"gene\", \"cohort\"]).sum())):", + " table[\"pct\"] = 100 * table[\"altered\"] / table[\"profiled\"]", + ...(two ? [ + " rows = []", + " for key in table.index.get_level_values(0).unique():", + " t = table.loc[key].reindex(cohorts).fillna(0)", + " if (t[\"profiled\"] > 0).all():", + " (a, pa), (b, pb) = [(r.altered, r.profiled) for r in t.itertuples()]", + " rows.append((key, fisher_exact([[a, pa - a], [b, pb - b]]).pvalue))", + " res = pd.DataFrame(rows, columns=[name, \"p\"])", + " res[\"q\"] = multipletests(res[\"p\"], method=\"fdr_bh\")[1]", + " print(res.sort_values(\"p\").to_string(index=False))", + " for r in res.itertuples():", + " RESULT[f\"pathways:{name}:{getattr(r, name)}:p\"], RESULT[f\"pathways:{name}:{getattr(r, name)}:q\"] = r.p, r.q", + ] : [" print(table.to_string())"]), + ].join("\n") + "\n"; + return { steps, implementation: { functions: two ? ["fisherExact", "benjaminiHochberg"] : [], file: "lib/stats.ts", commit: BUILD_COMMIT }, + ...(two ? { verification: "Fisher's exact test matches scipy " + VERIFIED.fisher.version + " fisher_exact and Benjamini–Hochberg statsmodels multipletests, to a relative 1e-6 (test/tables-golden.test.ts)." } : {}), + python: withResult(python), + check: { ...checks("pathways:pathway:", t.results.pathways, (r) => r.name, [["p", "p"], ["q", "q"]]), + ...checks("pathways:gene:", t.results.genes, (r) => r.gene, [["q", "q"]]) } }; +} diff --git a/lib/views/pathways.ts b/lib/views/pathways/run.ts similarity index 96% rename from lib/views/pathways.ts rename to lib/views/pathways/run.ts index fbbffe6..f4ec887 100644 --- a/lib/views/pathways.ts +++ b/lib/views/pathways/run.ts @@ -1,11 +1,12 @@ // Pathways view: oncogenic signalling pathways, per cohort -import type { ViewResult, PathwaysView, PathwayRow, PathwayCell } from "../types.ts"; -import { SANCHEZ_VEGA_2018, PATHWAY_NAMES, type PathwayRole } from "../pathways.ts"; -import { geneSetCountsSql } from "../federation-views.ts"; -import { fisherExact, benjaminiHochberg } from "../stats.ts"; -import { pathwaysMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse } from "./shared.ts"; +import type { ViewResult } from "../../types.ts"; +import { SANCHEZ_VEGA_2018, PATHWAY_NAMES, type PathwayRole } from "../../pathways.ts"; +import { fisherExact, benjaminiHochberg } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse } from "../shared.ts"; +import type { PathwaysView, PathwayRow, PathwayCell } from "./types.ts"; +import { geneSetCountsSql } from "./sql.ts"; +import { pathwaysMethod } from "./method.ts"; const GENE_SYMBOL = /^[A-Z0-9][A-Z0-9.-]{0,19}$/; const PATHWAYS_COHORT_LIMIT = 4; diff --git a/lib/views/pathways/sql.ts b/lib/views/pathways/sql.ts new file mode 100644 index 0000000..a872596 --- /dev/null +++ b/lib/views/pathways/sql.ts @@ -0,0 +1,56 @@ +// The pathways view's SQL: what its runner (run.ts) asks each participant's database for. +// pathways: is ANY gene of a set altered? +import { pidOf } from "../../federation-views.ts"; +import { sq } from "../../compiler.ts"; +import { driverJoin, ALTERED_COND } from "../../federation.ts"; + +/** + * Per gene SET, for one cohort in one study: patients profiled for at least one member (any + * alteration type), altered in at least one member, and carrying a driver in at least one + * member — the oncoprint's merged-row rule, so a set's percentage is over the UNION of its + * members' panels. Built from exactly the CTEs geneCountsSql uses (same event table, the same + * high-level-CNA rule, the same driver join, the same panel coverage), so a one-gene set gives + * that gene's own 'any' counts. + */ +export function geneSetCountsSql(cohortSql: string, sets: { name: string; genes: string[] }[], study: string, + pin: string | null, db: string): string { + const genes = [...new Set(sets.flatMap((x) => x.genes))]; + const geneList = genes.map(sq).join(","); + const pairs = sets.flatMap((x) => x.genes.map((g) => "(" + sq(x.name) + "," + sq(g) + ")")).join(","); + const pid = pidOf("g.patient_unique_id", study), covPid = pidOf("sd.patient_unique_id", study); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "members AS (SELECT tupleElement(m, 1) AS set_name, tupleElement(m, 2) AS gene FROM (SELECT arrayJoin([" + pairs + "]) AS m)),\n" + + "altered AS (\n" + + " SELECT g.hugo_gene_symbol AS gene, " + pid + " AS pid,\n" + + (pin ? " max(ann.is_driver) AS is_driver\n" : " 0 AS is_driver\n") + + " FROM " + db + ".genomic_event_derived g\n" + + (pin ? driverJoin(pin) : "") + + " WHERE g.cancer_study_identifier = " + sq(study) + "\n" + + " AND g.hugo_gene_symbol IN (" + geneList + ") AND " + ALTERED_COND + "\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY gene, pid\n" + + "),\n" + + "covered AS (\n" + + " SELECT DISTINCT pg.gene AS gene, " + covPid + " AS pid\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".gene_panel_to_gene_derived pg ON pg.gene_panel_id = sp.gene_panel_id AND pg.gene IN (" + geneList + ")\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + "\n" + + " AND sp.alteration_type IN ('MUTATION_EXTENDED','COPY_NUMBER_ALTERATION','STRUCTURAL_VARIANT')\n" + + " AND " + covPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "per AS (\n" + + " SELECT members.set_name AS set_name, x.pid AS pid, max(x.is_alt) AS is_alt, max(x.is_drv) AS is_drv\n" + + " FROM (\n" + + " SELECT gene, pid, 1 AS is_alt, is_driver > 0 AS is_drv FROM altered\n" + + " UNION ALL\n" + + " SELECT gene, pid, 0 AS is_alt, 0 AS is_drv FROM covered\n" + + " ) x\n" + + " INNER JOIN members ON members.gene = x.gene\n" + + " GROUP BY set_name, pid\n" + + ")\n" + + "SELECT set_name, uniqExact(pid) AS profiled, uniqExactIf(pid, is_alt = 1) AS altered, uniqExactIf(pid, is_drv = 1) AS driver\n" + + "FROM per GROUP BY set_name" + ); +} diff --git a/lib/views/pathways/types.ts b/lib/views/pathways/types.ts new file mode 100644 index 0000000..a344ea3 --- /dev/null +++ b/lib/views/pathways/types.ts @@ -0,0 +1,49 @@ +// The pathways view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Oncogenic signalling pathways: for each pathway (the ten of Sanchez-Vega et al. 2018, and/or + * the document's own gene sets), the share of each cohort with at least one member altered — + * over the union of the members' panels, the oncoprint's merged-row rule — and its most-altered + * member genes, each with its own panel-aware denominator. Counts only. + */ +export interface PathwaysView { + note?: string; + kind: "pathways"; cohorts?: string[]; + /** which of the ten canonical pathways to draw (default: all ten) */ + pathways?: string[]; + /** the document's own gene sets, drawn after the canonical ones: ≤4 sets of ≤40 genes */ + sets?: { name: string; genes: string[] }[]; + /** count OncoKB drivers only (default, as the paper curated oncogenic events) or any alteration */ + measure?: "driver" | "any"; + /** member genes drawn per pathway, the rest pooled and counted (default 6, max 12) */ + topGenes?: number; +} +export interface PathwayCell { cohort: string; profiled: number; altered: number; pct: number | null } +export interface PathwayRow { + name: string; source: "Sanchez-Vega 2018" | "document"; + /** member genes, and how many were profiled in at least one cohort */ + genes: number; profiledGenes: number; + cells: PathwayCell[]; + /** two cohorts: two-sided Fisher p, and BH q across the pathways drawn */ + p?: number; q?: number; + top: { gene: string; role: "OG" | "TSG" | null; cells: PathwayCell[]; q?: number }[]; + /** member genes altered somewhere but not among `top` — pooled, never dropped */ + otherAltered: number; + /** member genes no cohort was profiled for */ + notProfiled: string[]; +} +export interface PathwaysResult { + measure: "driver" | "any"; rows: PathwayRow[]; + excluded?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { pathways: PathwaysView } + interface ViewResultFields { + pathways: { + pathways?: PathwaysResult; + }; + } + interface ViewResultMain { pathways: "pathways" } +} diff --git a/lib/figures/plot.ts b/lib/views/plot/figure.ts similarity index 99% rename from lib/figures/plot.ts rename to lib/views/plot/figure.ts index 8f79767..285df81 100644 --- a/lib/figures/plot.ts +++ b/lib/views/plot/figure.ts @@ -4,14 +4,14 @@ // The cells, the quartiles and the coefficients arrive already computed, which is why the same // picture draws for a hundred cases or four hundred thousand. The only cases drawn one by one are // the points the engine chose (every case under the cap, the outliers over it), at most POINTS_CAP. -import type { DrawnResultOf } from "../types.ts"; -import type { HighlightPoint, PlotData, PlotLayer } from "../plot.ts"; -import { isAbsent, jitterFor } from "../plot-draw.ts"; -import { LAYER_BASE } from "../plot-layers.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { HighlightPoint, PlotData, PlotLayer } from "../../plot.ts"; +import { isAbsent, jitterFor } from "../../plot-draw.ts"; +import { LAYER_BASE } from "../../plot-layers.ts"; import { esc, palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, bandLabels, legend, label, textWidth, fmtInt, fmtNum, fmtP, FS, SW, SERIES, OTHER, type Scale, type SvgOpts, type LegendItem -} from "../svg-kit.ts"; +} from "../../svg-kit.ts"; type Pal = ReturnType; const r1 = (n: number) => Math.round(n * 10) / 10; diff --git a/lib/views/plot.ts b/lib/views/plot/run.ts similarity index 99% rename from lib/views/plot.ts rename to lib/views/plot/run.ts index 7e8bc91..a35ace0 100644 --- a/lib/views/plot.ts +++ b/lib/views/plot/run.ts @@ -1,13 +1,14 @@ // Plot view: two axes over one cohort's cases, drawn as a scatter, box or contingency table as // the axes decide; local, or federated where every participant can answer. -import type { PlotView, ViewResult } from "../types.ts"; -import { pearsonFromMoments, regressionFromMoments, type Moments } from "../stats.ts"; -import { isPatientLevel, hasFederatedPlot, legendFor, type Resolver, type FederatedPlotCapable, type PatientLevelResolver } from "../federation.ts"; +import type { ViewResult } from "../../types.ts"; +import { pearsonFromMoments, regressionFromMoments, type Moments } from "../../stats.ts"; +import { isPatientLevel, hasFederatedPlot, legendFor, type Resolver, type FederatedPlotCapable, type PatientLevelResolver } from "../../federation.ts"; import { DEFAULT_BINS, CATEGORY_CAP, POINTS_CAP, OUTLIER_CELL_MAX, tukey, coverageNote, combineNote, sparseCellMax, - type AxisKind, type HighlightPoint, type PlotData, type PlotGroup, type PlotLayer, type PlotPoint, type PlotRequest, type PointRank } from "../plot.ts"; -import { DEFAULT_POINT_LAYERS, LAYER_COLORS, POINT_LAYER_CAP, POINT_RULE_N, POINT_RULE_N_MAX, layerLabel, type PointLayerSpec } from "../plot-layers.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { resolveAxes } from "../plot-profile.ts"; + type AxisKind, type HighlightPoint, type PlotData, type PlotGroup, type PlotLayer, type PlotPoint, type PlotRequest, type PointRank } from "../../plot.ts"; +import { DEFAULT_POINT_LAYERS, LAYER_COLORS, POINT_LAYER_CAP, POINT_RULE_N, POINT_RULE_N_MAX, layerLabel, type PointLayerSpec } from "../../plot-layers.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { resolveAxes } from "../../plot-profile.ts"; +import type { PlotView } from "./types.ts"; type BoxRow = { g: string; n: number; lo: number; hi: number; q1: number; median: number; q3: number }; diff --git a/lib/views/plot/types.ts b/lib/views/plot/types.ts new file mode 100644 index 0000000..f68ec57 --- /dev/null +++ b/lib/views/plot/types.ts @@ -0,0 +1,49 @@ +// The plot view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; +import type { PlotAxis, PlotData } from "../../plot.ts"; +import type { PointLayerSpec } from "../../plot-layers.ts"; + +/** + * A plot: two axes over the same cases. The shape — scatter, box, contingency — follows from + * what the axes are, exactly as it does in the portal, rather than being asked for. + */ +export interface PlotView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; + kind: "plot"; x: PlotAxis; y: PlotAxis; cohorts?: string[]; + /** what a point is (default "sample"); patient mode combines a patient's samples (mean, common value or "Multiple", any alteration) */ + unit?: "patient" | "sample"; + /** the grid a scatter is binned onto (default 240 x 160) */ + bins?: [number, number]; + /** + * Every case is a point when there are at most POINTS_CAP (10,000) of them and the reader may see cases + * (default true); false draws the density alone at any size. + */ + allPoints?: boolean; + /** + * Subsets of the plotted cohort's cases drawn in colour over the plot, in order (at most 4): the + * outliers (past the cap: a box's values beyond its whiskers, a scatter's sparse cells), the + * extremes on an axis, the cases furthest from the fitted line, or a cohort's cases. Default + * [{rule:"outliers"}]; [] draws none. A case in two layers is drawn by the first. Every layer + * shares one POINTS_CAP budget; a cohort layer past it is drawn as density cells or boxes. + */ + points?: PointLayerSpec[]; + /** + * Colour the values by a category, as the portal's "Color samples by" does: `colorBy` fills + * each point, `ringBy` rings it, so a case that is both amplified and mutated stays legible. + * Above the points cap the fill splits the density instead. + */ + colorBy?: PlotAxis; + ringBy?: PlotAxis; +} + +declare module "../../types.ts" { + interface ViewSpecs { plot: PlotView } + interface ViewResultFields { + plot: { + /** a plot's aggregated contract: bins, groups or cells — never points */ + plot?: PlotData; + }; + } + interface ViewResultMain { plot: "plot" } +} diff --git a/lib/figures/predictive.ts b/lib/views/predictive/figure.ts similarity index 90% rename from lib/figures/predictive.ts rename to lib/views/predictive/figure.ts index 80deb82..daad3d4 100644 --- a/lib/figures/predictive.ts +++ b/lib/views/predictive/figure.ts @@ -1,8 +1,8 @@ // Predictive biomarker figure: per gene, its hazard ratio among the exposed and the unexposed on one // log axis, with the interaction's p and q beside it. -import type { DrawnResultOf } from "../types.ts"; -import { fmtInt, type SvgOpts } from "../svg-kit.ts"; -import { dualForestSvg } from "./forest.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { fmtInt, type SvgOpts } from "../../svg-kit.ts"; +import { dualForestSvg } from "../../figures/forest.ts"; export function predictiveSvg(v: DrawnResultOf<"predictive">, o: SvgOpts): string { const m = v.predictive; diff --git a/lib/views/predictive.ts b/lib/views/predictive/run.ts similarity index 96% rename from lib/views/predictive.ts rename to lib/views/predictive/run.ts index f1b27dc..46669cb 100644 --- a/lib/views/predictive.ts +++ b/lib/views/predictive/run.ts @@ -2,14 +2,15 @@ // exposure and their interaction — does the gene's prognosis differ between the treated and the // untreated? Fitted from per-patient rows inside each local patient-level study; a patient not // profiled for a gene is left out of that gene's fit, never counted as wild-type. -import type { ViewResult, PredictiveView, PredictiveGene, PredictiveEffect, CoxCovariate } from "../types.ts"; -import { hasViewAggregates, isPatientLevel } from "../federation.ts"; -import { riskmodelRowsSql, riskmodelPanelGenesSql } from "../federation-views.ts"; -import { coxRegression, benjaminiHochberg, type CoxRow, type CoxFit } from "../stats.ts"; -import type { AxisKind } from "../plot.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, refuse, plural, ATTR_NAME } from "./shared.ts"; -import { buildCoxColumns } from "./covariates.ts"; +import type { ViewResult, CoxCovariate } from "../../types.ts"; +import { hasViewAggregates, isPatientLevel } from "../../federation.ts"; +import { riskmodelRowsSql, riskmodelPanelGenesSql } from "../../federation-views.ts"; +import { coxRegression, benjaminiHochberg, type CoxRow, type CoxFit } from "../../stats.ts"; +import type { AxisKind } from "../../plot.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, refuse, plural, ATTR_NAME } from "../shared.ts"; +import { buildCoxColumns } from "../covariates.ts"; +import type { PredictiveView, PredictiveGene, PredictiveEffect } from "./types.ts"; const PRED_MAX_GENES = 20, PRED_MAX_COVARIATES = 6; diff --git a/lib/views/predictive/types.ts b/lib/views/predictive/types.ts new file mode 100644 index 0000000..40fdade --- /dev/null +++ b/lib/views/predictive/types.ts @@ -0,0 +1,51 @@ +// The predictive view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +export interface PredictiveView { + note?: string; + kind: "predictive"; cohorts?: string[]; + /** the exposure: patients with a timeline event of this type (and key = value) are exposed, the rest unexposed */ + exposure: { eventType: string; key?: string; value?: string }; + /** the candidate biomarkers (at most 20); each is fitted alone, over the patients profiled for it */ + genes: string[]; + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** "any" mutation (default) or OncoKB drivers only */ + measure?: "any" | "driver"; + /** clinical attributes adjusted for (at most 6): numbers as they are, categories as indicators against the commonest level */ + covariates?: string[]; + /** a gene is fitted only with at least this many mutated patients in each exposure group (default 5) */ + minMutated?: number; +} +export interface PredictiveEffect { hr: number; ciLow: number; ciHigh: number; p: number } +export interface PredictiveGene { + gene: string; + /** patients profiled for the gene (and with every covariate), by exposure, and those of them mutated */ + exposed: number; unexposed: number; mutatedExposed: number; mutatedUnexposed: number; events: number; + /** the gene's hazard ratio among the exposed and among the unexposed, and their ratio (the interaction); null when not fitted */ + inExposed: PredictiveEffect | null; inUnexposed: PredictiveEffect | null; interaction: PredictiveEffect | null; + /** Benjamini–Hochberg over the fitted genes' interaction p */ + q: number | null; + /** why the gene was not fitted */ + skipped?: string; +} +export interface PredictiveResult { + exposure: string; endpoint: string; measure: "any" | "driver"; + /** patients with a survival time and every covariate, and how many of them are exposed */ + n: number; exposed: number; events: number; + /** patients left out: no mutation panel on record, a covariate missing */ + notProfiled: number; noCovariate: number; + covariates: string[]; minMutated: number; + /** the fits' baseline hazard is stratified by these (cohort and study, when several) */ + strata: number; + genes: PredictiveGene[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { predictive: PredictiveView } + interface ViewResultFields { + predictive: { + predictive?: PredictiveResult; + }; + } + interface ViewResultMain { predictive: "predictive" } +} diff --git a/lib/figures/riskmodel.ts b/lib/views/riskmodel/figure.ts similarity index 97% rename from lib/figures/riskmodel.ts rename to lib/views/riskmodel/figure.ts index bf7eb31..0d4bae5 100644 --- a/lib/figures/riskmodel.ts +++ b/lib/views/riskmodel/figure.ts @@ -2,17 +2,18 @@ import { palette, frame, caption, linear, log, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, label, textWidth, seriesStyle, SERIES, esc, fmtInt, fmtNum, fmtPct, fmtShare, fmtRatio, fmtP, fmtQ, fmtMonths, FS, SW, type SvgOpts -} from "../svg-kit.ts"; -import type { RiskTable1, ViewResultOf, DrawnResultOf } from "../types.ts"; -import { stepPath } from "../chart/marks.ts"; -import { plotHeight } from "./common.ts"; -import { timeScale, timeTitle } from "../chart/time.ts"; -import { paragraph } from "../chart/text.ts"; -import { lineSwatch } from "../chart/legend.ts"; -import { r1 } from "../chart/marks.ts"; -import { placeLabels, type LabelWant } from "../chart/place.ts"; -import { unplacedNote } from "./volcano.ts"; -import { kmPanel } from "./km.ts"; +} from "../../svg-kit.ts"; +import type { ViewResultOf, DrawnResultOf } from "../../types.ts"; +import { stepPath } from "../../chart/marks.ts"; +import { plotHeight } from "../../figures/common.ts"; +import { timeScale, timeTitle } from "../../chart/time.ts"; +import { paragraph } from "../../chart/text.ts"; +import { lineSwatch } from "../../chart/legend.ts"; +import { r1 } from "../../chart/marks.ts"; +import { placeLabels, type LabelWant } from "../../chart/place.ts"; +import { unplacedNote } from "../../figures/volcano.ts"; +import { kmPanel } from "../../figures/km.ts"; +import type { RiskTable1 } from "./types.ts"; /** a hazard ratio below 1 and above it take the shared series' blue and vermillion */ const FAVOURABLE = SERIES[0], ADVERSE = SERIES[4]; diff --git a/lib/views/riskmodel/method.ts b/lib/views/riskmodel/method.ts new file mode 100644 index 0000000..b449d86 --- /dev/null +++ b/lib/views/riskmodel/method.ts @@ -0,0 +1,170 @@ +// The riskmodel view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, VERIFIED, withResult, pFmt, RESULT_INIT, pyTable, checks } from "../../method.ts"; +import type { Table1Histogram } from "./table1.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; +import { TABLE1_CONSIDERED, TABLE1_DEFAULT_MAX_VALUES, TABLE1_DEFAULT_ATTRIBUTES, TABLE1_MAX_VALUES, table1RowKey } from "./table1.ts"; + +export interface RiskmodelTrace { + study: string; cohort: string; cohortPatients: number; rowsReturned: number; + r: import("./types.ts").RiskmodelResult; + lambdas: number; folds: number; testShare: number; + /** Table 1's numeric rows: each group's value distribution, for the script */ + table1Histograms: Table1Histogram[]; +} + +/** + * The risk model's steps. No script recomputes it: the ensemble draws thousands of fits from a + * seeded generator, and the fit itself is what test/coxnet-golden.test.ts pins against + * scikit-survival and lifelines. The record names that test instead. + */ +export function riskmodelMethod(t: RiskmodelTrace): MethodRecord { + const r = t.r; + const out: [string, string][] = [["left out: no mutation panel on record", String(r.notProfiled)]]; + if (r.strata) out.push(["left out: no " + r.strata, String(r.noStratum)]); + if (r.covariates.length) out.push(["left out: missing a covariate", String(r.noCovariate)]); + if (r.anchoredTo) out.push(["left out: no " + r.anchoredTo + " event", String(r.noAnchor)], ["left out: anchor after the study's day 0", String(r.anchorAfterStart)], + ["left out: no follow-up after entry", String(r.noFollowUp)]); + const steps: MethodStep[] = [ + { kind: "fetch", title: "One row per patient", scope: t.cohort + " · " + t.study, rowsIn: t.cohortPatients, rowsOut: t.rowsReturned, + detail: "The riskmodel:rows query above: each patient's survival time and event" + (r.anchoredTo ? " (from their " + r.anchoredTo + " event, with that event's offset to the study's day 0 as their entry time)" : "") + + ", the panels their samples were sequenced on for mutations, the genes " + (r.measure === "driver" ? "with an OncoKB driver mutation" : "mutated") + " in them" + + (r.strata ? ", their " + r.strata : "") + (r.covariates.length ? ", their covariate values" : "") + + ", and their half of the engine's fixed held-out split. Patient-level: the rows never leave this server's engine, and no identifier is returned." }, + { kind: "transform", title: "Patients, candidate genes and covariates", + detail: "A gene is a candidate only if every patient's panels cover it, so no patient is counted as wild-type for a gene they were never tested for, " + + "and only if at least " + r.minMutated + " patients have it mutated." + + (r.covariates.length ? " Covariates enter as they are when every value is a number, else as one indicator per value against the commonest; they are never penalised." : ""), + params: [["genes covered for every patient", String(r.genesCovered)], ["candidates", String(r.candidates)], ...out] }, + { kind: "statistic", title: "Elastic-net Cox, " + r.runs + " random splits", + detail: (r.validation ? "Only the discovery half (" + r.validation.discovery + " patients) is used. " : "") + + "Each run holds out a random " + Math.round(t.testShare * 100) + "% of the patients; on the rest, the penalty is chosen from a path of " + t.lambdas + + " by " + t.folds + "-fold cross-validated partial likelihood (Verweij & van Houwelingen), and the model at that penalty scores the held-out patients" + + (r.validation ? " and every patient of the validation half" : "") + ". Genes are scaled to unit variance before fitting; Breslow ties" + + (r.strata ? "; a separate baseline hazard per " + r.strata : "") + (r.anchoredTo ? "; delayed entry" : "") + + ". A patient's risk score is the mean of the scores they got while held out (OncoCast's ensemble, Martin et al.)" + + (r.validation ? "; a validation patient's is the mean over every run" : "") + ".", + params: [["l1 share of the penalty (alpha)", String(r.alpha)], ["runs", String(r.runs)], ["seed", String(r.seed)], + ["test-set C-index, median (IQR)", r.cIndex.median.toFixed(3) + " (" + r.cIndex.q1.toFixed(3) + "–" + r.cIndex.q3.toFixed(3) + ")"], + ...(r.validation ? [["C-index on the validation half", r.validation.cIndex.toFixed(3)] as [string, string]] : []), + ["never held out, so unscored", String(r.neverHeldOut)], + ...r.covariates.map((c) => [c.name, "HR " + c.hr.toFixed(3) + " (mean over runs)"] as [string, string])] }, + { kind: "statistic", title: "Risk groups, their survival and hazard ratios", + detail: (r.cutsChosen ? "Cuts at the quantiles the view names (" + r.cutsChosen.join(", ") + ")" : "Cuts at equal quantiles") + " of the " + + (r.validation ? "discovery half's held-out scores, applied to the validation half" : "held-out scores") + + ", each moved to the nearer edge of any run of tied scores so equal scores share a group" + + (r.cutsChosen ? " — the quantiles are the view's; the computation does not use the outcome to place them, but whoever chose them may have." : " — no outcome is used to place them.") + " Each group is drawn as a Kaplan–Meier curve" + + (r.anchoredTo ? " with delayed entry" : "") + " and read at 12 and 36 months; the groups are compared with a " + (r.strata ? "stratified " : "") + + "log-rank test, and a Cox model of the outcome on the group indicators gives each group's hazard ratio against the lowest (Newton–Raphson, full information matrix, 95% Wald intervals). " + + "A second Cox model on the standardised score alone gives the hazard ratio per standard deviation. " + + "The score is also shown on OncoCast's 0–10 scale, (score − min) / (max − min) × 10, min and max the lowest and highest " + + (r.validation ? "held-out score in the discovery half (so a validation patient can fall outside 0–10)" : "held-out score") + + "; the scale is linear, so it moves no patient between groups and changes no number above.", + params: [["0 on the 0–10 scale (raw score)", r.scale.min.toFixed(4)], ["10 on the 0–10 scale (raw score)", r.scale.max.toFixed(4)], + ...r.groups.map((g) => [g.name, g.curve.n.toLocaleString() + " patients, " + g.curve.events.toLocaleString() + " events, median " + + (g.curve.median == null ? "not reached" : g.curve.median.toFixed(1) + " months") + (g.hr != null ? ", HR " + g.hr.toFixed(2) : "")] as [string, string])] }, + ]; + const fitVerification = "lib/coxnet.ts's path is checked against scikit-survival's CoxnetSurvivalAnalysis, its delayed-entry, stratified fits (with standard errors) against statsmodels' PHReg and its C-index against lifelines, by test/coxnet-golden.test.ts (fixtures from python/golden/coxnet.py)."; + const implementation = { functions: ["coxnetEnsemble", "coxnetPath", "cvLambda", "concordance", "coxFit"], file: "lib/coxnet.ts", commit: BUILD_COMMIT }; + if (!r.table1) return { steps, implementation, verification: fitVerification }; + const t1 = table1Record(r.table1, t.table1Histograms); + return { steps: [...steps, ...t1.steps], + implementation: { ...implementation, functions: [...implementation.functions, "buildTable1", "chiSquareTest", "kruskalWallis", "quantilesFromHistogram", "benjaminiHochberg"], + file: implementation.file + ", lib/views/riskmodel/table1.ts, lib/stats.ts" }, + verification: fitVerification + " The script below recomputes Table 1's tests and q-values from its counts (the model itself it does not refit); χ² matches scipy " + VERIFIED.chi2.version + + " chi2_contingency, Kruskal–Wallis and quantiles scipy/numpy, Benjamini–Hochberg statsmodels multipletests (test/tables-golden.test.ts, test/moments-golden.test.ts).", + python: withResult(t1.python), check: t1.check }; +} + +/** Table 1's steps, and the script that recomputes its tests from its counts. */ +function table1Record(t: import("./types.ts").RiskTable1, histograms: Table1Histogram[]) { + const fmtP = (p: number | null) => p == null ? "—" : pFmt(p); + const shown = t.rows.filter((x) => x.kind !== "skipped"); + const groups = t.groups.map((g) => g.name); + const steps: MethodStep[] = [ + { kind: "fetch", title: "Table 1: the groups' patients again, with their clinical values", + detail: "The riskmodel:table1 query above is the riskmodel:rows query with Table 1's attributes added: for each, the patient's value (a sample-level attribute: their first sample's), " + + "case and whitespace folded as everywhere in this app and shown in its commonest spelling, and how many values their samples hold. Its rows are kept exactly as the model's were and must match " + + "the model's patients one by one — survival time, entry and event — or the view is refused, so each group's column describes the patients its curve drew" + + (t.reportedOn === "validation half" ? " (the validation half)" : "") + ". riskmodel:table1-attributes gives each attribute's type (NUMBER is numeric), riskmodel:panels each mutation panel's genes. " + + "Patient-level: the rows never leave this server's engine; only the counts below do.", + params: t.groups.map((g) => [g.name, g.n.toLocaleString("en-US") + " patients"] as [string, string]) }, + { kind: "transform", title: "Table 1's rows", + detail: (t.defaults.attributes + ? "Attributes by the default rule: the model's stratum and covariates, then the study's attributes in its priority order, leaving out survival endpoints' columns (…_MONTHS, …_STATUS), " + + "considering the first " + TABLE1_CONSIDERED + "; of those, an attribute is left out if no patient here has a value, if it takes one value among them, or if it is categorical with more than " + + TABLE1_DEFAULT_MAX_VALUES + " values (free text, ids); the first " + TABLE1_DEFAULT_ATTRIBUTES + " that remain are shown." + : "Attributes as the view names them; a categorical one with more than " + TABLE1_MAX_VALUES + " values among these patients is set aside.") + " " + + (t.defaults.genes ? "Genes: the " + t.rows.filter((x) => x.kind === "gene").length + " the model kept in the most runs." : "Genes as the view names them.") + " " + + "A gene is counted as mutated the way the model counts it (" + (t.measure === "driver" ? "OncoKB drivers" : "any mutation") + "), and only among the patients whose mutation panels cover it " + + "(a whole-exome patient covers every gene): a patient whose panels do not is not profiled for it, never unaltered, and is counted as such in each group. " + + "A row the groups differ in by construction is marked: a gene the model kept in any run and a covariate are \"in the model\" (the score is built from them), the endpoint's own columns are \"outcome\" (the groups are cut to differ in survival); the model's stratum is marked \"stratum\" but is a description, since the score has no stratum term.", + params: [...t.omitted.map((o) => ["left out: " + o.name, o.why] as [string, string]), + ...t.rows.filter((x) => x.kind === "skipped").map((x) => ["set aside: " + x.name, (x as { skipped: string }).skipped] as [string, string])] }, + { kind: "statistic", title: "Each row tested across the groups", + detail: "A categorical attribute: Pearson χ² on the group × value table, no continuity correction, an all-zero row or column dropped first, patients with no value left out. " + + "A gene: Pearson χ² on the group × (mutated, not mutated) table of its profiled patients. A numeric attribute: Kruskal–Wallis on each group's values, ranks averaged within ties and tie-corrected; medians and quartiles interpolating linearly between ranks. " + + "The smallest expected count is reported, since χ² is poor below about 5.", + params: shown.map((x) => [table1RowKey(x).replace(/^attribute:/, ""), x.p == null ? "not tested: " + (x as { untested?: string }).untested + : (x.test === "chi-squared" ? "χ² " : "H ") + x.statistic + " on " + x.df + " df · p " + fmtP(x.p) + (x.role ? " · " + x.role : "")] as [string, string]) }, + { kind: "statistic", title: "Benjamini–Hochberg across Table 1's open rows", + detail: "q-values over the " + t.tested + " tested rows that are not in the model and not its outcome. Those rows differ between the groups by construction, so they get a p-value but no q-value and do not enter the correction, where their p-values would shrink every other row's q." }, + ]; + const tested = shown.filter((x) => x.p != null); + const cat = tested.filter((x) => x.kind === "cat") as Extract[]; + const genes = tested.filter((x) => x.kind === "gene") as Extract[]; + const numKeys = new Set(tested.filter((x) => x.kind === "num").map(table1RowKey)); + const hist = histograms.filter((h) => numKeys.has(h.row)); + const family = tested.filter((x) => x.q != null).map(table1RowKey); + const python = [ + "# Recompute Table 1's tests from its counts: scipy " + VERIFIED.chi2.version + " and statsmodels " + VERIFIED.bh.version + " are the versions our code is tested against.", + "import io", + "import numpy as np", + "import pandas as pd", + "from scipy.stats import chi2_contingency, kruskal", + "from statsmodels.stats.multitest import multipletests", + "", + "groups = " + JSON.stringify(groups), + RESULT_INIT, + "", + "def chi2_p(table):", + " table = table[table.sum(axis=1) > 0][:, table.sum(axis=0) > 0] # drop an all-zero row or column", + " return chi2_contingency(table, correction=False).pvalue", + ...(cat.length ? ["", + ...pyTable("categorical", "Categorical rows: patients per group (by index into groups) and value (by index into the row's values)", ["row", "group", "level", "n"], + cat.flatMap((x) => x.levels.flatMap((l, li) => l.counts.map((n, gi) => [table1RowKey(x), gi, li, n]))), "table1_categorical.csv"), + "for row, g in categorical.groupby(\"row\", sort=False):", + " table = g.pivot_table(index=\"group\", columns=\"level\", values=\"n\", aggfunc=\"sum\", fill_value=0).to_numpy()", + " RESULT[f\"table1:{row}:p\"] = chi2_p(table)", + " print(f\"{row}: chi-squared p={RESULT[f'table1:{row}:p']:.4g}\")"] : []), + ...(genes.length ? ["", + ...pyTable("genes", "Gene rows: per group (by index into groups), patients with the gene mutated and patients whose panels cover it", ["row", "group", "altered", "profiled"], + genes.flatMap((x) => x.perGroup.map((c, gi) => [table1RowKey(x), gi, c.altered, c.profiled])), "table1_genes.csv"), + "for row, g in genes.groupby(\"row\", sort=False):", + " table = np.column_stack([g[\"altered\"], g[\"profiled\"] - g[\"altered\"]])", + " RESULT[f\"table1:{row}:p\"] = chi2_p(table)", + " print(f\"{row}: \" + \", \".join(f\"{a}/{p}\" for a, p in zip(g[\"altered\"], g[\"profiled\"])) + f\" mutated; chi-squared p={RESULT[f'table1:{row}:p']:.4g}\")"] : []), + ...(hist.length ? ["", + ...pyTable("numeric", "Numeric rows: each group's values, as (value, patients)", ["row", "group", "value", "n"], hist.map((h) => [h.row, h.group, h.value, h.n]), "table1_numeric.csv"), + "for row, g in numeric.groupby(\"row\", sort=False):", + " values = {grp: np.repeat(h[\"value\"].to_numpy(), h[\"n\"].to_numpy()) for grp, h in g.groupby(\"group\", sort=False)}", + " for grp, v in values.items():", + " for name, q in ((\"median\", 50), (\"q1\", 25), (\"q3\", 75)):", + " RESULT[f\"table1:{row}:{grp}:{name}\"] = np.percentile(v, q)", + " RESULT[f\"table1:{row}:p\"] = kruskal(*values.values()).pvalue", + " print(f\"{row}: Kruskal-Wallis p={RESULT[f'table1:{row}:p']:.4g}\")"] : []), + "", + "# q-values over the open rows only: those in the model or its outcome differ by construction", + "family = " + JSON.stringify(family), + "if family:", + " for row, qv in zip(family, multipletests([RESULT[f\"table1:{r}:p\"] for r in family], method=\"fdr_bh\")[1]):", + " RESULT[f\"table1:{row}:q\"] = qv", + ].join("\n") + "\n"; + const medians = tested.filter((x) => x.kind === "num").flatMap((x) => (x as Extract).perGroup + .map((c, gi) => ({ key: table1RowKey(x) + ":" + groups[gi], ...c })).filter((c) => c.n > 0)); + const check = { + ...checks("table1:", tested.map((x) => ({ key: table1RowKey(x), p: x.p, ...(x.q != null ? { q: x.q } : {}) })), (x) => x.key, [["p", "p"], ["q", "q"]]), + // medians and quartiles are reported to 3 decimals + ...checks("table1:", medians, (m) => m.key, [["median", "median", { abs: 5e-4 + 1e-9 }], ["q1", "q1", { abs: 5e-4 + 1e-9 }], ["q3", "q3", { abs: 5e-4 + 1e-9 }]]), + }; + return { steps, python, check }; +} diff --git a/lib/views/riskmodel.ts b/lib/views/riskmodel/run.ts similarity index 98% rename from lib/views/riskmodel.ts rename to lib/views/riskmodel/run.ts index 544deaf..d621c3a 100644 --- a/lib/views/riskmodel.ts +++ b/lib/views/riskmodel/run.ts @@ -6,18 +6,19 @@ // held-out scores into risk groups and computes their curves and tests — milliseconds — from the // fit alone. The routes cache the fit (lib/next-cache.ts `cachedRiskFit`), so a change to the // groups refits nothing. -import type { ViewResult, RiskmodelView, RiskmodelResult, CohortDef } from "../types.ts"; -import { hasViewAggregates, isPatientLevel, resolverFor, type PatientLevelCapable, type ViewAggregateCapable } from "../federation.ts"; -import { riskmodelRowsSql, riskmodelPanelGenesSql } from "../federation-views.ts"; -import { coxnetEnsemble, coxFit, concordance, type CoxSubject, type CoxDesign } from "../coxnet.ts"; -import { kaplanMeier, logRankK } from "../stats.ts"; -import { riskmodelMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, refuse } from "./shared.ts"; -import { RISK_LABEL_ABOVE, RISK_FIGURE_GENES, riskGroupNames } from "../figures/riskmodel.ts"; -import { tableAttributesSql } from "../clinical-table.ts"; -import { riskExternal } from "./riskmodel-validate.ts"; -import { buildTable1, table1DefaultCandidates, TABLE1_MAX_ATTRIBUTES, TABLE1_MAX_GENES, type Table1Patient } from "./riskmodel-table1.ts"; +import type { ViewResult, CohortDef } from "../../types.ts"; +import { hasViewAggregates, isPatientLevel, resolverFor, type PatientLevelCapable, type ViewAggregateCapable } from "../../federation.ts"; +import { riskmodelRowsSql, riskmodelPanelGenesSql } from "../../federation-views.ts"; +import { coxnetEnsemble, coxFit, concordance, type CoxSubject, type CoxDesign } from "../../coxnet.ts"; +import { kaplanMeier, logRankK } from "../../stats.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, refuse } from "../shared.ts"; +import { RISK_LABEL_ABOVE, RISK_FIGURE_GENES, riskGroupNames } from "./figure.ts"; +import { tableAttributesSql } from "../../clinical-table.ts"; +import { riskExternal } from "./validate.ts"; +import { buildTable1, table1DefaultCandidates, TABLE1_MAX_ATTRIBUTES, TABLE1_MAX_GENES, type Table1Patient } from "./table1.ts"; +import type { RiskmodelView, RiskmodelResult } from "./types.ts"; +import { riskmodelMethod } from "./method.ts"; const RISK_MAX_RUNS = 100; /** patients x runs past which one request would not finish inside the analysis route's time limit */ @@ -340,7 +341,7 @@ export function groupRiskmodel(fit: RiskFit, qs: number[]): RiskGrouping { // ---- Table 1 ---------------------------------------------------------------------------------- /** - * Table 1, the groups' clinical and genomic makeup (counted and tested by lib/views/riskmodel-table1.ts). + * Table 1, the groups' clinical and genomic makeup (counted and tested by lib/views/riskmodel/table1.ts). * Reads the fit's own rows again with Table 1's attributes added and keeps them as the fit did, and * refuses unless they are the fit's patients in the fit's order — every survival time, entry and * event the same — so each group's members are exactly the patients its curve drew. diff --git a/lib/views/riskmodel-table1.ts b/lib/views/riskmodel/table1.ts similarity index 98% rename from lib/views/riskmodel-table1.ts rename to lib/views/riskmodel/table1.ts index 7c569d6..fafc1ca 100644 --- a/lib/views/riskmodel-table1.ts +++ b/lib/views/riskmodel/table1.ts @@ -1,13 +1,13 @@ // Table 1 for the risk model: each risk group's clinical and genomic makeup, tested across groups. // -// Pure: lib/views/riskmodel.ts fetches the rows (the fit's own query, with Table 1's attributes +// Pure: lib/views/riskmodel/run.ts fetches the rows (the fit's own query, with Table 1's attributes // added), matches them to the fit's patients and hands them here by position, without identifiers; // only counts come out. Gene rates are panel-aware (docs/PERFORMANCE.md): a group's rate for a gene // is over its patients whose panels cover that gene, and a patient whose panels do not is "not // profiled", never unaltered. Rows the groups differ in by construction — the model's genes and // covariates, its outcome — are marked and kept out of the q-value family. -import { benjaminiHochberg, chiSquareTest, kruskalWallis, quantilesFromHistogram, type Histogram } from "../stats.ts"; -import type { RiskTable1, RiskTable1Row, RiskTable1Role } from "../types.ts"; +import { benjaminiHochberg, chiSquareTest, kruskalWallis, quantilesFromHistogram, type Histogram } from "../../stats.ts"; +import type { RiskTable1, RiskTable1Row, RiskTable1Role } from "./types.ts"; export const TABLE1_MAX_ATTRIBUTES = 20, TABLE1_MAX_GENES = 20; /** the default attributes: at most this many, chosen from the first TABLE1_CONSIDERED in the study's priority order */ diff --git a/lib/views/riskmodel/types.ts b/lib/views/riskmodel/types.ts new file mode 100644 index 0000000..264f676 --- /dev/null +++ b/lib/views/riskmodel/types.ts @@ -0,0 +1,177 @@ +// The riskmodel view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { KmCurve, LogRankK } from "../../types.ts"; + +/** + * A lab value's course from a treatment start: % change from each patient's baseline over time + * (median and interquartile band per bin), the best response and the share responding (e.g. + * PSA50, PCWG3), and — from a participant entitled to patient-level values — a waterfall. + */ +/** + * A risk model in the manner of OncoCast (Martin et al., github.com/AxelitoMartin/OncoCast): an + * elastic-net Cox model of one cohort's survival on the genes mutated in it, fitted `runs` times + * on random two-thirds of the patients and scored on the held-out third. Each patient's risk score + * is the mean of the scores they got while held out; the patients are then cut into risk groups at + * quantiles of that score (no outcome is used to place the cuts) and drawn as Kaplan–Meier curves. + * Patient-level studies only: a model of individual patients needs their rows. + */ +export interface RiskmodelView { + note?: string; + kind: "riskmodel"; cohorts?: string[]; + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** "any" mutation (default) or OncoKB drivers only */ + measure?: "any" | "driver"; + /** only genes mutated in at least this many patients are candidates (default 30) */ + minMutated?: number; + /** a clinical attribute to stratify by (each stratum its own baseline hazard), e.g. CANCER_TYPE */ + strata?: string; + /** l1 share of the penalty: 1 lasso, 0 ridge (default 0.5, elastic net) */ + alpha?: number; + /** random splits (default 20, at most 100) */ + runs?: number; + /** risk groups, cut at equal quantiles of the score (default 3, 2 to 5) */ + groups?: number; + /** instead of `groups`: the quantiles of the score to cut at, increasing, each strictly between 0 and 1 + * (1 to 4 of them, so 2 to 5 groups) — e.g. [0.8] for the top fifth against the rest, OncoCast's `cuts` */ + cuts?: number[]; + seed?: number; + /** clinical attributes always in the model, never penalised (at most 6): numbers as they are, + * categories as indicators against the commonest value */ + covariates?: string[]; + /** start each patient's clock at a timeline event (as the survival view), with delayed entry */ + anchor?: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; + /** train on the discovery half of the engine's fixed split only, and report on the validation half */ + validate?: boolean; + /** the volcano names the genes kept in at least this share of runs (0 to 1, default 0.5); drawn in + * the browser from the whole result, so changing it re-runs nothing */ + labelAbove?: number; + /** Table 1: the risk groups' clinical and genomic makeup, tested across groups. Present, the view + * computes it; a list left out is the default (the study's leading attributes; the genes the + * model kept most often) */ + describe?: { attributes?: string[]; genes?: string[] }; + /** external validation: another cohort of the document, scored with the fitted ensemble's mean + * coefficients and cut at the training risk-group thresholds */ + validateOn?: string; +} + +/** A risk model scored on another cohort it never saw (RiskmodelView.validateOn). */ +export interface RiskExternal { + cohort: string; study: string; + /** patients scored, their events, and those left out with why */ + n: number; events: number; + cohortPatients: number; rowsReturned: number; + notProfiled: number; noStratum: number; noAnchor: number; anchorAfterStart: number; noFollowUp: number; + /** patients whose panels do not cover every gene the model weighs: never scored as wild-type */ + notCovered: number; + /** the model's genes with a non-zero ensemble coefficient, which every scored patient's panels cover */ + modelGenes: number; + /** Harrell's C-index of the score (within strata), with a seeded bootstrap 95% interval */ + cIndex: number; cIndexLo: number; cIndexHi: number; bootstrap: number; + /** the training thresholds (raw scores) the groups are cut at */ + cuts: number[]; + groups: { name: string; curve: Omit; hr: number | null; ciLow: number | null; ciHigh: number | null; p: number | null }[]; + logrank: LogRankK | null; +} + +/** Why a Table 1 row differs between the risk groups by construction, or is the model's stratum. */ +export type RiskTable1Role = "in the model" | "outcome" | "stratum" | null; + +interface RiskTable1Test { + /** null when the row cannot be tested (the reason in `untested`) */ + test: "chi-squared" | "Kruskal-Wallis" | null; statistic: number | null; df: number | null; p: number | null; + /** Benjamini–Hochberg across the tested rows that are not in the model or the outcome; null for those */ + q: number | null; + untested?: string; +} + +export type RiskTable1Row = + | ({ kind: "cat"; name: string; per: "patient" | "sample"; role: RiskTable1Role; + /** every value recorded among the groups' patients, commonest first, with its count per group */ + levels: { value: string; counts: number[] }[]; + /** per group, patients with no value recorded */ + missing: number[]; + /** patients whose samples hold more than one value (counted under their first sample's) */ + multiValued: number; minExpected: number | null } & RiskTable1Test) + | ({ kind: "num"; name: string; per: "patient" | "sample"; role: RiskTable1Role; + perGroup: { n: number; median: number | null; q1: number | null; q3: number | null; missing: number }[]; + multiValued: number } & RiskTable1Test) + | ({ kind: "gene"; name: string; role: RiskTable1Role; + /** per group: patients with the gene mutated, patients whose panels cover it, and those whose do not */ + perGroup: { altered: number; profiled: number; notProfiled: number }[]; minExpected: number | null } & RiskTable1Test) + | { kind: "skipped"; name: string; what: "attribute" | "gene"; skipped: string }; + +export interface RiskTable1 { + /** the patients described: the same as the groups' curves */ + reportedOn: "held-out scores" | "validation half"; + groups: { name: string; n: number }[]; + /** the gene rows count mutations as the model does */ + measure: "any" | "driver"; + /** what the table holds, as a document would name it — what "Add to document" writes */ + describe: { attributes: string[]; genes: string[] }; + /** the default lists were used (no list named in the view) */ + defaults: { attributes: boolean; genes: boolean }; + rows: RiskTable1Row[]; + /** attributes the default rule considered and left out, with why */ + omitted: { name: string; why: string }[]; + /** rows in the Benjamini–Hochberg family */ + tested: number; +} + +export interface RiskmodelResult { + study: string; + /** patients modelled, their events, and those left out with why */ + n: number; events: number; + notProfiled: number; noStratum: number; neverHeldOut: number; + noCovariate: number; noAnchor: number; anchorAfterStart: number; noFollowUp: number; + /** "Treatment (FIRST)" etc. when the clock starts at a timeline event */ + anchoredTo: string | null; + /** the always-in covariates as fitted columns (a category's indicator per non-reference level), with + * the share of runs they ended non-zero in (always 1 unless no variation) and their mean coefficient */ + covariates: { name: string; meanCoef: number; hr: number }[]; + /** with `validate`: the patients trained on (discovery) and reported on (validation) */ + validation: { discovery: number; validation: number; validationEvents: number; cIndex: number } | null; + /** what the groups, table and refit below are computed on */ + reportedOn: "held-out scores" | "validation half"; + /** genes every patient was sequenced for (mutations), and of those the candidates */ + genesCovered: number; candidates: number; minMutated: number; + strata: string | null; strataLevels: number; + alpha: number; runs: number; seed: number; measure: "any" | "driver"; + /** test-set C-index across runs */ + cIndex: { median: number; q1: number; q3: number; values: number[] }; + /** candidates kept in at least one run, by how often: share of runs, mean coefficient where kept, patients mutated */ + features: { gene: string; selected: number; meanCoef: number; hr: number; mutated: number }[]; + groups: { name: string; from: number | null; to: number | null; curve: Omit; + /** Kaplan-Meier survival at 12 and 36 months; null past the group's follow-up */ + at12: number | null; at36: number | null; + /** hazard ratio against the lowest-risk group (null for that group), 95% Wald interval, p */ + hr: number | null; ciLow: number | null; ciHigh: number | null; p: number | null }[]; + logrank: LogRankK | null; + /** a Cox model of the outcome on the risk score alone (per standard deviation of the score) */ + refit: { hr: number; ciLow: number; ciHigh: number; p: number } | null; + /** the scores' distribution, for the histogram: bin edges and counts (raw scores) */ + histogram: { edges: number[]; counts: number[] }; + /** OncoCast's 0–10 risk score: (raw − min) / (max − min) × 10, min and max the lowest and highest of + * the held-out scores the cuts are placed on (with `validate`, the discovery half's — so a + * validation patient can fall below 0 or above 10) */ + scale: { min: number; max: number }; + /** the volcano's label threshold, as the view asked (default 0.5) */ + labelAbove: number; + /** the quantiles the view chose to cut at; null when the groups are `groups` equal quantiles */ + cutsChosen: number[] | null; + /** the held-out score (raw) at each whole percentile 0…100 of the scores the cuts are placed on, + * the rank a cut at that quantile starts from — what the risk-groups dialog places its handles by */ + percentiles: number[]; + /** with `describe`: Table 1, the groups' makeup */ + table1?: RiskTable1; + /** with `validateOn`: the model scored on that cohort */ + external?: RiskExternal; +} + +declare module "../../types.ts" { + interface ViewSpecs { riskmodel: RiskmodelView } + interface ViewResultFields { + riskmodel: { + riskmodel?: RiskmodelResult; + }; + } + interface ViewResultMain { riskmodel: "riskmodel" } +} diff --git a/lib/views/riskmodel-validate.ts b/lib/views/riskmodel/validate.ts similarity index 97% rename from lib/views/riskmodel-validate.ts rename to lib/views/riskmodel/validate.ts index c9f897c..58d3fd8 100644 --- a/lib/views/riskmodel-validate.ts +++ b/lib/views/riskmodel/validate.ts @@ -1,10 +1,11 @@ // External validation of a fitted risk model (RiskmodelView.validateOn): another cohort's patients // scored with the ensemble's mean coefficients, cut at the training thresholds, and judged by // Harrell's C-index, Kaplan–Meier curves by risk group, the log-rank test and each group's HR. -import type { RiskExternal } from "../types.ts"; -import { concordance, type CoxSubject } from "../coxnet.ts"; -import { kaplanMeier, logRankK, coxRegression, seededRandom } from "../stats.ts"; -import { keepRiskRows, riskFitSpec, riskViewRows, RiskRefusal, type RiskFit, type RiskFitArgs, type RiskGrouping, type RiskResolver } from "./riskmodel.ts"; + +import { concordance, type CoxSubject } from "../../coxnet.ts"; +import { kaplanMeier, logRankK, coxRegression, seededRandom } from "../../stats.ts"; +import { keepRiskRows, riskFitSpec, riskViewRows, RiskRefusal, type RiskFit, type RiskFitArgs, type RiskGrouping, type RiskResolver } from "./run.ts"; +import type { RiskExternal } from "./types.ts"; /** bootstrap resamples for the C-index interval */ export const RISK_EXTERNAL_BOOTSTRAP = 200; diff --git a/lib/figures/rmst.ts b/lib/views/rmst/figure.ts similarity index 93% rename from lib/figures/rmst.ts rename to lib/views/rmst/figure.ts index f86e8a2..df1a67f 100644 --- a/lib/figures/rmst.ts +++ b/lib/views/rmst/figure.ts @@ -1,11 +1,12 @@ // RMST figure: Kaplan–Meier curves per cohort with the area to τ shaded — one panel per landmark, // as small multiples — and a table of each cohort's restricted mean with its difference and ratio // against the first cohort. -import type { DrawnResultOf, RmstComparison } from "../types.ts"; -import { palette, frame, caption, label, textWidth, seriesStyle, fmtInt, fmtNum, fmtRatio, fmtP, FS, type SvgOpts } from "../svg-kit.ts"; -import { lineSwatch } from "../chart/legend.ts"; -import { r1 } from "../chart/marks.ts"; -import { kmPanel } from "./km.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { palette, frame, caption, label, textWidth, seriesStyle, fmtInt, fmtNum, fmtRatio, fmtP, FS, type SvgOpts } from "../../svg-kit.ts"; +import { lineSwatch } from "../../chart/legend.ts"; +import { r1 } from "../../chart/marks.ts"; +import { kmPanel } from "../../figures/km.ts"; +import type { RmstComparison } from "./types.ts"; const mo = (x: number) => fmtNum(Math.round(x * 10) / 10); /** a restricted mean and its interval: "17.2 mo (16.1–18.3)" */ diff --git a/lib/views/rmst.ts b/lib/views/rmst/run.ts similarity index 95% rename from lib/views/rmst.ts rename to lib/views/rmst/run.ts index fdf6e94..bdd7135 100644 --- a/lib/views/rmst.ts +++ b/lib/views/rmst/run.ts @@ -3,15 +3,16 @@ // cohorts the difference and ratio of those means; optionally from landmarks, each restarting the // clock among the patients still followed. Meaningful whether or not the hazards are proportional. // Each participant returns id-free (entry, t, event, n) counts. -import type { ViewResult, RmstView, RmstPanel, RmstCurve, RmstComparison } from "../types.ts"; -import { txEndpointCountsSql, labTestsSql, CLOCK_SLACK_DAYS, DAYS_PER_MONTH, type TxEndpointOpts } from "../federation-views.ts"; -import type { EndpointColumns } from "../federation.ts"; -import { expandSurvivalCounts } from "../federation.ts"; -import { kaplanMeier, rmst, atRiskAtTimes, normalP, Z_975, type SurvRecord } from "../stats.ts"; -import { DEFAULT_LINE_GAP, TREATMENT_EVENT } from "../treatment-lines.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, plural, ATTR_NAME } from "./shared.ts"; -import { treatmentAnchor, noAnchorMessage, clockFailures, CLOCK_TOLERANCE, type TreatmentAnchor } from "../timeline-endpoints.ts"; +import type { ViewResult } from "../../types.ts"; +import { txEndpointCountsSql, labTestsSql, CLOCK_SLACK_DAYS, DAYS_PER_MONTH, type TxEndpointOpts } from "../../federation-views.ts"; +import type { EndpointColumns } from "../../federation.ts"; +import { expandSurvivalCounts } from "../../federation.ts"; +import { kaplanMeier, rmst, atRiskAtTimes, normalP, Z_975, type SurvRecord } from "../../stats.ts"; +import { DEFAULT_LINE_GAP, TREATMENT_EVENT } from "../../treatment-lines.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, plural, ATTR_NAME } from "../shared.ts"; +import { treatmentAnchor, noAnchorMessage, clockFailures, CLOCK_TOLERANCE, type TreatmentAnchor } from "../../timeline-endpoints.ts"; +import type { RmstView, RmstPanel, RmstCurve, RmstComparison } from "./types.ts"; const RMST_COHORT_LIMIT = 6; const RMST_LANDMARK_LIMIT = 4; diff --git a/lib/views/rmst/types.ts b/lib/views/rmst/types.ts new file mode 100644 index 0000000..1f21ca8 --- /dev/null +++ b/lib/views/rmst/types.ts @@ -0,0 +1,54 @@ +// The rmst view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { KmCurve } from "../../types.ts"; + +export interface RmstView { + note?: string; + kind: "rmst"; cohorts?: string[]; + /** the endpoint (default OS), its _MONTHS/_STATUS columns or monthsAttribute/statusAttribute */ + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** start the clock at a treatment record instead of day 0 (overall survival from it; txendpoints' anchor and clock check) */ + anchor?: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; + /** the restriction time, months; default the largest time at which every cohort still has 10 at risk */ + tau?: number; + /** landmarks, months: each keeps the patients still followed then and restarts the clock there (default [0]) */ + landmarkMonths?: number[]; +} +export interface RmstCurve extends Omit { + cohort: string; + /** restricted mean survival time to tau, its standard error and 95% interval, months */ + rmst: number; se: number; lo: number; hi: number; + /** the number at risk just before tau */ + atRiskTau: number; + /** patients dropped at the landmark: died or censored before it */ + beforeLandmark: number; +} +export interface RmstComparison { + cohort: string; vs: string; + /** RMST difference (cohort − vs) and ratio (cohort / vs), each with a 95% interval and a two-sided p */ + diff: number; diffLo: number; diffHi: number; diffP: number; + ratio: number; ratioLo: number; ratioHi: number; ratioP: number; +} +export interface RmstPanel { + landmark: number; tau: number; + /** whether tau was set in the view or is the default (every cohort ≥ 10 at risk) */ + tauSet: boolean; + curves: RmstCurve[]; + comparisons: RmstComparison[]; +} +export interface RmstResult { + endpointLabel: string; anchoredTo?: string; + panels: RmstPanel[]; + /** per cohort: its patients, and those without follow-up (or without the anchor) */ + cohorts: { cohort: string; cohortPatients: number; n: number; noFollowup: number; noAnchor: number }[]; + excludedStudies?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { rmst: RmstView } + interface ViewResultFields { + rmst: { + rmst?: RmstResult; + }; + } + interface ViewResultMain { rmst: "rmst" } +} diff --git a/lib/views/run.ts b/lib/views/run.ts index ef3e92b..a3af0fa 100644 --- a/lib/views/run.ts +++ b/lib/views/run.ts @@ -3,7 +3,7 @@ import type { ProfileInfo } from "../profile-catalog.ts"; import type { AnalysisDoc, ViewResult, ViewParticipation } from "../types.ts"; import type { Resolver } from "../federation.ts"; -import type { RiskFit, RiskFitArgs } from "./riskmodel.ts"; +import type { RiskFit, RiskFitArgs } from "./riskmodel/run.ts"; /** one view of a document, as authored */ export type DocView = NonNullable[number]; diff --git a/lib/figures/sequence.ts b/lib/views/sequence/figure.ts similarity index 98% rename from lib/figures/sequence.ts rename to lib/views/sequence/figure.ts index f7ab82d..fa374c6 100644 --- a/lib/figures/sequence.ts +++ b/lib/views/sequence/figure.ts @@ -1,9 +1,10 @@ // Treatment sequences as a Sankey. -import type { SequencePanel, SequenceNodeKind, DrawnResultOf } from "../types.ts"; +import type { DrawnResultOf } from "../../types.ts"; import { palette, frame, caption, label, legend, textWidth, seriesStyle, fmtInt, fmtShare, esc, num, FS, OTHER, type SvgOpts, type LegendItem -} from "../svg-kit.ts"; +} from "../../svg-kit.ts"; +import type { SequencePanel, SequenceNodeKind } from "./types.ts"; /** * Treatment sequences as a Sankey: columns are lines of therapy, ribbons are diff --git a/lib/views/sequence/method.ts b/lib/views/sequence/method.ts new file mode 100644 index 0000000..dec29ef --- /dev/null +++ b/lib/views/sequence/method.ts @@ -0,0 +1,98 @@ +// The sequence view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { SYSTEMIC_LINE_CLASSES, CLASS_KEYS } from "../../treatment-lines.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface SequenceTrace { + eventType: string; + labelKey: string; + lineGap: number; + maxSteps: number; + topN: number; + classes: "systemic" | "all" | string[]; + exclude: string[]; + truncated: boolean; + rowLimit: number; + perSite: { cohort: string; study: string; cohortPatients: number }[]; + /** treatment records set aside before lines were built, by class and reason */ + setAside: { cohort: string; cls: string; reason: string; records: number }[]; + /** per cohort, summed over its studies: patients per (line, from, to, fate) */ + flows: { cohort: string; step: number; from: string; to: string; fate: number; n: number }[]; + foldedLabels: number; + /** what the view reported — for the script check */ + results: { cohort: string; started: number; censored: number; nodes: { column: number; kind: string; label: string; n: number }[] }[]; +} + +export function sequenceMethod(t: SequenceTrace): MethodRecord { + const classText = t.classes === "all" ? "every " + t.eventType + " record" + : Array.isArray(t.classes) ? t.eventType + " records whose class is one of " + t.classes.join(", ") + : t.eventType + " records whose class is systemic therapy (" + SYSTEMIC_LINE_CLASSES.join(", ") + ")"; + const steps: MethodStep[] = t.perSite.map((s) => ({ + kind: "fetch" as const, title: "Build lines and count what follows what", + detail: "The treatmentSequence query above builds every patient's lines in the database and returns, per line number, how many patients went from one regimen to the next, stopped, or were lost to follow-up — counts only — plus how many records it set aside.", + scope: s.cohort + " · " + s.study, rowsIn: s.cohortPatients, + })); + const byReason = (reason: string) => { + const m = new Map(); + for (const r of t.setAside.filter((x) => x.reason === reason)) m.set(r.cls, (m.get(r.cls) || 0) + r.records); + return [...m.entries()].sort((a, b) => b[1] - a[1]); + }; + const notLine = byReason("#not a line class"), excluded = byReason("#excluded by the document"); + steps.push({ kind: "transform", title: "Choose the records that form lines", + detail: "The one definition every line count shares (lib/treatment-lines.ts): " + classText + ". A record's class is its SUBTYPE, or its TREATMENT_TYPE where it has none; names and values match ignoring case." + + (t.exclude.length ? " Records labelled " + t.exclude.join(", ") + " are left out by this document." : ""), + params: [ + ...notLine.slice(0, 12).map(([c, n]) => [c, n.toLocaleString() + " records set aside: not a line class"] as [string, string]), + ...(notLine.length > 12 ? [["…", (notLine.length - 12) + " more classes, " + notLine.slice(12).reduce((k, [, n]) => k + n, 0).toLocaleString() + " records"] as [string, string]] : []), + ...excluded.map(([c, n]) => [c, n.toLocaleString() + " records excluded by the document"] as [string, string]), + ] }); + steps.push({ kind: "transform", title: "Group records into lines", + detail: "Per patient, by start date: a record starting more than " + t.lineGap + " days after every earlier one has stopped begins a new line; anything closer — overlapping, or a switch within the gap — is the same line. A line is named by its sorted combination of " + + (CLASS_KEYS.includes(t.labelKey.toLowerCase() as never) ? "classes" : t.labelKey + " values") + " ('A + B'); the label never decides which records form a line." }); + steps.push({ kind: "transform", title: "Decide how each path ends", + detail: "A patient's last line ends in 'no further line' only if they were observed for at least " + t.lineGap + " days after it — their last contact being the later of their last recorded event of any type and their OS follow-up (OS_MONTHS × 30.4375 days) — and in 'record ends here' otherwise. Lines are followed up to line " + t.maxSteps + "." }); + steps.push({ kind: "transform", title: "Keep the " + t.topN + " most common regimens", + detail: "Flows are summed across studies, then regimens ranked by the patients leaving them (or, for one seen only as a destination, arriving), ties by name, across every panel so a regimen keeps one identity; the rest are drawn as 'other'." + + (t.truncated ? " A study's answer reached the " + t.rowLimit + "-flow limit, so its rarest flows are missing." : ""), + params: [["regimens folded into 'other'", String(t.foldedLabels)]] }); + const python = [ + "# Recompute the diagram from the flows: the top-" + t.topN + " folding, node sizes, and who started and was censored.", + "import io", + "import pandas as pd", + "", + RESULT_INIT, + ...pyTable("flows", "Per cohort (summed over its studies): patients per (line, from, to, fate); fate 0 another line, 1 no further line, 2 record ends", + ["cohort", "step", "from_", "to", "fate", "n"], t.flows.map((f) => [f.cohort, f.step, f.from, f.to, f.fate, f.n]), "sequence_flows.csv"), + "flows[\"to\"] = flows[\"to\"].fillna(\"\")", + "out_w = flows.groupby(\"from_\")[\"n\"].sum()", + "in_w = flows[(flows[\"fate\"] == 0) & (flows[\"to\"] != \"\")].groupby(\"to\")[\"n\"].sum()", + "weight = {**{k: v for k, v in in_w.items() if k not in out_w}, **out_w.to_dict()}", + "keep = {label for label, _ in sorted(weight.items(), key=lambda x: (-x[1], x[0]))[:" + t.topN + "]}", + "fold = lambda label: label if label in keep else \"other\"", + "kind = {0: \"regimen\", 1: \"stopped\", 2: \"censored\"}", + "print(len(weight) - len(keep), \"regimens folded into 'other'\")", + "for cohort, g in flows.groupby(\"cohort\", sort=False):", + " out_n, in_n, started, censored = {}, {}, 0, 0", + " for r in g.itertuples():", + " k = kind[r.fate]", + " src, dst = (r.step, \"regimen\", fold(r.from_)), (r.step + 1, k, fold(r.to) if k == \"regimen\" else \"\")", + " out_n[src] = out_n.get(src, 0) + r.n", + " in_n[dst] = in_n.get(dst, 0) + r.n", + " started += r.n if r.step == 1 else 0", + " censored += r.n if k == \"censored\" else 0", + " print(f\"{cohort}: {started} with a first line, {censored} paths end censored\")", + " RESULT[f\"sequence:{cohort}:started\"], RESULT[f\"sequence:{cohort}:censored\"] = started, censored", + " for node in set(out_n) | set(in_n):", + " RESULT[f\"sequence:{cohort}:{node[0]}:{node[1]}:{node[2]}:n\"] = out_n.get(node, in_n.get(node))", + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: [], file: "lib/treatment-lines.ts, lib/views/sequence/run.ts", commit: BUILD_COMMIT }, + python: withResult(python), + check: Object.assign({}, ...t.results.map((p) => ({ + ["sequence:" + p.cohort + ":started"]: { v: p.started, abs: 0 }, + ["sequence:" + p.cohort + ":censored"]: { v: p.censored, abs: 0 }, + ...checks("sequence:" + p.cohort + ":", p.nodes, (nd) => nd.column + ":" + nd.kind + ":" + nd.label, [["n", "n", { abs: 0 }]]), + }))), + }; +} diff --git a/lib/views/sequence.ts b/lib/views/sequence/run.ts similarity index 95% rename from lib/views/sequence.ts rename to lib/views/sequence/run.ts index 5337069..b6482e0 100644 --- a/lib/views/sequence.ts +++ b/lib/views/sequence/run.ts @@ -1,13 +1,14 @@ // Sequence view: what follows what, as lines of therapy — flows between the labels of each // patient's successive lines. -import { lineClassesParam } from "../cohort2.ts"; -import { lineGapOf, DEFAULT_LINE_GAP } from "../treatment-lines.ts"; -import type { ViewResult, SequenceView, SequenceResult, SequencePanel, SequenceNodeKind } from "../types.ts"; -import { SEQUENCE_ROW_LIMIT, type SequenceOpts } from "../federation.ts"; -import { canonicalEventName } from "../event-names.ts"; -import { sequenceMethod } from "../method.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { overLimit } from "./shared.ts"; +import { lineClassesParam } from "../../cohort2.ts"; +import { lineGapOf, DEFAULT_LINE_GAP } from "../../treatment-lines.ts"; +import type { ViewResult } from "../../types.ts"; +import { SEQUENCE_ROW_LIMIT, type SequenceOpts } from "../../federation.ts"; +import { canonicalEventName } from "../../event-names.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { overLimit } from "../shared.ts"; +import type { SequenceView, SequenceResult, SequencePanel, SequenceNodeKind } from "./types.ts"; +import { sequenceMethod } from "./method.ts"; export async function runSequence(view: DocView, c: ViewRun): Promise { const { doc, cohorts, resolvers, vNames, participationFor } = c; diff --git a/lib/views/sequence/types.ts b/lib/views/sequence/types.ts new file mode 100644 index 0000000..c421e4f --- /dev/null +++ b/lib/views/sequence/types.ts @@ -0,0 +1,70 @@ +// The sequence view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { } from "../../types.ts"; + +/** + * Treatment sequences: what follows what, as lines of therapy. + * + * `exclude` drops label values that are not therapies. It is a parameter rather + * than an engine rule because such markers are not structurally distinguishable + * from real treatments — in MSK-CHORD the SUBTYPE `Prior Medications to MSK` + * carries no duration, but neither do `Radiation Therapy` or `Investigational`. + * Putting the choice in the document keeps it visible to whoever reads the result. + */ +export interface SequenceView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; + kind: "sequence"; cohorts?: string[]; + /** event type carrying therapies (default: treatment) */ + eventType?: string; + /** attribute key labelling each therapy (default: SUBTYPE) */ + labelKey?: string; + exclude?: string[]; + /** days with no therapy that begin a new line (default 90) */ + lineGap?: number; + /** lines to follow (default 3) */ + maxSteps?: number; + /** distinct labels kept before the rest fold into "other" (default 8) */ + topN?: number; + /** which treatment records form lines: "systemic" (default), "all", or class names — lib/treatment-lines.ts */ + lineClasses?: "systemic" | "all" | string[]; +} +/** + * Therapy transitions, ready to draw: nodes in columns, flows between them. + * One panel per cohort, sharing one scale and one colour assignment so two + * cohorts can be compared without re-reading the legend. + */ +export interface SequenceResult { + labelKey: string; lineGap: number; steps: number; + foldedLabels: number; truncated: boolean; + /** other attribute keys that could label a line, with how granular each is */ + keyOptions?: { key: string; label: string; patients: number; values: number }[]; + panels: SequencePanel[]; +} +/** + * `kind` distinguishes the two ways a path can end, which the label text alone + * cannot: a regimen name is study data and could say anything. + * regimen — a real line of therapy + * stopped — observed for at least one line gap and began no further line + * censored — the record simply ends, so nothing can be concluded + */ +export type SequenceNodeKind = "regimen" | "stopped" | "censored"; +export interface SequencePanel { + cohort: string; + nodes: { column: number; label: string; n: number; kind: SequenceNodeKind }[]; + /** `kind` describes the TARGET of the flow */ + flows: { column: number; from: string; to: string; n: number; kind: SequenceNodeKind }[]; + /** patients with a first line */ + started: number; + /** of those, how many end in a censored terminal at any column */ + censored: number; +} + +declare module "../../types.ts" { + interface ViewSpecs { sequence: SequenceView } + interface ViewResultFields { + sequence: { + sequence?: SequenceResult; + }; + } + interface ViewResultMain { sequence: "sequence" } +} diff --git a/lib/figures/survival.ts b/lib/views/survival/figure.ts similarity index 96% rename from lib/figures/survival.ts rename to lib/views/survival/figure.ts index bb22f45..eece777 100644 --- a/lib/figures/survival.ts +++ b/lib/views/survival/figure.ts @@ -1,15 +1,14 @@ // Kaplan–Meier (or 1 − KM, "cumulative incidence") curves, one per cohort, with the number still // at risk under each tick (a curve over 40 remaining patients must not look like one over 4,000), // each cohort's median, size and events in the legend, and the log-rank test under it. -import type { KmCurve, DrawnResultOf } from "../types.ts"; -import { survivalAxis } from "../stats.ts"; -import { stepPath } from "../chart/marks.ts"; -import { plotHeight } from "./common.ts"; +import type { KmCurve, DrawnResultOf } from "../../types.ts"; +import { survivalAxis } from "../../stats.ts"; +import { stepPath } from "../../chart/marks.ts"; +import { plotHeight } from "../../figures/common.ts"; import { palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, legend, seriesStyle, fmtInt, fmtMonths, fmtP, fmtNum, label, textWidth, esc, FS, SW, type SvgOpts -} from "../svg-kit.ts"; - +} from "../../svg-kit.ts"; export function survivalSvg(v: DrawnResultOf<"survival">, o: SvgOpts): string { const p = palette(o.theme), W = Math.max(360, o.width || 720); diff --git a/lib/views/survival/method.ts b/lib/views/survival/method.ts new file mode 100644 index 0000000..4ced260 --- /dev/null +++ b/lib/views/survival/method.ts @@ -0,0 +1,136 @@ +// The survival view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type MethodRecord, type MethodStep, plural, pFmt, VERIFIED, RESULT_INIT, pyTable, withResult, checks } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export interface SurvivalTrace { + endpoint: string; + monthsColumn: string; + statusColumn: string; + queryLabel: string; + /** the timeline event survival was re-anchored to, which brings delayed entry with it */ + anchor?: string; + cutoffMonths?: number; + beyondCutoff: number; + display: "survival" | "incidence"; + cohorts: { + name: string; + perStudy: { study: string; cohortPatients: number; patients: number; rows: number }[]; + n: number; events: number; median: number | null; + /** follow-up ending at or before entry: never at risk, left out */ + unobserved?: number; + }[]; + /** every (months, event, n[, entry]) row each study returned, per cohort */ + counts: { cohort: string; study: string; months: number; event: number; n: number; entry?: number }[]; + test: + | { kind: "none"; reason: string } + | { kind: "logrank" | "logrankK"; stratified: boolean; strata: number; chi2: number; df: number; p: number; + pooled?: { chi2: number; p: number } }; +} + +export function survivalMethod(t: SurvivalTrace): MethodRecord { + const steps: MethodStep[] = []; + for (const c of t.cohorts) for (const s of c.perStudy) { + steps.push({ + kind: "fetch", title: "Count patients by time and status", + detail: "The " + t.queryLabel + " query above: patients grouped by identical " + t.monthsColumn + ", event" + + (t.anchor ? " and entry time" : "") + ", returned as counts — no patient identifier leaves the database. Event: " + + t.statusColumn + " starting with “1”; any other value is censored. " + + (s.cohortPatients > s.patients ? plural(s.cohortPatients - s.patients, "cohort patient") + " had no " + t.monthsColumn + " value." : "Every cohort patient had a value."), + scope: c.name + " · " + s.study, rowsIn: s.cohortPatients, rowsOut: s.patients, + params: [["count rows", s.rows.toLocaleString()]], + }); + } + if (t.cutoffMonths != null) { + steps.push({ kind: "transform", title: "Censor at " + t.cutoffMonths + " months", + detail: plural(t.beyondCutoff, "patient") + " followed past " + t.cutoffMonths + " months had their time set to " + + t.cutoffMonths + " and their event to censored." }); + } + steps.push({ + kind: "statistic", title: "Estimate survival by Kaplan–Meier", + detail: "The product-limit estimator, one curve per cohort, pooled over its studies" + + (t.anchor ? ", with delayed entry: re-anchored to " + t.anchor + ", a patient is at risk only from the time the study could first observe them" : "") + + (t.cohorts.some((c) => c.unobserved) ? ". A patient whose follow-up ends at or before their entry was never at risk and is left out: " + + t.cohorts.filter((c) => c.unobserved).map((c) => c.name + " " + c.unobserved!.toLocaleString("en-US")).join(", ") : "") + + ". The median is the first time the curve is at or below 0.5" + + (t.display === "incidence" ? ". The figure shows cumulative incidence, 1 − S(t)" : "") + ".", + params: t.cohorts.map((c) => [c.name, c.n.toLocaleString() + " patients, " + c.events.toLocaleString() + " events, median " + + (c.median == null ? "not reached" : c.median.toFixed(1) + " months")] as [string, string]), + }); + if (t.test.kind === "none") { + steps.push({ kind: "statistic", title: "No log-rank test", detail: t.test.reason }); + } else { + const k = t.cohorts.length, x = t.test; + steps.push({ + kind: "statistic", + title: (k === 2 ? "Two-group" : k + "-group") + " log-rank test" + (x.stratified ? ", stratified by study" : ""), + detail: "At each event time, every cohort's observed events against its share of the risk set, with the hypergeometric variance" + + (k > 2 ? " and covariance; χ² on " + x.df + " df from the deviation vector" : "; χ² on 1 df") + + (x.stratified ? ". Each study is its own stratum — compared only against itself — and the strata are summed, so a difference in case mix between studies cannot pass for a difference between cohorts" : "") + ".", + params: [ + ["χ² / df / p", x.chi2.toFixed(4) + " / " + x.df + " / " + pFmt(x.p)], + ...(x.stratified ? [["strata with events", String(x.strata)] as [string, string]] : []), + ...(x.pooled ? [["pooled, for contrast", "χ² " + x.pooled.chi2.toFixed(4) + ", p " + pFmt(x.pooled.p)] as [string, string]] : []), + ], + }); + } + const hasEntry = t.counts.some((r) => r.entry != null); + const cols = ["cohort", "study", "months", "event", "n", ...(hasEntry ? ["entry"] : [])]; + const python = [ + "# Refit with lifelines (Kaplan-Meier) and statsmodels (log-rank): lifelines " + VERIFIED.km.version + + " and statsmodels " + VERIFIED.logrank.version + " are the versions our code is tested against.", + "import io", + "import pandas as pd", + "from lifelines import KaplanMeierFitter", + "from scipy.stats import chi2 as chi2_dist", + "from statsmodels.duration.survfunc import survdiff", + "", + RESULT_INIT, + ...pyTable("counts", "What the " + t.queryLabel + " query returned for each cohort and study: n patients per (months, event" + + (hasEntry ? ", entry" : "") + ")", cols, t.counts.map((r) => [r.cohort, r.study, r.months, r.event, r.n, ...(hasEntry ? [r.entry] : [])]), + "survival_counts.csv"), + "# one row per patient again: a count row stands for n patients with exactly those values", + "df = counts.loc[counts.index.repeat(counts[\"n\"])].reset_index(drop=True)", + ...(t.cutoffMonths != null ? [ + "df.loc[df[\"months\"] > " + t.cutoffMonths + ", \"event\"] = 0 # censor at " + t.cutoffMonths + " months", + "df[\"months\"] = df[\"months\"].clip(upper=" + t.cutoffMonths + ")"] : []), + "df = df[df[\"months\"] >= 0]", + ...(hasEntry ? ["df = df[df[\"months\"] > df[\"entry\"]] # follow-up ending at or before entry was never at risk"] : []), + "", + "for cohort, g in df.groupby(\"cohort\", sort=False):", + " km = KaplanMeierFitter().fit(g[\"months\"], g[\"event\"]" + (hasEntry ? ", entry=g[\"entry\"]" : "") + ", label=cohort)", + " print(f\"{cohort}: n={len(g)}, events={g['event'].sum()}, median={km.median_survival_time_}\")", + " RESULT[f\"km:{cohort}:n\"], RESULT[f\"km:{cohort}:events\"], RESULT[f\"km:{cohort}:median\"] = len(g), g[\"event\"].sum(), km.median_survival_time_", + ...(t.test.kind === "none" ? [] : [ + "", + "# p from the chi-squared tail directly: survdiff's own p rounds to 0 below about 1e-16", + ...(t.test.stratified ? [ + "chi2, p = survdiff(df[\"months\"], df[\"event\"], df[\"cohort\"], strata=df[\"study\"]" + (hasEntry ? ", entry=df[\"entry\"]" : "") + ")", + "print(f\"log-rank, stratified by study: chi2={chi2:.4f}, p={chi2_dist.sf(chi2, " + t.test.df + "):.4g}\")", + "RESULT[\"logrank:chi2\"], RESULT[\"logrank:p\"] = chi2, chi2_dist.sf(chi2, " + t.test.df + ")", + "chi2, p = survdiff(df[\"months\"], df[\"event\"], df[\"cohort\"]" + (hasEntry ? ", entry=df[\"entry\"]" : "") + ")", + "print(f\"log-rank, pooled: chi2={chi2:.4f}, p={chi2_dist.sf(chi2, " + t.test.df + "):.4g}\")", + "RESULT[\"logrank:pooled:chi2\"], RESULT[\"logrank:pooled:p\"] = chi2, chi2_dist.sf(chi2, " + t.test.df + ")", + ] : [ + "chi2, p = survdiff(df[\"months\"], df[\"event\"], df[\"cohort\"]" + (hasEntry ? ", entry=df[\"entry\"]" : "") + ")", + "print(f\"log-rank: chi2={chi2:.4f}, p={chi2_dist.sf(chi2, " + t.test.df + "):.4g}\")", + "RESULT[\"logrank:chi2\"], RESULT[\"logrank:p\"] = chi2, chi2_dist.sf(chi2, " + t.test.df + ")", + ]), + ]), + ].join("\n") + "\n"; + return { + steps, + implementation: { functions: ["kaplanMeier", ...(t.test.kind === "logrank" ? ["logRank"] : t.test.kind === "logrankK" ? ["logRankK"] : [])], + file: "lib/stats.ts", commit: BUILD_COMMIT }, + verification: "Kaplan–Meier matches " + VERIFIED.km.library + " " + VERIFIED.km.version + " " + VERIFIED.km.fn + + " (curves are reported to 4 decimals); the log-rank matches " + VERIFIED.logrank.library + " " + VERIFIED.logrank.version + " " + + VERIFIED.logrank.fn + " to a relative 1e-6, pooled, stratified, with delayed entry and for k groups (test/survival-golden.test.ts).", + python: withResult(python), + check: { + ...checks("km:", t.cohorts, (c) => c.name, [["n", "n", { abs: 0 }], ["events", "events", { abs: 0 }], ["median", "median", { abs: 1e-9 }]]), + ...(t.test.kind === "none" ? {} : { + "logrank:chi2": { v: t.test.chi2, abs: 5e-5 }, "logrank:p": { v: t.test.p, rel: 1e-5, abs: 1e-300 }, + ...(t.test.pooled ? { "logrank:pooled:chi2": { v: t.test.pooled.chi2, abs: 5e-5 }, "logrank:pooled:p": { v: t.test.pooled.p, rel: 1e-5, abs: 1e-300 } } : {}), + }), + }, + }; +} diff --git a/lib/views/survival.ts b/lib/views/survival/run.ts similarity index 98% rename from lib/views/survival.ts rename to lib/views/survival/run.ts index 6b27081..13a554a 100644 --- a/lib/views/survival.ts +++ b/lib/views/survival/run.ts @@ -1,11 +1,12 @@ // Survival view: Kaplan-Meier curves pooled exactly from each participant's id-free // (months, event, n) counts, with log-rank tests. -import { kaplanMeier, logRank, logRankK, applyCutoff, toIncidence } from "../stats.ts"; -import type { ViewResult, LogRank, LogRankK, SurvivalView } from "../types.ts"; -import { expandSurvivalCounts, type SurvivalCount, type EndpointColumns } from "../federation.ts"; -import { survivalMethod } from "../method.ts"; -import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { ATTR_NAME, askEachSite } from "./shared.ts"; +import { kaplanMeier, logRank, logRankK, applyCutoff, toIncidence } from "../../stats.ts"; +import type { ViewResult, LogRank, LogRankK } from "../../types.ts"; +import { expandSurvivalCounts, type SurvivalCount, type EndpointColumns } from "../../federation.ts"; +import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; +import { ATTR_NAME, askEachSite } from "../shared.ts"; +import type { SurvivalView } from "./types.ts"; +import { survivalMethod } from "./method.ts"; export async function runSurvival(view: DocView, c: ViewRun): Promise { const { doc, cohorts, resolvers, vNames, vStudies, participationFor } = c; diff --git a/lib/views/survival/types.ts b/lib/views/survival/types.ts new file mode 100644 index 0000000..22447f3 --- /dev/null +++ b/lib/views/survival/types.ts @@ -0,0 +1,57 @@ +// The survival view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { KmCurve, LogRank, LogRankK } from "../../types.ts"; +import type { SplitBy } from "../../types.ts"; + +export interface SurvivalView { + /** the analyst's own one-line annotation, drawn above the figure (plain text, never markdown) */ + note?: string; + kind: "survival"; cohorts?: string[]; splitBy?: SplitBy; + /** endpoint prefix: OS (default), PFS, DFS, DSS… reads _MONTHS/_STATUS */ + endpoint?: string; + /** + * Override the _MONTHS/_STATUS naming convention with the exact clinical + * attribute names, for a study whose outcome columns don't follow it (some do not — e.g. + * PFS_MONTHS_FIRST_LINE_TX/PROGRESSION_STATUS). Both must be given together; `endpoint`, if + * also given, is then just a display label, not something column names are derived from. + */ + monthsAttribute?: string; statusAttribute?: string; + /** + * Re-anchor the clock to a timeline event instead of the study's day 0. + * Delayed entry is applied automatically — without it, anchoring earlier than + * day 0 manufactures immortal time. + */ + anchor?: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; + /** administratively censor everyone still at risk past this month; truncates the plot too */ + cutoffMonths?: number; + /** x-axis tick spacing in months (default: 12, or 24 past 60mo of plotted range) */ + tickMonths?: number; + /** draw 1 - S(t), cumulative incidence, instead of survival (default "survival") */ + display?: "survival" | "incidence"; +} + +declare module "../../types.ts" { + interface ViewSpecs { survival: SurvivalView } + interface ViewResultFields { + survival: { + curves?: KmCurve[]; + /** which endpoint was used */ + endpoint?: string; + /** the event the clock was reset to */ + anchoredTo?: string; + /** delayed entry was applied */ + truncated?: boolean; + /** how long after the anchor patients became observable */ + entryMonths?: { median: number; p90: number; max: number }; + /** administrative censoring applied, echoed for the renderers */ + cutoffMonths?: number; + /** requested x-axis tick spacing, echoed for the renderers */ + tickMonths?: number; + /** which curve was drawn */ + display?: "survival" | "incidence"; + logrank?: LogRank; logrankPooled?: LogRank; + /** 3+ cohorts: the k-group generalization (tests whether the groups differ AT ALL) */ + logrankK?: LogRankK; logrankKPooled?: LogRankK; + }; + } + interface ViewResultMain { survival: "curves" } +} diff --git a/lib/views/table/method.ts b/lib/views/table/method.ts new file mode 100644 index 0000000..cd1448a --- /dev/null +++ b/lib/views/table/method.ts @@ -0,0 +1,14 @@ +// The table view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type TableLists, type MethodRecord, tableSteps } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export function tableMethod(t: { unit: "sample" | "patient"; cohorts: string[]; columns: number; query: unknown; queryText: string; sortName: string | null; shown: number; + lists: TableLists; excluded: { study: string; reason: string; patients: number }[]; instances: number }): MethodRecord { + const desc = !!(t.query as { sort?: { desc?: boolean } })?.sort?.desc; + const steps = tableSteps({ label: "table", what: t.unit + "s", queryText: t.queryText, shown: t.shown, lists: t.lists, instances: t.instances, excluded: t.excluded, + source: "every " + t.unit + " of each cohort's patients" + (t.unit === "sample" ? " (from the study's sample list, so a sample with no sample attribute still has a row)" : "") + + ", each with the value of each of the " + t.columns + " attributes as recorded — blank where none was.", + order: (t.sortName ? "by " + t.sortName + (desc ? " descending" : "") + " with missing values last, then " : "by ") + "patient and sample id" }); + return { steps, implementation: { functions: ["clinicalRowSource", "pageSql", "countsSql"], file: "lib/clinical-table.ts, lib/table-query.ts", commit: BUILD_COMMIT }, + verification: "A listing: no statistic is computed, so there is no script — the queries are the numbers." }; +} diff --git a/lib/views/table.ts b/lib/views/table/run.ts similarity index 93% rename from lib/views/table.ts rename to lib/views/table/run.ts index 4368396..55f8e60 100644 --- a/lib/views/table.ts +++ b/lib/views/table/run.ts @@ -1,11 +1,12 @@ // Table view: the clinical data table, on lib/table-query.ts -import type { ViewResult, TableView } from "../types.ts"; -import { tableAttributesSql, clinicalRowSource, idColumns, TABLE_COLUMNS_MAX, type TableColumn } from "../clinical-table.ts"; -import { checkQuery, describeQuery, shownColumns } from "../table-query.ts"; -import { tableMethod } from "../method.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, ATTR_NAME, plural, refuse, type ViewResolver } from "./shared.ts"; -import { TABLE_ROWS_MAX, rowHolders, runRows, tableTail, viewQuery, type Rows } from "./rows.ts"; +import type { ViewResult } from "../../types.ts"; +import { tableAttributesSql, clinicalRowSource, idColumns, TABLE_COLUMNS_MAX, type TableColumn } from "../../clinical-table.ts"; +import { checkQuery, describeQuery, shownColumns } from "../../table-query.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, ATTR_NAME, plural, refuse, type ViewResolver } from "../shared.ts"; +import { TABLE_ROWS_MAX, rowHolders, runRows, tableTail, viewQuery, type Rows } from "../rows.ts"; +import type { TableView } from "./types.ts"; +import { tableMethod } from "./method.ts"; export async function runTable(view: TableView, ctx: ViewRun): Promise { const { vNames } = ctx; diff --git a/lib/views/table/types.ts b/lib/views/table/types.ts new file mode 100644 index 0000000..ebc70eb --- /dev/null +++ b/lib/views/table/types.ts @@ -0,0 +1,33 @@ +// The table view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { TableRows } from "../../types.ts"; +import type { TableQuery } from "../../table-query.ts"; + +/** + * The clinical data table (cBioPortal's Clinical Data tab): one row per sample — or per patient — + * of each cohort, with every clinical attribute the study records; searchable, filterable, + * sortable, paged, and downloadable whole as CSV. Its rows are patients, so only a patient-level + * participant contributes them; the rest are named with how many patients they hold. + */ +export interface TableView extends TableQuery { + note?: string; + kind: "table"; cohorts?: string[]; + /** a row per sample (default, with its patient's attributes too) or per patient */ + unit?: "sample" | "patient"; + /** older spelling of `columns`: the attribute columns shown */ + attributes?: string[]; + /** rows per page (default 25, at most 1000); every page and the download are slices of one order */ + rows?: number; +} +export interface TableResult extends TableRows { + unit: "sample" | "patient"; +} + +declare module "../../types.ts" { + interface ViewSpecs { table: TableView } + interface ViewResultFields { + table: { + table?: TableResult; + }; + } + interface ViewResultMain { table: "table" } +} diff --git a/lib/views/timeline/method.ts b/lib/views/timeline/method.ts new file mode 100644 index 0000000..cbc0db4 --- /dev/null +++ b/lib/views/timeline/method.ts @@ -0,0 +1,19 @@ +// The timeline view's method record: what was computed from its queries' rows, step by step (lib/method.ts). +import { type TableLists, type MethodRecord, tableSteps } from "../../method.ts"; +import { BUILD_COMMIT } from "../../build-info.ts"; + +export function timelineMethod(t: { cohorts: string[]; queryText: string; sortName: string | null; desc: boolean; shown: number; + lists: TableLists; excluded: { study: string; reason: string; patients: number }[]; instances: number; + patient: { study: string; id: string; events: number; samples: number } | null }): MethodRecord { + const steps = tableSteps({ label: "timeline", what: "patients", queryText: t.queryText, shown: t.shown, lists: t.lists, instances: t.instances, excluded: t.excluded, + source: "every patient of each cohort, with their sample count and, from the study's timeline, the number of events, the first and last event day (a range counted to its stop) and the event types — a patient with no timeline is listed with none.", + order: t.sortName ? "by " + t.sortName + (t.desc ? " descending" : "") + " with missing values last, then patient id" : "patients with events first, then by patient id" }); + if (t.patient) steps.push({ + kind: "fetch" as const, title: "Read " + t.patient.id + "'s timeline", + detail: "The timeline:events and timeline:samples queries above: every clinical event recorded for " + t.patient.id + " in " + t.patient.study + + " with all its attributes, and the patient's samples with their SAMPLE_TYPE. Events are drawn as stored, in days from diagnosis; tracks group them by event type (case-folded) and nest them by attribute as cBioPortal's patient view does.", + params: [["events", t.patient.events.toLocaleString("en-US")], ["samples", t.patient.samples.toLocaleString("en-US")]], + }); + return { steps, implementation: { functions: ["runTimeline", "timelinePatientRowSource", "patientEventsSql", "buildTracks", "pageSql", "countsSql", "rankSql"], file: "lib/views/timeline/run.ts, lib/patient-timeline.ts, lib/timeline-tracks.ts, lib/table-query.ts", commit: BUILD_COMMIT }, + verification: "A listing and one patient's record: no statistic is computed, so there is no script — the queries are the numbers." }; +} diff --git a/lib/views/timeline.ts b/lib/views/timeline/run.ts similarity index 93% rename from lib/views/timeline.ts rename to lib/views/timeline/run.ts index 308fb6f..54a5c86 100644 --- a/lib/views/timeline.ts +++ b/lib/views/timeline/run.ts @@ -1,15 +1,16 @@ // Timeline view: the cohort's patients, and one patient's clinical timeline (cBioPortal's patient-view // Timeline) — drawn by lib/timeline-svg.ts, on the track model of lib/timeline-tracks.ts. -import type { ViewResult, TimelineView, TimelineResult, PatientTimeline } from "../types.ts"; -import { hasPatientTimeline, peerRowsFor, type PatientLevelCapable, type PatientTimelineCapable, type Resolver } from "../federation.ts"; -import type { TablePart } from "../mutation-table.ts"; -import { checkQuery, describeQuery, shownColumns, type TableQuery } from "../table-query.ts"; -import { timelineMethod } from "../method.ts"; -import { timelineSvg, timelineSummary } from "../timeline-svg.ts"; -import { timelinePatientRowSource, TIMELINE_COLUMNS, TIMELINE_DEFAULT_COLUMNS, PATIENT_ID } from "../patient-timeline.ts"; -import type { ViewRun } from "./run.ts"; -import { TABLE_ROWS_MAX, listPosition, rowHolders, runRows, tableTail, viewQuery, type Rows } from "./rows.ts"; -import { refuse } from "./shared.ts"; +import type { ViewResult, TimelineResult, PatientTimeline } from "../../types.ts"; +import { hasPatientTimeline, peerRowsFor, type PatientLevelCapable, type PatientTimelineCapable, type Resolver } from "../../federation.ts"; +import type { TablePart } from "../../mutation-table.ts"; +import { checkQuery, describeQuery, shownColumns, type TableQuery } from "../../table-query.ts"; +import { timelineSvg, timelineSummary } from "../../timeline-svg.ts"; +import { timelinePatientRowSource, TIMELINE_COLUMNS, TIMELINE_DEFAULT_COLUMNS, PATIENT_ID } from "../../patient-timeline.ts"; +import type { ViewRun } from "../run.ts"; +import { TABLE_ROWS_MAX, listPosition, rowHolders, runRows, tableTail, viewQuery, type Rows } from "../rows.ts"; +import { refuse } from "../shared.ts"; +import type { TimelineView } from "./types.ts"; +import { timelineMethod } from "./method.ts"; export async function runTimeline(view: TimelineView, ctx: ViewRun): Promise { const { vNames } = ctx; diff --git a/lib/views/timeline/types.ts b/lib/views/timeline/types.ts new file mode 100644 index 0000000..c054e77 --- /dev/null +++ b/lib/views/timeline/types.ts @@ -0,0 +1,32 @@ +// The timeline view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { TimelineResult } from "../../types.ts"; +import type { TableQuery } from "../../table-query.ts"; + +/** + * One patient's clinical timeline (cBioPortal's patient-view Timeline), with the list of the + * cohorts' patients to choose them from. The list is a table view (lib/table-query.ts: paged, + * searched, filtered, sorted, downloadable); the timeline is the chosen patient's events as tracks + * (lib/timeline-tracks.ts). Both name a patient, so only a patient-level participant contributes. + */ +export interface TimelineView extends TableQuery { + note?: string; + kind: "timeline"; cohorts?: string[]; + /** the patient whose timeline is drawn; with none, the list's first row */ + patient?: { study: string; id: string }; + /** track uids drawn collapsed (their events folded into one row), e.g. "treatment.Chemo" */ + collapsed?: string[]; + /** draw every year rather than cutting runs of four or more empty years to one mark (default false) */ + expandGaps?: boolean; + /** patients per page of the list (default 25, at most 1000) */ + rows?: number; +} + +declare module "../../types.ts" { + interface ViewSpecs { timeline: TimelineView } + interface ViewResultFields { + timeline: { + timeline?: TimelineResult; + }; + } + interface ViewResultMain { timeline: "timeline" } +} diff --git a/lib/figures/txendpoints.ts b/lib/views/txendpoints/figure.ts similarity index 91% rename from lib/figures/txendpoints.ts rename to lib/views/txendpoints/figure.ts index 0799727..8d110a0 100644 --- a/lib/figures/txendpoints.ts +++ b/lib/views/txendpoints/figure.ts @@ -1,8 +1,9 @@ // Treatment endpoints figure: Kaplan–Meier curves of the endpoint per cohort from the treatment // anchor, the number at risk, each cohort's median with its interval, and the log-rank test. -import type { DrawnResultOf, TxendpointsCurve } from "../types.ts"; -import { palette, frame, caption, label, fmtNum, fmtP, fmtInt, FS, type SvgOpts } from "../svg-kit.ts"; -import { kmPanel } from "./km.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import { palette, frame, caption, label, fmtNum, fmtP, fmtInt, FS, type SvgOpts } from "../../svg-kit.ts"; +import { kmPanel } from "../../figures/km.ts"; +import type { TxendpointsCurve } from "./types.ts"; const fmtMo = (m: number | null) => m == null ? "not reached" : fmtNum(Math.round(m * 10) / 10) + " mo"; diff --git a/lib/views/txendpoints.ts b/lib/views/txendpoints/run.ts similarity index 96% rename from lib/views/txendpoints.ts rename to lib/views/txendpoints/run.ts index 837ddda..1c880cf 100644 --- a/lib/views/txendpoints.ts +++ b/lib/views/txendpoints/run.ts @@ -1,13 +1,14 @@ // Real-world treatment endpoints view: time to next treatment, time on treatment or real-world // progression-free survival from a treatment anchor, as Kaplan–Meier curves per cohort with a // log-rank test. Each participant returns id-free (entry, t, event, n) counts. -import type { ViewResult, TxendpointsView, TxendpointsCurve, LogRankK } from "../types.ts"; -import { txEndpointCountsSql, labTestsSql, CLOCK_SLACK_DAYS, DAYS_PER_MONTH, type TxEndpointOpts } from "../federation-views.ts"; -import { kaplanMeier, kmMedianCi, logRankK, applyCutoff, type SurvRecord } from "../stats.ts"; -import { DEFAULT_LINE_GAP, TREATMENT_EVENT } from "../treatment-lines.ts"; -import type { ViewRun } from "./run.ts"; -import { overLimit, answering, refuse, plural } from "./shared.ts"; -import { progressionEvent, progressionError, progressionCondition, clockFailures, treatmentAnchor, noAnchorMessage, CLOCK_TOLERANCE } from "../timeline-endpoints.ts"; +import type { ViewResult, LogRankK } from "../../types.ts"; +import { txEndpointCountsSql, labTestsSql, CLOCK_SLACK_DAYS, DAYS_PER_MONTH, type TxEndpointOpts } from "../../federation-views.ts"; +import { kaplanMeier, kmMedianCi, logRankK, applyCutoff, type SurvRecord } from "../../stats.ts"; +import { DEFAULT_LINE_GAP, TREATMENT_EVENT } from "../../treatment-lines.ts"; +import type { ViewRun } from "../run.ts"; +import { overLimit, answering, refuse, plural } from "../shared.ts"; +import { progressionEvent, progressionError, progressionCondition, clockFailures, treatmentAnchor, noAnchorMessage, CLOCK_TOLERANCE } from "../../timeline-endpoints.ts"; +import type { TxendpointsView, TxendpointsCurve } from "./types.ts"; const TX_COHORT_LIMIT = 6; /** below this many patients in a cohort the log-rank test is refused (the survival view's floor) */ diff --git a/lib/views/txendpoints/types.ts b/lib/views/txendpoints/types.ts new file mode 100644 index 0000000..4d9b4f4 --- /dev/null +++ b/lib/views/txendpoints/types.ts @@ -0,0 +1,51 @@ +// The txendpoints view's spec and result, added to lib/types.ts's maps by the declaration at the end. +import type { KmCurve, LogRankK } from "../../types.ts"; + +export interface TxendpointsView { + note?: string; + kind: "txendpoints"; cohorts?: string[]; + /** the clock's start: a treatment record (event type, key = value), its first occurrence unless LAST */ + anchor: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; + /** ttnt time to next treatment (default), tot time on treatment, rwpfs real-world progression-free survival */ + endpoint?: "ttnt" | "tot" | "rwpfs"; + /** treatment lines (lib/treatment-lines.ts): days without therapy that start a new line (default 90), and which records form lines */ + lineGap?: number; lineClasses?: "systemic" | "all" | string[]; + /** rwpfs: the progression event (default event type Diagnosis, key PROGRESSION, values Y / Yes), and an optional probability floor */ + progression?: { eventType?: string; key?: string; values?: string[]; probabilityKey?: string; minProbability?: number }; + /** tot: a living patient whose stop lies within this many days of the last follow-up is censored as still on treatment (default 30) */ + ongoingDays?: number; + /** administrative censoring, months */ + cutoffMonths?: number; +} +export interface TxendpointsCurve extends Omit { + cohort: string; + /** the median's 95% interval (Greenwood, log scale), null where the band never reaches 0.5 */ + medianLo: number | null; medianHi: number | null; + /** the cohort's patients, those anchored, and those left out with why */ + cohortPatients: number; anchored: number; + excluded: { noAnchor: number; noFollowup: number; noStop: number }; + /** patients whose timeline runs past their last follow-up (the clock check) */ + late: number; +} +export interface TxendpointsResult { + endpoint: "ttnt" | "tot" | "rwpfs"; endpointLabel: string; anchoredTo: string; + lineGap: number; lineClasses: string; + progression?: { eventType: string; key: string; values: string[]; probabilityKey?: string; minProbability?: number }; + ongoingDays?: number; cutoffMonths?: number; + curves: TxendpointsCurve[]; + /** k-group log-rank over the cohorts, stratified by study where several answer */ + logrank: LogRankK | null; + /** why there is no test */ + noTest?: string; + excludedStudies?: { study: string; reason: string }[]; +} + +declare module "../../types.ts" { + interface ViewSpecs { txendpoints: TxendpointsView } + interface ViewResultFields { + txendpoints: { + txendpoints?: TxendpointsResult; + }; + } + interface ViewResultMain { txendpoints: "txendpoints" } +} diff --git a/mcp/tools.ts b/mcp/tools.ts index 6b76d98..8d6794f 100644 --- a/mcp/tools.ts +++ b/mcp/tools.ts @@ -26,7 +26,8 @@ import { peerSource } from "../lib/provenance.ts"; import { connections, directConnections } from "../lib/ch.ts"; import { studiesFor } from "../lib/study-list.ts"; import { catalogFor } from "../lib/study-catalog.ts"; -import type { AnalysisDoc, AnalysisResult, Contract, OncoprintSpec } from "../lib/types.ts"; +import type { AnalysisDoc, AnalysisResult, Contract } from "../lib/types.ts"; +import type { OncoprintSpec } from "../lib/views/oncoprint/types.ts"; // ---------- shared zod fragments (the tools' input schemas) ---------- const zSelector = z.object({ @@ -425,7 +426,6 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void { async () => ({ content: [{ type: "text", text: renderDslMarkdown() }] }) ); - server.tool( "run_analysis", "Execute a full Analysis document — the v2 DSL: named cohorts built from OQL genomic status, clinical attributes, timeline events, case lists, and continuous genomic data (optionally federated across studies), plus views: compare (alteration frequencies with Fisher p), clinical (attribute distributions per cohort), cooccurrence (pairwise 2x2 tests), hotspots (mutation lollipop), coexpression (gene-pair correlation), survival (Kaplan-Meier), cox (multivariate hazard ratios, forest plot), logistic (multivariate logistic regression on a binary outcome, odds ratios), course (event timelines), sequence (treatment-line flows), plot (scatter/box/table of any two axes), oncoprint, attrition (CONSORT-style counts after each cohort clause), genome (genome-wide copy-number gain/loss frequency from segments), pathways (the ten Sanchez-Vega oncogenic pathways, % altered per cohort), diffexpr (two-cohort differential expression volcano plus preranked GSEA), paired (primary-vs-metastasis or before-vs-after-treatment concordance within patients; omit genes to scan every gene, pairBy.studies to pool studies that label samples differently), clonality (FACETS clonal/subclonal calls, LOH, genome doubling, purity, ploidy), incidence (cumulative incidence of a timeline event with death as a competing risk, Gray's test), labs (a lab value's course and response from a treatment start, e.g. PSA50, with a waterfall), acquired (alterations acquired under therapy: each patient's sample before a treatment start against their sample after it, per gene acquired vs lost with McNemar q; omit genes to scan), ctdna (plasma cfDNA against tissue within patients: per gene both / tissue-only / plasma-only with positive percent agreement, the share with ctDNA detected, and overall survival by ctDNA detected), exclusivity (mutual exclusivity and co-occurrence of up to 20 genes against each tumour's alteration burden, DISCOVER, beside the plain Fisher test), hla (HLA class I genotype: the share homozygous at 0/1/2+ of HLA-A, -B, -C and with HLA LOH, and overall survival by each with log-rank and Cox HRs, optionally adjusted; patient-level studies only), cutpoint (the cut of a numeric marker such as TMB that best splits survival: scan between the 10th and 90th percentiles, permutation-corrected p beside the naive one, KM at the cut, optional per-stratum cuts; patient-level studies only), predictive (gene x treatment interaction per gene: the gene's survival HR among the treated and the untreated and their interaction p, BH q; patient-level studies only), txendpoints (real-world treatment endpoints from a treatment start — time to next treatment, time on treatment or real-world PFS — as Kaplan-Meier curves with log-rank), rmst (restricted mean survival time to tau per cohort with differences and ratios, for non-proportional hazards; landmark analysis from given months), multistate (an illness-death model — start, progression, death — from day 0: state occupation over time, cumulative hazards, transition rates and a hazard ratio per transition), instability (genomic instability from copy-number segments: fraction of genome altered, arm-level gains and losses and the aneuploidy score per cohort, with a rank test), cancertypes (a gene list's alteration frequency per cancer type, the portal's Cancer Types Summary), overlap (how cohorts' patients intersect, as an UpSet plot), enrichment (every gene's alteration frequency compared between cohorts, the portal's Genomic Alterations tab), assays (every entity of a generic-assay profile — mutational signatures, arm-level copy number, treatment response — compared between cohorts), composition (what each case is made of — cell types, cell states, signatures — as stacked bars per case and each cohort's mean, every part of each cohort set against the others), mutations (one gene's mutation table: every call with sample, protein change, OncoKB annotation, allele frequency; patient-level studies only), table (the clinical data table: each sample or patient with every clinical attribute, patient-level studies only), timeline (one patient's clinical timeline as on cBioPortal's patient view — treatments, diagnoses, samples, lab values from diagnosis — with the cohorts' patients listed to pick from; patient-level studies only), alterations (one patient's Mutations, Copy Number Alterations and Structural Variants tables as on cBioPortal's patient view, each searchable, filterable and sortable, with the cohort's patients listed to pick from; patient-level studies only), correlates (every gene's expression correlated with one gene's, the portal's Co-expression tab), riskmodel (an OncoCast-style elastic-net Cox ensemble predicting survival from mutated genes, held-out C-index and Kaplan-Meier risk groups; patient-level studies only), forest (one exposure's hazard ratio per cancer type, study or cohort, pooled by a DerSimonian–Laird random-effects meta-analysis with I² and Cochran's Q; patient-level studies only), adjusted (every frequently mutated gene compared between two cohorts by logistic regression on covariates such as MSI and TMB, adjusted beside raw odds ratios; patient-level studies only), actionability (each patient's highest OncoKB therapeutic level and resistance level per cohort or cancer type, and its primary/metastasis concordance), drivernegative (one cohort split into patients with and without an OncoKB driver in a canonical driver list, panel-aware, and every other gene's frequency compared between them), organotropism (which genes' alterations go with metastasis to which organ, per-organ flags, timeline sites or biopsy sites, Cochran–Mantel–Haenszel within cancer types as a gene × site heatmap, plus the cumulative incidence of one site by one gene's status with death competing), molsubtype (a published molecular classifier per patient — the endometrial POLE-ultramutated / MMR-deficient / p53-abnormal / NSMP hierarchy, with not-classifiable patients kept apart — as subtype shares per cohort and by an attribute, and overall survival by subtype), germline (germline carriers per gene among patients sequenced, and how often their tumour has a somatic second hit in the same gene — a mutation or deep deletion — against non-carriers, with optional overall survival by carrier status). Every view's figure is returned as an image. Call describe_dsl first for the grammar and runnable examples. Returns per-cohort counts with plain-language readbacks, view results, caveats you MUST surface to the user, and a link that opens this exact document in the interactive editor.", @@ -609,7 +609,6 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void { }; }); - // ---- hypothesis campaigns: this tool as the referee for any hypothesis generator ---------- const appBase = () => process.env.APP_BASE_URL || "https://cohort-lab.vercel.app"; const campaignErr = (e: unknown) => text("REJECTED: " + String((e as Error)?.message || e)); diff --git a/scripts/check-mutants.ts b/scripts/check-mutants.ts index 92f5cba..8f9a100 100644 --- a/scripts/check-mutants.ts +++ b/scripts/check-mutants.ts @@ -113,11 +113,11 @@ const MUTANTS: Mutant[] = [ history: "Shipped until 2026-10: TRUNC was nonsense, frameshift and nonstop only.", find: "\" NOT IN ('missense', 'inframe')\"", replace: "\" IN ('nonsense', 'frameshift', 'nonstop')\"" }, - { id: "table1-denominator-ignores-panel", layer: "counts", guard: "unit", file: "lib/views/riskmodel-table1.ts", + { id: "table1-denominator-ignores-panel", layer: "counts", guard: "unit", file: "lib/views/riskmodel/table1.ts", effect: "A risk group's mutation rate in Table 1 divides by every patient in the group, counting those whose panel never covered the gene as unmutated, so the rate reads too low.", find: " if (!covers(p)) continue;\n", replace: "\n" }, - { id: "table1-model-rows-in-fdr", layer: "statistics", guard: "unit", file: "lib/views/riskmodel-table1.ts", + { id: "table1-model-rows-in-fdr", layer: "statistics", guard: "unit", file: "lib/views/riskmodel/table1.ts", effect: "Table 1's q-values are corrected together with the rows the model was built from, whose tiny p-values are guaranteed, so every other row looks more significant than it is.", find: "r.role !== \"in the model\" && r.role !== \"outcome\"", replace: "r.role !== \"outcome\"" }, diff --git a/scripts/check-pooled.ts b/scripts/check-pooled.ts index 5792040..d579663 100644 --- a/scripts/check-pooled.ts +++ b/scripts/check-pooled.ts @@ -26,7 +26,8 @@ import { buildContract } from "../lib/oncoprint.ts"; import { studyProfiles } from "../lib/analysis.ts"; import { chQuery, DB } from "../lib/ch.ts"; import { sq } from "../lib/compiler.ts"; -import type { ClinicalFacet, CohortDef, OncoprintSpec } from "../lib/types.ts"; +import type { ClinicalFacet, CohortDef } from "../lib/types.ts"; +import type { OncoprintSpec } from "../lib/views/oncoprint/types.ts"; const STUDIES = [ "utuc_mskcc_2015", // IMPACT341 diff --git a/test/actionability.test.ts b/test/actionability.test.ts index 9983007..81c9041 100644 --- a/test/actionability.test.ts +++ b/test/actionability.test.ts @@ -1,7 +1,7 @@ // The actionability view's grouping, on fixed inputs. import { test } from "node:test"; import assert from "node:assert/strict"; -import { topGroups } from "../lib/views/actionability.ts"; +import { topGroups } from "../lib/views/actionability/run.ts"; test("the largest groups are kept and the rest pooled into a named Other, counts conserved", () => { const g = (n: number, l1: number) => ({ sequenced: n, levels: [l1, 0, 0, 0, 0, n - l1], resistance: [1, 0] }); diff --git a/test/adjusted.test.ts b/test/adjusted.test.ts index 9f5da25..92728ac 100644 --- a/test/adjusted.test.ts +++ b/test/adjusted.test.ts @@ -1,7 +1,7 @@ // The adjusted view's per-gene fits, on fixed inputs. import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { adjustedFit, covariateKinds, type AdjustedPatient } from "../lib/views/adjusted.ts"; +import { adjustedFit, covariateKinds, type AdjustedPatient } from "../lib/views/adjusted/run.ts"; // cohort 1 is mostly MSI-high, and MSI-high tumours carry gene X: raw, X is enriched in cohort 1; // adjusted for MSI, it is not diff --git a/test/boundary.test.ts b/test/boundary.test.ts index 17931e3..303236e 100644 --- a/test/boundary.test.ts +++ b/test/boundary.test.ts @@ -18,7 +18,7 @@ // ============================================================================ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { carriesPatientIds, plotBinsSql, plotBoxSql, plotCountSql, plotCoverageSql, plotHighlightCountSql, plotMomentsSql, plotStatsSql, plotTableSql, plotViolinSql, @@ -40,6 +40,18 @@ import { asLoopbackPeer, isLoopbackRequest, loopbackHeaders, loopbackUrl, LOOPBA import type { Instance } from "../lib/federation.ts"; import { PRIMARY, chQuery, connById, directConnections, assertNotLoopbackDirect } from "../lib/ch.ts"; +/** + * Every aggregate SQL builder there is: lib/federation.ts's, and each view kind's own + * (lib/views//sql.ts), found by reading the folder, so a new kind's SQL is swept by the + * coverage test below without anyone registering it. + */ +const viewSql: Record = {}; +for (const kind of readdirSync(new URL("../lib/views/", import.meta.url))) { + const file = new URL("../lib/views/" + kind + "/sql.ts", import.meta.url); + if (existsSync(file)) Object.assign(viewSql, await import(file.href)); +} +const aggregates = { ...federation, ...viewSql } as typeof federation & Record string>; + const AGG: Instance = { id: "peer", mode: "aggregate-only", label: "test peer", conn: PRIMARY }; const PL: Instance = { id: "a", mode: "patient-level", label: "test primary", conn: PRIMARY }; @@ -274,7 +286,7 @@ describe("federation SQL returns aggregates, never identifiers", () => { /** * Every aggregate SQL builder, with arguments that exercise it. Naming a * builder here is not optional: the coverage test below fails if any - * `*Sql` export from lib/federation.ts is absent, so a new aggregate cannot + * `*Sql` export from lib/federation.ts or a view kind's sql.ts is absent, so a new aggregate cannot * reach a peer without someone deciding what it may project. Several cases * may share one builder — the suffix after ':' is free-form. */ @@ -362,51 +374,51 @@ describe("federation SQL returns aggregates, never identifiers", () => { "logisticRowsSql": () => logisticRowsSql(cohortSql, { label: "y0", sel: catAxis }, [{ label: "c0", sel: numAxis }]), // lib/federation-views.ts, re-exported through lib/federation.ts: the genome view's bins group // the sample id away inside `binned`; only (chrom, bin) and counts cross. - "genomeBinsSql": () => federation.genomeBinsSql(cohortSql, "x", "db", { binBp: 2e6, threshold: 0.2, includeX: false }), - "genomeBinsSql:withX": () => federation.genomeBinsSql(cohortSql, "x", "db", { binBp: 1e6, threshold: 0.3, includeX: true }), + "genomeBinsSql": () => aggregates.genomeBinsSql(cohortSql, "x", "db", { binBp: 2e6, threshold: 0.2, includeX: false }), + "genomeBinsSql:withX": () => aggregates.genomeBinsSql(cohortSql, "x", "db", { binBp: 1e6, threshold: 0.3, includeX: true }), "genomeCoverageSql": () => federation.genomeCoverageSql(cohortSql, "x", "db"), "studyGenomeSql": () => federation.studyGenomeSql("x", "db"), // pathways: `per` holds (set, patient) rows, but the outer SELECT only counts them per set - "geneSetCountsSql": () => federation.geneSetCountsSql(cohortSql, [{ name: "RTK-RAS", genes: ["KRAS", "NRAS"] }, { name: "TP53", genes: ["TP53"] }], "x", "v7.5", "db"), - "geneSetCountsSql:no-annotation-pin": () => federation.geneSetCountsSql(cohortSql, [{ name: "set", genes: ["APC"] }], "x", null, "db"), + "geneSetCountsSql": () => aggregates.geneSetCountsSql(cohortSql, [{ name: "RTK-RAS", genes: ["KRAS", "NRAS"] }, { name: "TP53", genes: ["TP53"] }], "x", "v7.5", "db"), + "geneSetCountsSql:no-annotation-pin": () => aggregates.geneSetCountsSql(cohortSql, [{ name: "set", genes: ["APC"] }], "x", null, "db"), // diffexpr: `vals` is per (patient, gene); only per-gene totals leave - "profileMomentsAllSql": () => federation.profileMomentsAllSql(cohortSql, "x", "db", "rna_seq_v2_mrna_median_Zscores", false), - "profileMomentsAllSql:log2": () => federation.profileMomentsAllSql(cohortSql, "x", "db", "rna_seq_v2_mrna", true), + "profileMomentsAllSql": () => aggregates.profileMomentsAllSql(cohortSql, "x", "db", "rna_seq_v2_mrna_median_Zscores", false), + "profileMomentsAllSql:log2": () => aggregates.profileMomentsAllSql(cohortSql, "x", "db", "rna_seq_v2_mrna", true), // gene-set membership is reference data, not patient data — no cohort at all - "genesetMembersSql": () => federation.genesetMembersSql(["HALLMARK_E2F_TARGETS"], null, "db"), - "genesetMembersSql:prefix": () => federation.genesetMembersSql([], "HALLMARK_", "db"), + "genesetMembersSql": () => aggregates.genesetMembersSql(["HALLMARK_E2F_TARGETS"], null, "db"), + "genesetMembersSql:prefix": () => aggregates.genesetMembersSql([], "HALLMARK_", "db"), // paired: `ps`/`per` hold samples and patients; the outer SELECT counts them per gene - "pairedCountsSql": () => federation.pairedCountsSql(cohortSql, "x", "db", ["TP53", "ESR1"], { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }, "v7.5", "driver"), - "pairedCountsSql:any": () => federation.pairedCountsSql(cohortSql, "x", "db", ["TP53"], { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis", "Recurrence"] }, null, "any"), - "pairedCountsSql:scan": () => federation.pairedCountsSql(cohortSql, "x", "db", null, { attribute: "IMPACT_PRE_POST", a: ["PRE-TX"], b: ["POST-TX"] }, "v7.5", "driver"), - "pairedCountsSql:scan:any": () => federation.pairedCountsSql(cohortSql, "x", "db", null, { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }, null, "any"), - "pairedPatientsSql": () => federation.pairedPatientsSql(cohortSql, "x", "db", { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }), + "pairedCountsSql": () => aggregates.pairedCountsSql(cohortSql, "x", "db", ["TP53", "ESR1"], { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }, "v7.5", "driver"), + "pairedCountsSql:any": () => aggregates.pairedCountsSql(cohortSql, "x", "db", ["TP53"], { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis", "Recurrence"] }, null, "any"), + "pairedCountsSql:scan": () => aggregates.pairedCountsSql(cohortSql, "x", "db", null, { attribute: "IMPACT_PRE_POST", a: ["PRE-TX"], b: ["POST-TX"] }, "v7.5", "driver"), + "pairedCountsSql:scan:any": () => aggregates.pairedCountsSql(cohortSql, "x", "db", null, { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }, null, "any"), + "pairedPatientsSql": () => aggregates.pairedPatientsSql(cohortSql, "x", "db", { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }), // acquired: `pick`/`ps` hold each patient's before and after sample; the outer SELECTs count them per gene or along the funnel - "acquiredCountsSql": () => federation.acquiredCountsSql(cohortSql, "x", "db", ["TP53", "ESR1"], ACQ, "v7.5", "driver"), - "acquiredCountsSql:scan-any": () => federation.acquiredCountsSql(cohortSql, "x", "db", null, { ...ACQ, preWindowDays: 365 }, null, "any"), - "acquiredFunnelSql": () => federation.acquiredFunnelSql(cohortSql, "x", "db", ACQ), + "acquiredCountsSql": () => aggregates.acquiredCountsSql(cohortSql, "x", "db", ["TP53", "ESR1"], ACQ, "v7.5", "driver"), + "acquiredCountsSql:scan-any": () => aggregates.acquiredCountsSql(cohortSql, "x", "db", null, { ...ACQ, preWindowDays: 365 }, null, "any"), + "acquiredFunnelSql": () => aggregates.acquiredFunnelSql(cohortSql, "x", "db", ACQ), // ctdna: `classed`/`sides`/`ps` hold samples by kind; the outer SELECTs count them per gene, along the funnel, or per (detected, months, dead) - "ctdnaCountsSql": () => federation.ctdnaCountsSql(cohortSql, "x", "db", ["EGFR"], CTD, "v7.5", "driver"), - "ctdnaCountsSql:dated-panels": () => federation.ctdnaCountsSql(cohortSql, "x", "db", null, { ...CTD, plasmaPanels: ["ACCESS129"], withinDays: 90 }, null, "any"), - "ctdnaFunnelSql": () => federation.ctdnaFunnelSql(cohortSql, "x", "db", CTD, "v7.5", "driver"), + "ctdnaCountsSql": () => aggregates.ctdnaCountsSql(cohortSql, "x", "db", ["EGFR"], CTD, "v7.5", "driver"), + "ctdnaCountsSql:dated-panels": () => aggregates.ctdnaCountsSql(cohortSql, "x", "db", null, { ...CTD, plasmaPanels: ["ACCESS129"], withinDays: 90 }, null, "any"), + "ctdnaFunnelSql": () => aggregates.ctdnaFunnelSql(cohortSql, "x", "db", CTD, "v7.5", "driver"), // exclusivity: `sp`/`altc`/`per` hold samples; the outer SELECTs count them per (panel signature, stratum, burden, mask) or per gene - "exclusivityGroupsSql": () => federation.exclusivityGroupsSql(cohortSql, "x", "db", ["KRAS", "EGFR"], "CANCER_TYPE", "v7.5", "driver"), - "exclusivityGroupsSql:any": () => federation.exclusivityGroupsSql(cohortSql, "x", "db", ["KRAS", "EGFR", "TP53"], null, null, "any"), - "exclusivityColumnsSql": () => federation.exclusivityColumnsSql(cohortSql, "x", "db", null, null, "any"), - "exclusivityCoverageSql": () => federation.exclusivityCoverageSql(cohortSql, "x", "db"), + "exclusivityGroupsSql": () => aggregates.exclusivityGroupsSql(cohortSql, "x", "db", ["KRAS", "EGFR"], "CANCER_TYPE", "v7.5", "driver"), + "exclusivityGroupsSql:any": () => aggregates.exclusivityGroupsSql(cohortSql, "x", "db", ["KRAS", "EGFR", "TP53"], null, null, "any"), + "exclusivityColumnsSql": () => aggregates.exclusivityColumnsSql(cohortSql, "x", "db", null, null, "any"), + "exclusivityCoverageSql": () => aggregates.exclusivityCoverageSql(cohortSql, "x", "db"), // instability: `parts`/`arms`/`calls`/`per` hold samples' segments and calls; the outer SELECTs are histograms and per-arm counts - "instabilityScoresSql": () => federation.instabilityScoresSql(cohortSql, "x", "db", { threshold: 0.2, armShare: 0.5, includeX: false }), - "instabilityArmsSql": () => federation.instabilityArmsSql(cohortSql, "x", "db", { threshold: 0.3, armShare: 0.8, includeX: true }), - "ctdnaCountsSql:ch": () => federation.ctdnaCountsSql(cohortSql, "x", "db", ["EGFR"], { ...CTD, ch: { genes: ["DNMT3A", "TET2"], normalMinAlt: 2, normalMinRatio: 0.3 } }, "v7.5", "driver"), - "ctdnaFunnelSql:ch-genes-only": () => federation.ctdnaFunnelSql(cohortSql, "x", "db", { ...CTD, ch: { genes: ["DNMT3A"], normalMinAlt: null, normalMinRatio: 0.3 } }, null, "any"), - "ctdnaChRemovedSql": () => federation.ctdnaChRemovedSql(cohortSql, "x", "db", { ...CTD, ch: { genes: ["DNMT3A"], normalMinAlt: 2, normalMinRatio: 0.3 } }), - "ctdnaSurvivalSql": () => federation.ctdnaSurvivalSql(cohortSql, "x", "db", { ...CTD, withinDays: 30 }, null, "any"), + "instabilityScoresSql": () => aggregates.instabilityScoresSql(cohortSql, "x", "db", { threshold: 0.2, armShare: 0.5, includeX: false }), + "instabilityArmsSql": () => aggregates.instabilityArmsSql(cohortSql, "x", "db", { threshold: 0.3, armShare: 0.8, includeX: true }), + "ctdnaCountsSql:ch": () => aggregates.ctdnaCountsSql(cohortSql, "x", "db", ["EGFR"], { ...CTD, ch: { genes: ["DNMT3A", "TET2"], normalMinAlt: 2, normalMinRatio: 0.3 } }, "v7.5", "driver"), + "ctdnaFunnelSql:ch-genes-only": () => aggregates.ctdnaFunnelSql(cohortSql, "x", "db", { ...CTD, ch: { genes: ["DNMT3A"], normalMinAlt: null, normalMinRatio: 0.3 } }, null, "any"), + "ctdnaChRemovedSql": () => aggregates.ctdnaChRemovedSql(cohortSql, "x", "db", { ...CTD, ch: { genes: ["DNMT3A"], normalMinAlt: 2, normalMinRatio: 0.3 } }), + "ctdnaSurvivalSql": () => aggregates.ctdnaSurvivalSql(cohortSql, "x", "db", { ...CTD, withinDays: 30 }, null, "any"), "sampleAttributeValuesSql": () => federation.sampleAttributeValuesSql(cohortSql, "x", "db", "SAMPLE_TYPE"), // clonality: `calls` is per (gene, sample); only per-gene counts and CCF bins leave - "clonalityGenesSql": () => federation.clonalityGenesSql(cohortSql, "x", "db", ["TP53"], "v7.5", "driver", true), - "clonalityGenesSql:any-noqc": () => federation.clonalityGenesSql(cohortSql, "x", "db", ["TP53", "KRAS"], null, "any", false), - "facetsSummarySql": () => federation.facetsSummarySql(cohortSql, "x", "db", true), - "facetsHistogramSql": () => federation.facetsHistogramSql(cohortSql, "x", "db", false), + "clonalityGenesSql": () => aggregates.clonalityGenesSql(cohortSql, "x", "db", ["TP53"], "v7.5", "driver", true), + "clonalityGenesSql:any-noqc": () => aggregates.clonalityGenesSql(cohortSql, "x", "db", ["TP53", "KRAS"], null, "any", false), + "facetsSummarySql": () => aggregates.facetsSummarySql(cohortSql, "x", "db", true), + "facetsHistogramSql": () => aggregates.facetsHistogramSql(cohortSql, "x", "db", false), "ascnStudiesSql": () => federation.ascnStudiesSql("x", "db"), // incidence: `per`/`cls` are per patient; the outer SELECT groups them into (kind, entry, t, cause) tallies "incidenceCountsSql": () => federation.incidenceCountsSql(cohortSql, "x", "db", { eventType: "Diagnosis", attrs: [{ key: "TUMOR_SITE", value: "CNS/Brain" }] }, null), @@ -418,85 +430,85 @@ describe("federation SQL returns aggregates, never identifiers", () => { progression: { eventType: "Diagnosis", key: "PROGRESSION", values: ["Y", "Yes"], probabilityKey: "NLP_PROGRESSION_PROBABILITY", minProbability: 0.8 } }), "txEndpointCountsSql:os": () => federation.txEndpointCountsSql(cohortSql, "x", "db", { endpoint: "os", anchor: { eventType: "Treatment", key: "SUBTYPE", value: "Immuno" }, lines: { gap: 90 } }), // multistate: `per`/`cls` are per patient; the outer SELECT groups them into (prevalent, tp, te, dead, flags) tallies - "multistateCountsSql": () => federation.multistateCountsSql(cohortSql, "x", "db", { eventType: "Diagnosis", key: "PROGRESSION", values: ["Y", "Yes"], probabilityKey: "NLP_PROGRESSION_PROBABILITY", minProbability: 0.8 }), + "multistateCountsSql": () => aggregates.multistateCountsSql(cohortSql, "x", "db", { eventType: "Diagnosis", key: "PROGRESSION", values: ["Y", "Yes"], probabilityKey: "NLP_PROGRESSION_PROBABILITY", minProbability: 0.8 }), // labs: per-patient % changes stay inside; the course and best-response queries return histograms - "labCourseSql": () => federation.labCourseSql(cohortSql, "x", "db", LAB, LAB_ANCHOR, LAB_OPTS), - "labBestSql": () => federation.labBestSql(cohortSql, "x", "db", LAB, LAB_ANCHOR, LAB_OPTS, 50), + "labCourseSql": () => aggregates.labCourseSql(cohortSql, "x", "db", LAB, LAB_ANCHOR, LAB_OPTS), + "labBestSql": () => aggregates.labBestSql(cohortSql, "x", "db", LAB, LAB_ANCHOR, LAB_OPTS, 50), // one row per patient — its best % change — but no identifier; run only by PatientLevelResolver.viewRows - "labWaterfallSql": () => federation.labWaterfallSql(cohortSql, "x", "db", LAB, LAB_ANCHOR, LAB_OPTS, 5000), + "labWaterfallSql": () => aggregates.labWaterfallSql(cohortSql, "x", "db", LAB, LAB_ANCHOR, LAB_OPTS, 5000), // run only by PatientLevelResolver.viewRows: one row per patient, values only "riskmodelRowsSql": () => federation.riskmodelRowsSql(cohortSql, "x", "db", { endpoint: "OS", strata: "CANCER_TYPE", driverPin: "v7", covariates: ["AGE", "STAGE"], anchor: { eventType: "Treatment", key: "SUBTYPE", value: "Immuno" } }), // run only by PatientLevelResolver.viewRows: one row per patient, panels, genes and covariate values only - "adjustedRowsSql": () => federation.adjustedRowsSql(cohortSql, "x", "db", { covariates: ["MSI_TYPE", "TMB_NONSYNONYMOUS"], driverPin: "v7.5" }), - "adjustedRowsSql:no-covariates": () => federation.adjustedRowsSql(cohortSql, "x", "db", { covariates: [] }), + "adjustedRowsSql": () => aggregates.adjustedRowsSql(cohortSql, "x", "db", { covariates: ["MSI_TYPE", "TMB_NONSYNONYMOUS"], driverPin: "v7.5" }), + "adjustedRowsSql:no-covariates": () => aggregates.adjustedRowsSql(cohortSql, "x", "db", { covariates: [] }), // actionability: per-sample and per-patient levels stay in CTEs; patients are counted per level - "actionLevelsSql": () => federation.actionLevelsSql(cohortSql, "x", "db", "v7.5", "CANCER_TYPE"), - "actionLevelsSql:ungrouped": () => federation.actionLevelsSql(cohortSql, "x", "db", "v7.5", null), - "actionConcordanceSql": () => federation.actionConcordanceSql(cohortSql, "x", "db", "v7.5", { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }), - "actionStoreTypesSql": () => federation.actionStoreTypesSql("x", "db", "v7.5"), + "actionLevelsSql": () => aggregates.actionLevelsSql(cohortSql, "x", "db", "v7.5", "CANCER_TYPE"), + "actionLevelsSql:ungrouped": () => aggregates.actionLevelsSql(cohortSql, "x", "db", "v7.5", null), + "actionConcordanceSql": () => aggregates.actionConcordanceSql(cohortSql, "x", "db", "v7.5", { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }), + "actionStoreTypesSql": () => aggregates.actionStoreTypesSql("x", "db", "v7.5"), // drivernegative: per-patient groups stay in CTEs; patients are counted per group and gene - "dnegGroupsSql": () => federation.dnegGroupsSql(cohortSql, "x", "db", "v7.5", ["EGFR", "KRAS", "ALK"]), - "dnegAlteredSql": () => federation.dnegAlteredSql(cohortSql, "x", "db", "v7.5", ["EGFR", "KRAS"], "mutation", false), - "dnegAlteredSql:sv-drivers": () => federation.dnegAlteredSql(cohortSql, "x", "db", "v7.5", ["EGFR"], "sv", true), - "dnegProfiledSql": () => federation.dnegProfiledSql(cohortSql, "x", "db", "v7.5", ["EGFR", "KRAS"], "mutation"), + "dnegGroupsSql": () => aggregates.dnegGroupsSql(cohortSql, "x", "db", "v7.5", ["EGFR", "KRAS", "ALK"]), + "dnegAlteredSql": () => aggregates.dnegAlteredSql(cohortSql, "x", "db", "v7.5", ["EGFR", "KRAS"], "mutation", false), + "dnegAlteredSql:sv-drivers": () => aggregates.dnegAlteredSql(cohortSql, "x", "db", "v7.5", ["EGFR"], "sv", true), + "dnegProfiledSql": () => aggregates.dnegProfiledSql(cohortSql, "x", "db", "v7.5", ["EGFR", "KRAS"], "mutation"), // organotropism: per-patient sites, panels and alterations stay in CTEs; the outer SELECTs count - "orgSourcesSql": () => federation.orgSourcesSql(cohortSql, "x", "db", { prefix: "DMETS_DX_", eventType: "Diagnosis", key: "TUMOR_SITE", attribute: "METASTATIC_SITE" }), - "orgSitesSql:flags": () => federation.orgSitesSql(cohortSql, "x", "db", { kind: "flags", prefix: "DMETS_DX_" }), - "orgSitesSql:timeline": () => federation.orgSitesSql(cohortSql, "x", "db", { kind: "timeline", eventType: "Diagnosis", key: "TUMOR_SITE" }), - "orgSitesSql:sample": () => federation.orgSitesSql(cohortSql, "x", "db", { kind: "sample", attribute: "METASTATIC_SITE" }), - "orgOtherValuesSql": () => federation.orgOtherValuesSql(cohortSql, "x", "db", "METASTATIC_SITE"), - "orgTopGenesSql": () => federation.orgTopGenesSql(cohortSql, "x", "db", { alteration: "mutation", driverPin: "v7.5" }, 12), - "orgCountsSql": () => federation.orgCountsSql(cohortSql, "x", "db", { kind: "timeline", eventType: "Diagnosis", key: "TUMOR_SITE" }, { genes: ["EGFR", "TP53"], alteration: "mutation", driverPin: null }, "CANCER_TYPE"), + "orgSourcesSql": () => aggregates.orgSourcesSql(cohortSql, "x", "db", { prefix: "DMETS_DX_", eventType: "Diagnosis", key: "TUMOR_SITE", attribute: "METASTATIC_SITE" }), + "orgSitesSql:flags": () => aggregates.orgSitesSql(cohortSql, "x", "db", { kind: "flags", prefix: "DMETS_DX_" }), + "orgSitesSql:timeline": () => aggregates.orgSitesSql(cohortSql, "x", "db", { kind: "timeline", eventType: "Diagnosis", key: "TUMOR_SITE" }), + "orgSitesSql:sample": () => aggregates.orgSitesSql(cohortSql, "x", "db", { kind: "sample", attribute: "METASTATIC_SITE" }), + "orgOtherValuesSql": () => aggregates.orgOtherValuesSql(cohortSql, "x", "db", "METASTATIC_SITE"), + "orgTopGenesSql": () => aggregates.orgTopGenesSql(cohortSql, "x", "db", { alteration: "mutation", driverPin: "v7.5" }, 12), + "orgCountsSql": () => aggregates.orgCountsSql(cohortSql, "x", "db", { kind: "timeline", eventType: "Diagnosis", key: "TUMOR_SITE" }, { genes: ["EGFR", "TP53"], alteration: "mutation", driverPin: null }, "CANCER_TYPE"), // molsubtype: each patient's class stays in CTEs; patients are counted per class, group and survival time - "molCountsSql": () => federation.molCountsSql(cohortSql, "x", "db", { poleHotspots: ["P286R", "V411L"], msi: { attribute: "MSI_TYPE", high: ["Instable"], stable: ["Stable"] }, p53Pin: "v7.5", by: "ANCESTRY_LABEL" }), - "molCountsSql:score": () => federation.molCountsSql(cohortSql, "x", "db", { poleHotspots: ["P286R"], msi: { attribute: "MSI_SCORE", threshold: 10, stableBelow: 3 }, p53Pin: null, by: null }), - "molSurvivalSql": () => federation.molSurvivalSql(cohortSql, "x", "db", { poleHotspots: ["P286R"], msi: { attribute: "MSI_TYPE", high: ["Instable"], stable: ["Stable"] }, p53Pin: "v7.5", by: null }), - "molMsiAttributesSql": () => federation.molMsiAttributesSql(cohortSql, "x", "db"), + "molCountsSql": () => aggregates.molCountsSql(cohortSql, "x", "db", { poleHotspots: ["P286R", "V411L"], msi: { attribute: "MSI_TYPE", high: ["Instable"], stable: ["Stable"] }, p53Pin: "v7.5", by: "ANCESTRY_LABEL" }), + "molCountsSql:score": () => aggregates.molCountsSql(cohortSql, "x", "db", { poleHotspots: ["P286R"], msi: { attribute: "MSI_SCORE", threshold: 10, stableBelow: 3 }, p53Pin: null, by: null }), + "molSurvivalSql": () => aggregates.molSurvivalSql(cohortSql, "x", "db", { poleHotspots: ["P286R"], msi: { attribute: "MSI_TYPE", high: ["Instable"], stable: ["Stable"] }, p53Pin: "v7.5", by: null }), + "molMsiAttributesSql": () => aggregates.molMsiAttributesSql(cohortSql, "x", "db"), // germline: carriers, somatic hits and panels stay in CTEs; patients are counted per gene and combination - "germCarriersSql": () => federation.germCarriersSql(cohortSql, "x", "db"), - "germCountsSql": () => federation.germCountsSql(cohortSql, "x", "db", ["BRCA1", "BRCA2"]), - "germSurvivalSql": () => federation.germSurvivalSql(cohortSql, "x", "db", "BRCA2"), - "orgIncidenceSql": () => federation.orgIncidenceSql(cohortSql, "x", "db", { kind: "timeline", eventType: "Diagnosis", key: "TUMOR_SITE" }, { genes: ["EGFR"], alteration: "mutation", driverPin: "v7.5" }, true, "CNS/Brain"), + "germCarriersSql": () => aggregates.germCarriersSql(cohortSql, "x", "db"), + "germCountsSql": () => aggregates.germCountsSql(cohortSql, "x", "db", ["BRCA1", "BRCA2"]), + "germSurvivalSql": () => aggregates.germSurvivalSql(cohortSql, "x", "db", "BRCA2"), + "orgIncidenceSql": () => aggregates.orgIncidenceSql(cohortSql, "x", "db", { kind: "timeline", eventType: "Diagnosis", key: "TUMOR_SITE" }, { genes: ["EGFR"], alteration: "mutation", driverPin: "v7.5" }, true, "CNS/Brain"), "riskmodelPanelGenesSql": () => federation.riskmodelPanelGenesSql("x", "db"), // run only by PatientLevelResolver.viewRows: one row of values per patient, no identifier - "cutpointRowsSql": () => federation.cutpointRowsSql(cohortSql, "x", "db", { attribute: "TMB_NONSYNONYMOUS", months: "OS_MONTHS", status: "OS_STATUS" }), + "cutpointRowsSql": () => aggregates.cutpointRowsSql(cohortSql, "x", "db", { attribute: "TMB_NONSYNONYMOUS", months: "OS_MONTHS", status: "OS_STATUS" }), // run only by PatientLevelResolver.viewRows: one row of genotype, LOH, endpoint and covariate values per patient, no identifier - "hlaRowsSql": () => federation.hlaRowsSql(cohortSql, "x", "db", { loci: ["A", "B", "C"].map((l) => ({ first: ["HLA-" + l + "1"], second: ["HLA-" + l + "2"], pair: ["HLA_" + l] })), + "hlaRowsSql": () => aggregates.hlaRowsSql(cohortSql, "x", "db", { loci: ["A", "B", "C"].map((l) => ({ first: ["HLA-" + l + "1"], second: ["HLA-" + l + "2"], pair: ["HLA_" + l] })), fields: 2, lohAttributes: ["HLA_A1_LOH"], lohValues: ["Loss"], months: "OS_MONTHS", status: "OS_STATUS", covariates: ["TMB_SCORE", "CANCER_TYPE"] }), - "hlaAttributesSql": () => federation.hlaAttributesSql(cohortSql, "x", "db"), - "cutpointRowsSql:stratified": () => federation.cutpointRowsSql(cohortSql, "x", "db", { attribute: "TMB_NONSYNONYMOUS", months: "OS_MONTHS", status: "OS_STATUS", stratum: "CANCER_TYPE" }), + "hlaAttributesSql": () => aggregates.hlaAttributesSql(cohortSql, "x", "db"), + "cutpointRowsSql:stratified": () => aggregates.cutpointRowsSql(cohortSql, "x", "db", { attribute: "TMB_NONSYNONYMOUS", months: "OS_MONTHS", status: "OS_STATUS", stratum: "CANCER_TYPE" }), "labTestsSql": () => federation.labTestsSql(cohortSql, "x", "db", "Lab_Test", "TEST"), // cancertypes: `alt`/`per`/`covered` hold samples; the outer SELECT counts them per group - "cancerTypesSql": () => federation.cancerTypesSql(cohortSql, "x", "db", ["KRAS", "NRAS"], "CANCER_TYPE", "v7.5", "driver"), - "cancerTypesSql:by-study-any": () => federation.cancerTypesSql(cohortSql, "x", "db", ["KRAS"], null, null, "any"), - "cancerTypeSamplesSql": () => federation.cancerTypeSamplesSql(cohortSql, "x", "db", "CANCER_TYPE"), - "cancerTypeSamplesSql:by-study": () => federation.cancerTypeSamplesSql(cohortSql, "x", "db", null), + "cancerTypesSql": () => aggregates.cancerTypesSql(cohortSql, "x", "db", ["KRAS", "NRAS"], "CANCER_TYPE", "v7.5", "driver"), + "cancerTypesSql:by-study-any": () => aggregates.cancerTypesSql(cohortSql, "x", "db", ["KRAS"], null, null, "any"), + "cancerTypeSamplesSql": () => aggregates.cancerTypeSamplesSql(cohortSql, "x", "db", "CANCER_TYPE"), + "cancerTypeSamplesSql:by-study": () => aggregates.cancerTypeSamplesSql(cohortSql, "x", "db", null), // overlap: per-patient membership bits stay inside; one count per bitmask leaves - "overlapCountsSql": () => federation.overlapCountsSql([cohortSql, cohortSql, cohortSql], "x", "db"), + "overlapCountsSql": () => aggregates.overlapCountsSql([cohortSql, cohortSql, cohortSql], "x", "db"), // enrichment: per-gene sample counts, and panel-level sample counts joined to genes - "enrichAlteredSql": () => federation.enrichAlteredSql(cohortSql, "x", "db", "mutation", "v7.5", true), - "enrichAlteredSql:cna-any": () => federation.enrichAlteredSql(cohortSql, "x", "db", "cna", null, false), - "enrichPanelProfiledSql": () => federation.enrichPanelProfiledSql(cohortSql, "x", "db", "amp"), - "enrichWesSql": () => federation.enrichWesSql(cohortSql, "x", "db", "sv"), + "enrichAlteredSql": () => aggregates.enrichAlteredSql(cohortSql, "x", "db", "mutation", "v7.5", true), + "enrichAlteredSql:cna-any": () => aggregates.enrichAlteredSql(cohortSql, "x", "db", "cna", null, false), + "enrichPanelProfiledSql": () => aggregates.enrichPanelProfiledSql(cohortSql, "x", "db", "amp"), + "enrichWesSql": () => aggregates.enrichWesSql(cohortSql, "x", "db", "sv"), // assays: per-entity moments or value counts; the rows CTE holds values, never ids, and stays inside - "assayMomentsSql": () => federation.assayMomentsSql(cohortSql, "x", "db", "mutational_signatures_contribution_v2"), - "assayValueCountsSql": () => federation.assayValueCountsSql(cohortSql, "x", "db", "armlevel_cna"), + "assayMomentsSql": () => aggregates.assayMomentsSql(cohortSql, "x", "db", "mutational_signatures_contribution_v2"), + "assayValueCountsSql": () => aggregates.assayValueCountsSql(cohortSql, "x", "db", "armlevel_cna"), "assayProfileSql": () => federation.assayProfileSql("x", "db", "armlevel_cna"), "assayEntityNamesSql": () => federation.assayEntityNamesSql("x", "db", "armlevel_cna"), // composition: each case's vector is built and summed in CTEs; one row of sums (and two counts) leaves - "compositionMomentsSql": () => federation.compositionMomentsSql(cohortSql, "x", "db", + "compositionMomentsSql": () => aggregates.compositionMomentsSql(cohortSql, "x", "db", { kind: "genericAssay", profileType: "cell_type_relative_counts", parts: ["T_cell", "B_cell", "Fibroblast"], unit: "sample", normalize: true }), - "compositionMomentsSql:clinical": () => federation.compositionMomentsSql(cohortSql, "x", "db", + "compositionMomentsSql:clinical": () => aggregates.compositionMomentsSql(cohortSql, "x", "db", { kind: "clinical", parts: ["custom:sc-states/CD8_EX", "custom:sc-states/CD8_NAIVE"], unit: "patient", normalize: false }), - "compositionEntitiesSql": () => federation.compositionEntitiesSql("x", "db", "cell_type_relative_counts"), + "compositionEntitiesSql": () => aggregates.compositionEntitiesSql("x", "db", "cell_type_relative_counts"), // correlates: per-patient values and their ranks stay in CTEs; twelve sums per gene leave "geneCytobandsSql": () => federation.geneCytobandsSql(["KRAS", "APC"], "x", "db"), - "correlatesMomentsSql": () => federation.correlatesMomentsSql(cohortSql, "x", "db", "ESR1", "rna_seq_v2_mrna", "rppa") + "correlatesMomentsSql": () => aggregates.correlatesMomentsSql(cohortSql, "x", "db", "ESR1", "rna_seq_v2_mrna", "rppa") }; test("every exported *Sql builder is covered by this suite", () => { - const exported = Object.entries(federation as Record) + const exported = Object.entries(aggregates as Record) .filter(([n, v]) => /Sql$/.test(n) && typeof v === "function").map(([n]) => n); assert.ok(exported.length >= 2, "expected to find the federation SQL builders; did the file move?"); const covered = new Set(Object.keys(BUILDERS).map((k) => k.split(":")[0])); @@ -737,7 +749,7 @@ describe("patient-level federation is opt-in, on both sides, per study and kind" }); test("the patient views ask a peer only through the kind they belong to", () => { - const read = (f: string) => readFileSync(new URL("../lib/views/" + f, import.meta.url), "utf8"); + const read = (f: string) => readFileSync(new URL("../lib/views/" + f.replace(/\.ts$/, "/run.ts"), import.meta.url), "utf8"); assert.match(read("timeline.ts"), /rowHolders\(ctx, "timeline"\)/); assert.match(read("alterations.ts"), /rowHolders\(ctx, "alterations"\)/); assert.doesNotMatch(read("timeline.ts"), /peerRowsFor\(\w+, "alterations"\)/); diff --git a/test/compare-alteration-types.test.ts b/test/compare-alteration-types.test.ts index 1067329..54944d5 100644 --- a/test/compare-alteration-types.test.ts +++ b/test/compare-alteration-types.test.ts @@ -3,7 +3,7 @@ // CNA-only gene as a false negative instead of refusing/disclosing it. import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { missingAlterationTypes, type AlterationType } from "../lib/views/compare.ts"; +import { missingAlterationTypes, type AlterationType } from "../lib/views/compare/run.ts"; const tc = (profiled: number, altered = 0) => ({ profiled, altered }); const cell = (mutation: ReturnType, cna: ReturnType, structuralVariant: ReturnType) => diff --git a/test/composition.test.ts b/test/composition.test.ts index f67a1d3..bc883e8 100644 --- a/test/composition.test.ts +++ b/test/composition.test.ts @@ -3,7 +3,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { partNames, validateSource, compositionCasesSql, partsSql, COMPOSITION_MAX_PARTS } from "../lib/composition.ts"; -import { compositionSvg } from "../lib/figures/composition.ts"; +import { compositionSvg } from "../lib/views/composition/figure.ts"; import { lintSvg } from "../lib/chart/lint.ts"; import type { DrawnResultOf } from "../lib/types.ts"; diff --git a/test/ctdna.test.ts b/test/ctdna.test.ts index 3e9f3fc..6ca386f 100644 --- a/test/ctdna.test.ts +++ b/test/ctdna.test.ts @@ -4,7 +4,7 @@ import { viewToSvg } from "../lib/view-svg.ts"; import { lintSvg } from "../lib/chart/lint.ts"; import { wilsonInterval } from "../lib/stats.ts"; import type { DrawnResultOf } from "../lib/types.ts"; -import { CH_GENES } from "../lib/views/ctdna.ts"; +import { CH_GENES } from "../lib/views/ctdna/run.ts"; describe("wilsonInterval", () => { test("matches statsmodels' proportion_confint(method='wilson')", () => { diff --git a/test/cutpoint.test.ts b/test/cutpoint.test.ts index d600aa7..165d07f 100644 --- a/test/cutpoint.test.ts +++ b/test/cutpoint.test.ts @@ -2,7 +2,7 @@ // with the ordinary log-rank test at the cut it picks. import { test } from "node:test"; import assert from "node:assert/strict"; -import { scanCutpoints, CUT_MAX_CANDIDATES, type CutRec } from "../lib/views/cutpoint.ts"; +import { scanCutpoints, CUT_MAX_CANDIDATES, type CutRec } from "../lib/views/cutpoint/run.ts"; import { seededRandom } from "../lib/stats.ts"; const cohort = (n: number, hazard: (v: number) => number, seed: number): CutRec[] => { diff --git a/test/drivernegative.test.ts b/test/drivernegative.test.ts index 17f7f0c..34b00d6 100644 --- a/test/drivernegative.test.ts +++ b/test/drivernegative.test.ts @@ -1,7 +1,7 @@ // The driver-negative view's per-gene tests, on fixed inputs. import { test } from "node:test"; import assert from "node:assert/strict"; -import { dnegTest } from "../lib/views/drivernegative.ts"; +import { dnegTest } from "../lib/views/drivernegative/run.ts"; import { fisherExact } from "../lib/stats.ts"; test("per gene: whole-exome patients join every gene's denominator, Fisher and BH, untested genes counted", () => { diff --git a/test/exclusivity.test.ts b/test/exclusivity.test.ts index 7f0d305..e0f9d21 100644 --- a/test/exclusivity.test.ts +++ b/test/exclusivity.test.ts @@ -1,7 +1,7 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { poissonBinomialTails } from "../lib/stats.ts"; -import { fitBackground } from "../lib/views/exclusivity.ts"; +import { fitBackground } from "../lib/views/exclusivity/run.ts"; import { viewToSvg } from "../lib/view-svg.ts"; import { lintSvg } from "../lib/chart/lint.ts"; import type { DrawnResultOf } from "../lib/types.ts"; diff --git a/test/figures-bars.test.ts b/test/figures-bars.test.ts index ef40e2c..7d06f74 100644 --- a/test/figures-bars.test.ts +++ b/test/figures-bars.test.ts @@ -3,10 +3,11 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { viewToSvg } from "../lib/view-svg.ts"; -import { chromLabels } from "../lib/figures/genome.ts"; +import { chromLabels } from "../lib/views/genome/figure.ts"; import { textWidth } from "../lib/chart/text.ts"; import { FS } from "../lib/chart/style.ts"; -import type { ViewResult, CompareCell } from "../lib/types.ts"; +import type { ViewResult } from "../lib/types.ts"; +import type { CompareCell } from "../lib/views/compare/types.ts"; const vb = (svg: string) => Number(/viewBox="0 0 (\d+)/.exec(svg)![1]); const cell = (cohort: string, pct: number | null): CompareCell => ({ cohort, profiled: 100, any: pct ?? 0, driver: 0, anyPct: pct, driverPct: null, diff --git a/test/figures-flows.test.ts b/test/figures-flows.test.ts index f8757b9..5ed8059 100644 --- a/test/figures-flows.test.ts +++ b/test/figures-flows.test.ts @@ -4,11 +4,11 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { viewToSvg } from "../lib/view-svg.ts"; -import { placeLabels } from "../lib/figures/sequence.ts"; -import { attritionColumns, clauseLines } from "../lib/figures/attrition.ts"; -import { upsetColumns, UPSET_MIN_COL } from "../lib/figures/overlap.ts"; +import { placeLabels } from "../lib/views/sequence/figure.ts"; +import { attritionColumns, clauseLines } from "../lib/views/attrition/figure.ts"; +import { upsetColumns, UPSET_MIN_COL } from "../lib/views/overlap/figure.ts"; import { packLines } from "../lib/chart/text.ts"; -import { tileLines } from "../lib/figures/pathways.ts"; +import { tileLines } from "../lib/views/pathways/figure.ts"; import { textWidth, FS } from "../lib/svg-kit.ts"; import type { ViewResult } from "../lib/types.ts"; diff --git a/test/figures-portal.test.ts b/test/figures-portal.test.ts index 6da05b7..749bb93 100644 --- a/test/figures-portal.test.ts +++ b/test/figures-portal.test.ts @@ -3,8 +3,9 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { viewToSvg, viewTitle } from "../lib/view-svg.ts"; -import { cancertypesPanelSvg } from "../lib/figures/cancertypes.ts"; -import type { ViewResult, CancertypesGroup } from "../lib/types.ts"; +import { cancertypesPanelSvg } from "../lib/views/cancertypes/figure.ts"; +import type { ViewResult } from "../lib/types.ts"; +import type { CancertypesGroup } from "../lib/views/cancertypes/types.ts"; const grp = (key: string, label: string, profiled: number, mutation: number, amp = 0, samples = profiled): CancertypesGroup => ({ key, label, samples, profiled, altered: mutation + amp, mutation, amp, homdel: 0, sv: 0, multiple: 0 }); diff --git a/test/figures-timecourse.test.ts b/test/figures-timecourse.test.ts index f36bb54..42daa92 100644 --- a/test/figures-timecourse.test.ts +++ b/test/figures-timecourse.test.ts @@ -4,11 +4,11 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { timeScale } from "../lib/chart/time.ts"; import { paragraph } from "../lib/chart/text.ts"; -import { incidenceSvg } from "../lib/figures/incidence.ts"; -import { riskmodelSvg } from "../lib/figures/riskmodel.ts"; +import { incidenceSvg } from "../lib/views/incidence/figure.ts"; +import { riskmodelSvg } from "../lib/views/riskmodel/figure.ts"; import { lintSvg } from "../lib/chart/lint.ts"; -import { labsSvg } from "../lib/figures/labs.ts"; -import { courseSvg } from "../lib/figures/course.ts"; +import { labsSvg } from "../lib/views/labs/figure.ts"; +import { courseSvg } from "../lib/views/course/figure.ts"; import { timelineSvg } from "../lib/timeline-svg.ts"; import { textWidth } from "../lib/svg-kit.ts"; import type { DrawnResultOf } from "../lib/types.ts"; diff --git a/test/figures-volcano.test.ts b/test/figures-volcano.test.ts index 3dd2aa7..b0ac3a3 100644 --- a/test/figures-volcano.test.ts +++ b/test/figures-volcano.test.ts @@ -1,4 +1,4 @@ -// The shared volcano's label placer (lib/figures/enrichment.ts): measured boxes, inside the plot, +// The shared volcano's label placer (lib/views/enrichment/figure.ts): measured boxes, inside the plot, // never on another label or a labelled point, and every label it cannot place reported. import { test, describe } from "node:test"; import assert from "node:assert/strict"; diff --git a/test/figures.test.ts b/test/figures.test.ts index cb8803d..6230aac 100644 --- a/test/figures.test.ts +++ b/test/figures.test.ts @@ -4,9 +4,10 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { viewToSvg, viewTitle } from "../lib/view-svg.ts"; import { wrap } from "../lib/figures/common.ts"; -import { defaultLog2 } from "../lib/views/diffexpr.ts"; +import { defaultLog2 } from "../lib/views/diffexpr/run.ts"; import { niceCeil } from "../lib/views/shared.ts"; -import type { ViewResult, GenomeResult } from "../lib/types.ts"; +import type { ViewResult } from "../lib/types.ts"; +import type { GenomeResult } from "../lib/views/genome/types.ts"; describe("wrap", () => { test("breaks on words within the width and keeps an over-long word whole", () => { diff --git a/test/forest.test.ts b/test/forest.test.ts index e9a0a49..2dd56c7 100644 --- a/test/forest.test.ts +++ b/test/forest.test.ts @@ -2,7 +2,7 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { dersimonianLaird } from "../lib/stats.ts"; -import { forestFit, type ForestPatient } from "../lib/views/forest.ts"; +import { forestFit, type ForestPatient } from "../lib/views/forest/run.ts"; const near = (a: number, b: number, tol = 1e-9) => assert.ok(Math.abs(a - b) < tol, a + " is not " + b); diff --git a/test/germline.test.ts b/test/germline.test.ts index 2ee1786..567ee38 100644 --- a/test/germline.test.ts +++ b/test/germline.test.ts @@ -1,7 +1,7 @@ // The germline view's per-gene counting, on fixed inputs. import { test } from "node:test"; import assert from "node:assert/strict"; -import { germAdd } from "../lib/views/germline.ts"; +import { germAdd } from "../lib/views/germline/run.ts"; const blank = () => ({ sequenced: 0, carriers: 0, outsidePanel: 0, carrierSomatic: 0, carrierCnaProfiled: 0, carrierDeletion: 0, carrierEither: 0, noncarriers: 0, noncarrierSomatic: 0, noncarrierCnaProfiled: 0, noncarrierDeletion: 0, noncarrierEither: 0 }); diff --git a/test/gsea-golden.test.ts b/test/gsea-golden.test.ts index db86aff..6e55ad2 100644 --- a/test/gsea-golden.test.ts +++ b/test/gsea-golden.test.ts @@ -5,7 +5,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { preRankedGsea } from "../lib/stats.ts"; -import { GSEA_NULL_PY } from "../lib/method.ts"; +import { GSEA_NULL_PY } from "../lib/views/diffexpr/method.ts"; const golden = JSON.parse(readFileSync("test/fixtures/golden/gsea.json", "utf8")); const res = preRankedGsea(golden.ranked, golden.sets, { permutations: golden.permutations }); diff --git a/test/hla.test.ts b/test/hla.test.ts index bc0b957..5729754 100644 --- a/test/hla.test.ts +++ b/test/hla.test.ts @@ -1,6 +1,6 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { hlaPanel, covariateColumns, type HlaRec } from "../lib/views/hla.ts"; +import { hlaPanel, covariateColumns, type HlaRec } from "../lib/views/hla/run.ts"; import { viewToSvg } from "../lib/view-svg.ts"; import { lintSvg } from "../lib/chart/lint.ts"; diff --git a/test/incidence-golden.test.ts b/test/incidence-golden.test.ts index 7946e8b..1f99555 100644 --- a/test/incidence-golden.test.ts +++ b/test/incidence-golden.test.ts @@ -6,7 +6,8 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { aalenJohansen, grayPermutationTest, type CrRecord } from "../lib/stats.ts"; -import { GRAY_NULL_PY, VERIFIED } from "../lib/method.ts"; +import { VERIFIED } from "../lib/method.ts"; +import { GRAY_NULL_PY } from "../lib/views/incidence/method.ts"; const golden = JSON.parse(readFileSync("test/fixtures/golden/incidence.json", "utf8")); const TOL = 1e-12; diff --git a/test/instability.test.ts b/test/instability.test.ts index bc7cdb2..781512f 100644 --- a/test/instability.test.ts +++ b/test/instability.test.ts @@ -1,9 +1,10 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { HG19_CENTROMERES, CHROM_LENGTHS, GENOME_CHROMS, instabilityArms } from "../lib/federation-views.ts"; +import { CHROM_LENGTHS, GENOME_CHROMS } from "../lib/federation-views.ts"; import { viewToSvg } from "../lib/view-svg.ts"; import { lintSvg } from "../lib/chart/lint.ts"; import type { DrawnResultOf } from "../lib/types.ts"; +import { instabilityArms, HG19_CENTROMERES } from "../lib/views/instability/sql.ts"; describe("instability arms", () => { test("39 autosomal arms (no acrocentric p arms), 41 with X; every centromere inside its chromosome", () => { diff --git a/test/mcp-views.test.ts b/test/mcp-views.test.ts index 1bf7f4e..9d5307d 100644 --- a/test/mcp-views.test.ts +++ b/test/mcp-views.test.ts @@ -11,19 +11,15 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { viewKinds } from "./support/view-kinds.ts"; const tools = readFileSync(new URL("../mcp/tools.ts", import.meta.url), "utf8"); // the per-view text lives in lib/result-text.ts, shared by run_analysis and the WebMCP tools const renderer = readFileSync(new URL("../lib/result-text.ts", import.meta.url), "utf8"); -const types = readFileSync(new URL("../lib/types.ts", import.meta.url), "utf8"); const reference = readFileSync(new URL("../lib/dsl-reference.ts", import.meta.url), "utf8"); -/** every kind in the View union, e.g. CompareView -> "compare" */ -function engineViewKinds(): string[] { - const m = /export type View = ([^;]+);/.exec(types); - assert.ok(m, "could not find the View union in lib/types.ts — did it move?"); - return [...new Set([...m![1].matchAll(/(\w+)View/g)].map((x) => x[1].toLowerCase()))].sort(); -} +/** every view kind the document language has (test/support/view-kinds.ts) */ +const engineViewKinds = viewKinds; /** the kinds run_analysis has a formatter branch for */ function viewLoop(): string { diff --git a/test/method.test.ts b/test/method.test.ts index 5078085..942380b 100644 --- a/test/method.test.ts +++ b/test/method.test.ts @@ -4,8 +4,27 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { coxMethod, logisticMethod, survivalMethod, compareMethod, cooccurrenceMethod, clinicalMethod, coexpressionMethod, diffexprMethod, incidenceMethod, pairedMethod, genomeMethod, pathwaysMethod, attritionMethod, clonalityMethod, labsMethod, courseMethod, sequenceMethod, GSEA_NULL_PY, GRAY_NULL_PY, INLINE_ROWS_MAX, type CoxTrace, type LogisticTrace, type SurvivalTrace } from "../lib/method.ts"; +import { INLINE_ROWS_MAX, type CoxTrace } from "../lib/method.ts"; import type { CoxCovariate } from "../lib/types.ts"; +import { attritionMethod } from "../lib/views/attrition/method.ts"; +import { clinicalMethod } from "../lib/views/clinical/method.ts"; +import { clonalityMethod } from "../lib/views/clonality/method.ts"; +import { coexpressionMethod } from "../lib/views/coexpression/method.ts"; +import { compareMethod } from "../lib/views/compare/method.ts"; +import { cooccurrenceMethod } from "../lib/views/cooccurrence/method.ts"; +import { courseMethod } from "../lib/views/course/method.ts"; +import { coxMethod } from "../lib/views/cox/method.ts"; +import { diffexprMethod, GSEA_NULL_PY } from "../lib/views/diffexpr/method.ts"; +import { genomeMethod } from "../lib/views/genome/method.ts"; +import { incidenceMethod, GRAY_NULL_PY } from "../lib/views/incidence/method.ts"; +import { labsMethod } from "../lib/views/labs/method.ts"; +import { logisticMethod } from "../lib/views/logistic/method.ts"; +import type { LogisticTrace } from "../lib/views/logistic/method.ts"; +import { pairedMethod } from "../lib/views/paired/method.ts"; +import { pathwaysMethod } from "../lib/views/pathways/method.ts"; +import { sequenceMethod } from "../lib/views/sequence/method.ts"; +import { survivalMethod } from "../lib/views/survival/method.ts"; +import type { SurvivalTrace } from "../lib/views/survival/method.ts"; const tp53: CoxCovariate = { source: "alteration", gene: "TP53", by: "mutated" }; const gender: CoxCovariate = { source: "clinical", attribute: "GENDER" }; diff --git a/test/molsubtype.test.ts b/test/molsubtype.test.ts index c1c9a08..19a5641 100644 --- a/test/molsubtype.test.ts +++ b/test/molsubtype.test.ts @@ -1,7 +1,7 @@ // The molecular-subtype view's group pooling, on fixed inputs. import { test } from "node:test"; import assert from "node:assert/strict"; -import { molTopGroups, POLE_HOTSPOTS } from "../lib/views/molsubtype.ts"; +import { molTopGroups, POLE_HOTSPOTS } from "../lib/views/molsubtype/run.ts"; const g = (counts: number[], nc = 0): { counts: number[]; classified: number; notClassifiable: number; why: Record } => ({ counts, classified: counts.reduce((a, b) => a + b, 0), notClassifiable: nc, why: nc ? { "no MSI call": nc } : {} }); diff --git a/test/organotropism.test.ts b/test/organotropism.test.ts index 77ebbb8..2640db1 100644 --- a/test/organotropism.test.ts +++ b/test/organotropism.test.ts @@ -1,7 +1,7 @@ // The organotropism view's per-cell test, on fixed inputs. import { test } from "node:test"; import assert from "node:assert/strict"; -import { orgCell, flagLabel } from "../lib/views/organotropism.ts"; +import { orgCell, flagLabel } from "../lib/views/organotropism/run.ts"; import { cochranMantelHaenszel } from "../lib/stats.ts"; test("a cell's strata become 2×2 tables, pooled for display and tested by CMH", () => { diff --git a/test/plot-layers.test.ts b/test/plot-layers.test.ts index 07de513..677d567 100644 --- a/test/plot-layers.test.ts +++ b/test/plot-layers.test.ts @@ -4,7 +4,7 @@ // calls, so the test can say what was NOT asked. import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { runPlot } from "../lib/views/plot.ts"; +import { runPlot } from "../lib/views/plot/run.ts"; import { highlightCohortSql, plotHighlightCountSql, plotScatterPointsSql, plotRankedPointsSql, axisSelect, POINTS_CAP, LAYER_COLORS } from "../lib/plot.ts"; import type { ViewRun, EvaluatedCohort } from "../lib/views/run.ts"; import type { PlotData, ViewResultOf } from "../lib/types.ts"; diff --git a/test/plot-points.test.ts b/test/plot-points.test.ts index 6f10252..3e78843 100644 --- a/test/plot-points.test.ts +++ b/test/plot-points.test.ts @@ -4,7 +4,7 @@ // what was asked of whom, and what was NOT. import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { runPlot } from "../lib/views/plot.ts"; +import { runPlot } from "../lib/views/plot/run.ts"; import { POINTS_CAP, OUTLIER_CELL_MAX, sparseCellMax } from "../lib/plot.ts"; import type { ViewRun, EvaluatedCohort } from "../lib/views/run.ts"; import type { PlotData, ViewResultOf } from "../lib/types.ts"; diff --git a/test/predictive.test.ts b/test/predictive.test.ts index 99f95a9..f464ede 100644 --- a/test/predictive.test.ts +++ b/test/predictive.test.ts @@ -1,7 +1,7 @@ // The predictive view's per-gene fit: the two contrasts and the interaction are one model's. import { test } from "node:test"; import assert from "node:assert/strict"; -import { fitPredictiveGene } from "../lib/views/predictive.ts"; +import { fitPredictiveGene } from "../lib/views/predictive/run.ts"; import { seededRandom } from "../lib/stats.ts"; import { viewToSvg } from "../lib/view-svg.ts"; import { lintSvg } from "../lib/chart/lint.ts"; diff --git a/test/riskmodel-groups.test.ts b/test/riskmodel-groups.test.ts index 197394e..3a5f818 100644 --- a/test/riskmodel-groups.test.ts +++ b/test/riskmodel-groups.test.ts @@ -1,10 +1,11 @@ -// The risk model's group step (lib/views/riskmodel.ts groupRiskmodel): how held-out scores become +// The risk model's group step (lib/views/riskmodel/run.ts groupRiskmodel): how held-out scores become // risk groups, from a fit alone. Synthetic fits, so each rule is checked on numbers it can be read off. import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { groupRiskmodel as groupAt, groupQuantiles, riskFitSpec, riskModelView, RISK_PRESENTATION_KEYS, type RiskFit } from "../lib/views/riskmodel.ts"; -import type { RiskmodelView } from "../lib/types.ts"; -import { cutLandsAt } from "../lib/figures/riskmodel.ts"; +import { groupRiskmodel as groupAt, groupQuantiles, riskFitSpec, riskModelView, RISK_PRESENTATION_KEYS, type RiskFit } from "../lib/views/riskmodel/run.ts"; + +import { cutLandsAt } from "../lib/views/riskmodel/figure.ts"; +import type { RiskmodelView } from "../lib/views/riskmodel/types.ts"; /** A fit over these held-out scores (null: never held out): survival times 1…n, every third an event. */ function fitOf(heldOut: (number | null)[], o: { validation?: number[]; external?: (number | null)[] } = {}): RiskFit { diff --git a/test/riskmodel-table1.test.ts b/test/riskmodel-table1.test.ts index 9dd99fd..2e2b794 100644 --- a/test/riskmodel-table1.test.ts +++ b/test/riskmodel-table1.test.ts @@ -1,13 +1,13 @@ -// Table 1 of the risk model (lib/views/riskmodel-table1.ts buildTable1, lib/views/riskmodel.ts riskTable1): +// Table 1 of the risk model (lib/views/riskmodel/table1.ts buildTable1, lib/views/riskmodel/run.ts riskTable1): // each risk group's makeup, counted from patients by position. Synthetic patients, so every count // can be read off — above all the panel-aware denominator: a patient whose panels do not cover a // gene is not profiled for it, never unaltered. import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { buildTable1, table1DefaultCandidates, TABLE1_DEFAULT_ATTRIBUTES, TABLE1_DEFAULT_MAX_VALUES, type Table1Input, type Table1Patient } from "../lib/views/riskmodel-table1.ts"; -import { riskTable1, RiskRefusal, type RiskFit, type RiskGrouping, type RiskFitArgs } from "../lib/views/riskmodel.ts"; +import { buildTable1, table1DefaultCandidates, TABLE1_DEFAULT_ATTRIBUTES, TABLE1_DEFAULT_MAX_VALUES, type Table1Input, type Table1Patient } from "../lib/views/riskmodel/table1.ts"; +import { riskTable1, RiskRefusal, type RiskFit, type RiskGrouping, type RiskFitArgs } from "../lib/views/riskmodel/run.ts"; import { chiSquareTest } from "../lib/stats.ts"; -import type { RiskTable1Row } from "../lib/types.ts"; +import type { RiskTable1Row } from "../lib/views/riskmodel/types.ts"; const patient = (o: Partial = {}): Table1Patient => ({ panels: ["IMPACT468"], genes: [], d: [], r: [], u: [], ...o }); diff --git a/test/riskmodel-validate.test.ts b/test/riskmodel-validate.test.ts index cbd2f2b..1611caf 100644 --- a/test/riskmodel-validate.test.ts +++ b/test/riskmodel-validate.test.ts @@ -2,8 +2,8 @@ // training thresholds, and a patient not covered for a model gene left out, never wild-type. import { test } from "node:test"; import assert from "node:assert/strict"; -import { riskExternal } from "../lib/views/riskmodel-validate.ts"; -import type { RiskFit, RiskGrouping, RiskFitArgs, RiskResolver } from "../lib/views/riskmodel.ts"; +import { riskExternal } from "../lib/views/riskmodel/validate.ts"; +import type { RiskFit, RiskGrouping, RiskFitArgs, RiskResolver } from "../lib/views/riskmodel/run.ts"; test("scores by selected × mean coefficient, cuts at the training threshold, leaves the uncovered out", async () => { const rows = [ diff --git a/test/rmst.test.ts b/test/rmst.test.ts index 4c07700..d9221d7 100644 --- a/test/rmst.test.ts +++ b/test/rmst.test.ts @@ -3,7 +3,7 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { rmst } from "../lib/stats.ts"; -import { fromLandmark, rmstContrast } from "../lib/views/rmst.ts"; +import { fromLandmark, rmstContrast } from "../lib/views/rmst/run.ts"; import { kaplanMeier } from "../lib/stats.ts"; import { viewToSvg } from "../lib/view-svg.ts"; import { lintSvg } from "../lib/chart/lint.ts"; diff --git a/test/snapshots/alterations.json b/test/snapshots/alterations.json index 17a1c46..e70f844 100644 --- a/test/snapshots/alterations.json +++ b/test/snapshots/alterations.json @@ -993,7 +993,7 @@ "countsSql", "rankSql" ], - "file": "lib/views/alterations.ts, lib/patient-alterations.ts, lib/table-query.ts", + "file": "lib/views/alterations/run.ts, lib/patient-alterations.ts, lib/table-query.ts", "commit": null }, "verification": "A listing: no statistic is computed, so there is no script — the queries are the numbers." diff --git a/test/snapshots/attrition.json b/test/snapshots/attrition.json index 229858a..11d040f 100644 --- a/test/snapshots/attrition.json +++ b/test/snapshots/attrition.json @@ -145,7 +145,7 @@ ], "implementation": { "functions": [], - "file": "lib/views/attrition.ts", + "file": "lib/views/attrition/run.ts", "commit": null } } diff --git a/test/snapshots/cancertypes.json b/test/snapshots/cancertypes.json index 5ccdd93..3203026 100644 --- a/test/snapshots/cancertypes.json +++ b/test/snapshots/cancertypes.json @@ -1553,7 +1553,7 @@ ], "implementation": { "functions": [], - "file": "lib/views/cancertypes.ts", + "file": "lib/views/cancertypes/run.ts", "commit": null } } diff --git a/test/snapshots/course.json b/test/snapshots/course.json index e12b69d..13ce53f 100644 --- a/test/snapshots/course.json +++ b/test/snapshots/course.json @@ -687,7 +687,7 @@ ], "implementation": { "functions": [], - "file": "lib/views/course.ts", + "file": "lib/views/course/run.ts", "commit": null }, "check": { diff --git a/test/snapshots/overlap.json b/test/snapshots/overlap.json index 3425ccb..81abd28 100644 --- a/test/snapshots/overlap.json +++ b/test/snapshots/overlap.json @@ -228,7 +228,7 @@ ], "implementation": { "functions": [], - "file": "lib/views/overlap.ts", + "file": "lib/views/overlap/run.ts", "commit": null } } diff --git a/test/snapshots/riskmodel.json b/test/snapshots/riskmodel.json index 52279e3..142262b 100644 --- a/test/snapshots/riskmodel.json +++ b/test/snapshots/riskmodel.json @@ -1158,7 +1158,7 @@ "quantilesFromHistogram", "benjaminiHochberg" ], - "file": "lib/coxnet.ts, lib/views/riskmodel-table1.ts, lib/stats.ts", + "file": "lib/coxnet.ts, lib/views/riskmodel/table1.ts, lib/stats.ts", "commit": null }, "verification": "lib/coxnet.ts's path is checked against scikit-survival's CoxnetSurvivalAnalysis, its delayed-entry, stratified fits (with standard errors) against statsmodels' PHReg and its C-index against lifelines, by test/coxnet-golden.test.ts (fixtures from python/golden/coxnet.py). The script below recomputes Table 1's tests and q-values from its counts (the model itself it does not refit); χ² matches scipy 1.18.1 chi2_contingency, Kruskal–Wallis and quantiles scipy/numpy, Benjamini–Hochberg statsmodels multipletests (test/tables-golden.test.ts, test/moments-golden.test.ts).", diff --git a/test/snapshots/sequence.json b/test/snapshots/sequence.json index 796240a..da8f22d 100644 --- a/test/snapshots/sequence.json +++ b/test/snapshots/sequence.json @@ -715,7 +715,7 @@ ], "implementation": { "functions": [], - "file": "lib/treatment-lines.ts, lib/views/sequence.ts", + "file": "lib/treatment-lines.ts, lib/views/sequence/run.ts", "commit": null }, "check": { diff --git a/test/snapshots/timeline.json b/test/snapshots/timeline.json index 8d62a60..be002c6 100644 --- a/test/snapshots/timeline.json +++ b/test/snapshots/timeline.json @@ -915,7 +915,7 @@ "countsSql", "rankSql" ], - "file": "lib/views/timeline.ts, lib/patient-timeline.ts, lib/timeline-tracks.ts, lib/table-query.ts", + "file": "lib/views/timeline/run.ts, lib/patient-timeline.ts, lib/timeline-tracks.ts, lib/table-query.ts", "commit": null }, "verification": "A listing and one patient's record: no statistic is computed, so there is no script — the queries are the numbers." @@ -2233,7 +2233,7 @@ "countsSql", "rankSql" ], - "file": "lib/views/alterations.ts, lib/patient-alterations.ts, lib/table-query.ts", + "file": "lib/views/alterations/run.ts, lib/patient-alterations.ts, lib/table-query.ts", "commit": null }, "verification": "A listing: no statistic is computed, so there is no script — the queries are the numbers." diff --git a/test/support/view-kinds.ts b/test/support/view-kinds.ts new file mode 100644 index 0000000..53664c1 --- /dev/null +++ b/test/support/view-kinds.ts @@ -0,0 +1,15 @@ +// Every view kind the document language has: each kind folder's lib/views//types.ts adds its +// entry to ViewSpecs, so the kinds are the keys those entries declare. Sorted. +import { existsSync, readFileSync, readdirSync } from "node:fs"; + +export function viewKinds(): string[] { + const dir = new URL("../../lib/views/", import.meta.url); + const kinds: string[] = []; + for (const d of readdirSync(dir)) { + const f = new URL(d + "/types.ts", dir); + if (!existsSync(f)) continue; + const m = /interface ViewSpecs \{\s*([a-z0-9_]+)\s*:/.exec(readFileSync(f, "utf8")); + if (m) kinds.push(m[1]); + } + return kinds.sort(); +} diff --git a/test/support/view-snapshots.ts b/test/support/view-snapshots.ts index 92a32a5..f7d6e80 100644 --- a/test/support/view-snapshots.ts +++ b/test/support/view-snapshots.ts @@ -51,6 +51,7 @@ const { viewToSvg } = await import("../../lib/view-svg.ts"); const { analysisText } = await import("../../lib/result-text.ts"); const { renderViewCards } = await import("./render-views.ts"); import type { AnalysisDoc, AnalysisResult } from "../../lib/types.ts"; +import { viewKinds } from "./view-kinds.ts"; /** * One case per view kind: the smallest gallery example that draws it (the oncoprint is pinned by @@ -255,10 +256,6 @@ export function snapshotCases(shard: number, of: number): [string, () => Promise /** That CASES covers every view kind; the first shard runs it. */ export function everyKindHasACase(): void { - const types = readFileSync(join(import.meta.dirname, "..", "..", "lib", "types.ts"), "utf8"); - const m = /export type View = ([^;]+);/.exec(types); - assert.ok(m, "could not find the View union in lib/types.ts — did it move?"); - const kinds = [...new Set([...m[1].matchAll(/(\w+)View/g)].map((x) => x[1].toLowerCase()))]; - assert.deepEqual(kinds.filter((k) => k !== "oncoprint" && !(k in CASES)), [], + assert.deepEqual(viewKinds().filter((k) => k !== "oncoprint" && !(k in CASES)), [], "add a case for each to CASES above (its smallest gallery example), then RECORD_SNAPSHOTS=1"); } diff --git a/test/view-help.test.ts b/test/view-help.test.ts index 47ace80..687ceb9 100644 --- a/test/view-help.test.ts +++ b/test/view-help.test.ts @@ -7,18 +7,14 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { VIEW_HELP } from "../lib/view-help.ts"; +import { viewKinds } from "./support/view-kinds.ts"; -const types = readFileSync(new URL("../lib/types.ts", import.meta.url), "utf8"); // the view cards (app/lab/view-cards.tsx) are where course and sequence draw their own headers const cards = readFileSync(new URL("../app/lab/view-cards.tsx", import.meta.url), "utf8"); const chrome = readFileSync(new URL("../app/lab/view-chrome.tsx", import.meta.url), "utf8"); -/** every kind in the View union, e.g. CompareView -> "compare" */ -function engineViewKinds(): string[] { - const m = /export type View = ([^;]+);/.exec(types); - assert.ok(m, "could not find the View union in lib/types.ts — did it move?"); - return [...new Set([...m![1].matchAll(/(\w+)View/g)].map((x) => x[1].toLowerCase()))].sort(); -} +/** every view kind the document language has (test/support/view-kinds.ts) */ +const engineViewKinds = viewKinds; describe("VIEW_HELP: every view kind has a plain-language explanation", () => { test("no view kind is missing an entry", () => { diff --git a/test/view-svg.test.ts b/test/view-svg.test.ts index 67bfe79..9ad0515 100644 --- a/test/view-svg.test.ts +++ b/test/view-svg.test.ts @@ -4,7 +4,7 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { viewToSvg, viewTitle, esc } from "../lib/view-svg.ts"; -import { PLOT_POINT_R } from "../lib/figures/plot.ts"; +import { PLOT_POINT_R } from "../lib/views/plot/figure.ts"; import { buildLollipopGene } from "../lib/lollipop.ts"; import type { DrawnResultOf } from "../lib/types.ts"; import { LAYER_BASE, LAYER_COLORS } from "../lib/plot-layers.ts"; From b60da3a22e9b1b7a70733a6d6b754420c304484a Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Fri, 23 Oct 2026 22:04:34 +0000 Subject: [PATCH 3/7] One descriptor per view kind replaces six per-kind lists; lint knows the folders Six places listed something about every kind: lib/view-svg.ts's figure and title chains, lib/figures/index.ts, lib/result-text.ts's fifty-branch text chain, VIEW_HELP, view-coverage's NEEDS, PREVIEW_KINDS and NEW_KINDS. Each kind now says all of it in lib/views//kind.ts (a ViewKindDef, lib/views/kind-def.ts: figure, title, text, help, need, preview, isNew), and lib/views/kinds.ts lists the descriptors, typed so a kind in the View union without one does not compile. Those six places read the registry; lib/figures/index.ts is gone. The pieces were cut out mechanically: each figure's "is there anything to draw" test, each title, each text branch (the same body, given what it used from the loop as a TextContext; one that had nothing to say for a result returns false and gets the same "no text rendering" line), each help text, need, preview note and NEW mark. table and mutations shared one text branch that switched on the kind; each now has its half. The older kinds' figure functions left lib/figures/bars.ts and forest.ts (now only what those figures share) and lib/view-svg.ts (the lollipop) for their own figure.ts; the table-row text helpers went to lib/views/text.ts. local/kind-boundary now reads the folders: a kind's files (lib/views//, and its card, app/lab/kinds/.tsx) import one another, the shared view modules and anything outside lib/views, never another kind's files or the registries; a shared view module (lib/views' top-level modules, lib/figures/) imports no kind. One documented exception: lib/views/run.ts's types for the riskmodel fitter the server hands in. mcp-views' source-text checks on the old chain now check the descriptors (every kind has text, nearly every text surfaces its caveat, an unknown kind still says it has no text). Verified: every view snapshot (SQL, result, figure, text, card HTML) and kinds.json byte-identical; npm test 1611/1611; tsc, eslint, knip clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CuBjiBEjYispDSEYDZDWM1 --- CLAUDE.md | 7 +- eslint.config.mjs | 42 +-- lib/figures/bars.ts | 165 +--------- lib/figures/forest.ts | 104 +------ lib/figures/index.ts | 187 ------------ lib/new-in-lab.ts | 12 +- lib/preview-kinds.ts | 24 +- lib/result-text.ts | 507 +------------------------------ lib/view-coverage.ts | 22 +- lib/view-help.ts | 198 +----------- lib/view-svg.ts | 56 +--- lib/views/acquired/kind.ts | 26 ++ lib/views/actionability/kind.ts | 26 ++ lib/views/adjusted/kind.ts | 25 ++ lib/views/alterations/kind.ts | 24 ++ lib/views/assays/kind.ts | 25 ++ lib/views/attrition/kind.ts | 20 ++ lib/views/cancertypes/kind.ts | 23 ++ lib/views/clinical/figure.ts | 28 ++ lib/views/clinical/kind.ts | 27 ++ lib/views/clonality/kind.ts | 25 ++ lib/views/coexpression/figure.ts | 24 ++ lib/views/coexpression/kind.ts | 20 ++ lib/views/compare/figure.ts | 80 +++++ lib/views/compare/kind.ts | 20 ++ lib/views/composition/kind.ts | 25 ++ lib/views/cooccurrence/figure.ts | 28 ++ lib/views/cooccurrence/kind.ts | 23 ++ lib/views/correlates/figure.ts | 1 + lib/views/correlates/kind.ts | 23 ++ lib/views/course/kind.ts | 25 ++ lib/views/cox/figure.ts | 15 + lib/views/cox/kind.ts | 32 ++ lib/views/ctdna/kind.ts | 33 ++ lib/views/cutpoint/kind.ts | 26 ++ lib/views/diffexpr/kind.ts | 26 ++ lib/views/drivernegative/kind.ts | 25 ++ lib/views/enrichment/figure.ts | 1 + lib/views/enrichment/kind.ts | 21 ++ lib/views/exclusivity/kind.ts | 25 ++ lib/views/forest/figure.ts | 82 +++++ lib/views/forest/kind.ts | 26 ++ lib/views/genome/kind.ts | 35 +++ lib/views/germline/kind.ts | 26 ++ lib/views/hla/kind.ts | 30 ++ lib/views/hotspots/figure.ts | 14 + lib/views/hotspots/kind.ts | 24 ++ lib/views/incidence/kind.ts | 24 ++ lib/views/instability/kind.ts | 25 ++ lib/views/kind-def.ts | 41 +++ lib/views/kinds.ts | 123 ++++++++ lib/views/labs/kind.ts | 24 ++ lib/views/logistic/figure.ts | 10 + lib/views/logistic/kind.ts | 22 ++ lib/views/molsubtype/kind.ts | 27 ++ lib/views/multistate/kind.ts | 27 ++ lib/views/mutations/kind.ts | 18 ++ lib/views/oncoprint/kind.ts | 25 ++ lib/views/organotropism/kind.ts | 29 ++ lib/views/overlap/kind.ts | 21 ++ lib/views/paired/kind.ts | 27 ++ lib/views/pathways/kind.ts | 24 ++ lib/views/plot/figure.ts | 1 + lib/views/plot/kind.ts | 46 +++ lib/views/predictive/kind.ts | 25 ++ lib/views/riskmodel/kind.ts | 38 +++ lib/views/rmst/kind.ts | 24 ++ lib/views/run.ts | 1 + lib/views/sequence/kind.ts | 29 ++ lib/views/survival/figure.ts | 1 + lib/views/survival/kind.ts | 20 ++ lib/views/table/kind.ts | 18 ++ lib/views/text.ts | 14 + lib/views/timeline/kind.ts | 28 ++ lib/views/txendpoints/kind.ts | 25 ++ test/mcp-views.test.ts | 44 ++- 76 files changed, 1792 insertions(+), 1272 deletions(-) delete mode 100644 lib/figures/index.ts create mode 100644 lib/views/acquired/kind.ts create mode 100644 lib/views/actionability/kind.ts create mode 100644 lib/views/adjusted/kind.ts create mode 100644 lib/views/alterations/kind.ts create mode 100644 lib/views/assays/kind.ts create mode 100644 lib/views/attrition/kind.ts create mode 100644 lib/views/cancertypes/kind.ts create mode 100644 lib/views/clinical/figure.ts create mode 100644 lib/views/clinical/kind.ts create mode 100644 lib/views/clonality/kind.ts create mode 100644 lib/views/coexpression/figure.ts create mode 100644 lib/views/coexpression/kind.ts create mode 100644 lib/views/compare/figure.ts create mode 100644 lib/views/compare/kind.ts create mode 100644 lib/views/composition/kind.ts create mode 100644 lib/views/cooccurrence/figure.ts create mode 100644 lib/views/cooccurrence/kind.ts create mode 100644 lib/views/correlates/kind.ts create mode 100644 lib/views/course/kind.ts create mode 100644 lib/views/cox/figure.ts create mode 100644 lib/views/cox/kind.ts create mode 100644 lib/views/ctdna/kind.ts create mode 100644 lib/views/cutpoint/kind.ts create mode 100644 lib/views/diffexpr/kind.ts create mode 100644 lib/views/drivernegative/kind.ts create mode 100644 lib/views/enrichment/kind.ts create mode 100644 lib/views/exclusivity/kind.ts create mode 100644 lib/views/forest/figure.ts create mode 100644 lib/views/forest/kind.ts create mode 100644 lib/views/genome/kind.ts create mode 100644 lib/views/germline/kind.ts create mode 100644 lib/views/hla/kind.ts create mode 100644 lib/views/hotspots/figure.ts create mode 100644 lib/views/hotspots/kind.ts create mode 100644 lib/views/incidence/kind.ts create mode 100644 lib/views/instability/kind.ts create mode 100644 lib/views/kind-def.ts create mode 100644 lib/views/kinds.ts create mode 100644 lib/views/labs/kind.ts create mode 100644 lib/views/logistic/figure.ts create mode 100644 lib/views/logistic/kind.ts create mode 100644 lib/views/molsubtype/kind.ts create mode 100644 lib/views/multistate/kind.ts create mode 100644 lib/views/mutations/kind.ts create mode 100644 lib/views/oncoprint/kind.ts create mode 100644 lib/views/organotropism/kind.ts create mode 100644 lib/views/overlap/kind.ts create mode 100644 lib/views/paired/kind.ts create mode 100644 lib/views/pathways/kind.ts create mode 100644 lib/views/plot/kind.ts create mode 100644 lib/views/predictive/kind.ts create mode 100644 lib/views/riskmodel/kind.ts create mode 100644 lib/views/rmst/kind.ts create mode 100644 lib/views/sequence/kind.ts create mode 100644 lib/views/survival/kind.ts create mode 100644 lib/views/table/kind.ts create mode 100644 lib/views/text.ts create mode 100644 lib/views/timeline/kind.ts create mode 100644 lib/views/txendpoints/kind.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5995ab3..845708d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,9 +71,10 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a **`lib/federation-views.ts`** (re-exported by `lib/federation.ts`, so the boundary suite sweeps it). Every local participant has those methods, a remote peer does not, so such a view is not drawn over a peer's studies (`lib/view-coverage.ts`, below invariant 10). - Figures: **`lib/figures/.ts`**, one module per kind (the later kinds registered with their - title in `lib/figures/index.ts`), dispatched by **`lib/view-svg.ts`**; all browser-safe, laid out - with **`lib/chart/`** (see "Figures" under UI conventions); cards: `app/lab/kind-cards.tsx`. + Figures: **`lib/views//figure.ts`**, drawn through the kind's descriptor (`kind.ts`, listed in + `lib/views/kinds.ts`) by **`lib/view-svg.ts`**; what several figures share is in `lib/figures/`; all + browser-safe, laid out with **`lib/chart/`** (see "Figures" under UI conventions); cards: + `app/lab/kind-cards.tsx`. - **Table views** (`table`, `mutations`, `timeline`'s patient list, `alterations`' list and three tables, and any later view that lists rows) — **every one must support the same five things: pagination, search over its text, a filter per column (text conditions or a value list for text, a range for numbers), sorting, and a CSV download of every diff --git a/eslint.config.mjs b/eslint.config.mjs index efcabc2..4e60600 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,31 +11,35 @@ import jsxA11y from "eslint-plugin-jsx-a11y-x"; import globals from "globals"; import path from "node:path"; -// View kinds stay independent: a view's runner (lib/views/.ts), its figure -// (lib/figures/.ts) and its own parts (-.ts) may import each other and the -// shared modules below, never another kind's modules, so a change to one view cannot break -// another. What two kinds need goes into a shared module, added here. -const SHARED_VIEW_MODULES = { - "lib/views": ["run", "shared", "rows", "covariates"], - "lib/figures": ["common", "volcano", "bars", "forest", "km"], -}; +// View kinds stay independent. Each kind's code is its folder, lib/views// (and its card, +// app/lab/kinds/.tsx): a kind's files import one another, the shared view modules below and +// anything outside lib/views, never another kind's files and never the registries that list every +// kind; a shared view module imports no kind at all. So a change to one view cannot break another, +// and what two kinds need goes into a shared module. +const SHARED_VIEW_MODULES = ["lib/views/run.ts", "lib/views/shared.ts", "lib/views/rows.ts", "lib/views/covariates.ts", + "lib/views/kind-def.ts", "lib/views/text.ts"]; +const VIEW_REGISTRIES = ["lib/views/index.ts", "lib/views/kinds.ts", "app/lab/kinds/index.ts"]; const kindBoundary = { meta: { type: "problem", schema: [] }, create(context) { const rel = (f) => path.relative(context.cwd, f).split(path.sep).join("/"); - const dirOf = (f) => Object.keys(SHARED_VIEW_MODULES).find((d) => path.posix.dirname(f) === d); - const from = rel(context.filename), fromDir = dirOf(from); - const base = (f) => path.posix.basename(f).replace(/\.tsx?$/, ""); - if (!fromDir || base(from) === "index" || SHARED_VIEW_MODULES[fromDir].includes(base(from))) return {}; - const kind = (f) => base(f).split("-")[0]; + const resolve = (f) => [f, f + ".ts", f + ".tsx"].find((x) => /\.tsx?$/.test(x)) && (/\.tsx?$/.test(f) ? f : f + ".ts"); + /** the kind a file belongs to, or null for shared code */ + const kindOf = (f) => (/^lib\/views\/([^/]+)\//.exec(f) || /^app\/lab\/kinds\/([^/.]+)(\/|\.tsx?$)/.exec(f) || [])[1] ?? null; + const from = rel(context.filename); + if (VIEW_REGISTRIES.includes(from)) return {}; + const fromKind = kindOf(from); + const sharedView = SHARED_VIEW_MODULES.includes(from) || /^lib\/figures\//.test(from); + if (!fromKind && !sharedView) return {}; const check = (node) => { const spec = node.source?.value; if (typeof spec !== "string" || !spec.startsWith(".")) return; - const to = rel(path.resolve(path.dirname(context.filename), spec)), toDir = dirOf(to); - if (!toDir || SHARED_VIEW_MODULES[toDir].includes(base(to)) || kind(to) === kind(from)) return; - context.report({ node: node.source, message: from + " imports " + to + ", another view kind's module. Move what both " + - "need into a shared module (" + Object.entries(SHARED_VIEW_MODULES).map(([d, ms]) => d + ": " + ms.join(", ")).join("; ") + - ") so a change to one view cannot break the other." }); + const to = resolve(rel(path.resolve(path.dirname(context.filename), spec))); + const toKind = kindOf(to); + const why = VIEW_REGISTRIES.includes(to) ? "the registry that lists every kind" + : toKind && toKind !== fromKind ? (fromKind ? "another view kind's code" : "one view kind's code, from a module every kind shares") : null; + if (why) context.report({ node: node.source, message: from + " imports " + to + ", " + why + ". Move what both need into a shared " + + "module (" + SHARED_VIEW_MODULES.join(", ") + ", lib/figures/) so a change to one view cannot break another." }); }; return { ImportDeclaration: check, ExportNamedDeclaration: check, ExportAllDeclaration: check, ImportExpression: check }; }, @@ -126,7 +130,7 @@ export default tseslint.config( }, }, { - files: ["lib/views/*.ts", "lib/figures/*.ts"], + files: ["lib/views/**/*.ts", "lib/figures/*.ts", "app/lab/kinds/**/*.tsx"], plugins: { local: { rules: { "kind-boundary": kindBoundary } } }, rules: { "local/kind-boundary": "error" }, }, diff --git a/lib/figures/bars.ts b/lib/figures/bars.ts index 0658db4..4c14d9d 100644 --- a/lib/figures/bars.ts +++ b/lib/figures/bars.ts @@ -1,27 +1,22 @@ -// Bar figures for the views the page shows as tables (compare, co-occurrence, co-expression, -// clinical): what MCP and `?figures=svg` get for them. Compare is grouped bars, one group per gene -// and one bar per cohort; the other three are one horizontal bar per row on a labelled value axis, -// signed about a zero line where the value has a direction. -import type { DrawnResultOf } from "../types.ts"; +// What the bar figures share — the views the page shows as tables (compare, co-occurrence, +// co-expression, clinical; each figure is lib/views//figure.ts), drawn for MCP and +// `?figures=svg`: compare as grouped bars, the other three as one horizontal bar per row on a +// labelled value axis, signed about a zero line where the value has a direction. import { esc, palette, frame, caption, linear, axisBottom, bottomAxisHeight, legend, label, textWidth, - seriesStyle, fmtNum, fmtPct, fmtQ, fmtInt, FS, SW, OTHER, SERIES, type SvgOpts, type LegendItem, type Scale + fmtInt, FS, SW, SERIES, type SvgOpts, type LegendItem, type Scale } from "../svg-kit.ts"; -import type { ClinicalRow } from "../views/clinical/types.ts"; -import type { CoexprRow } from "../views/coexpression/types.ts"; -import type { CompareRow } from "../views/compare/types.ts"; -import type { CooccurRow } from "../views/cooccurrence/types.ts"; type Pal = ReturnType; /** below this the plot column stops narrowing and the figure grows instead */ const MIN_PLOT = 160; -const ROW = 22, PAD = 8, GUT = 12; +export const ROW = 22, PAD = 8, GUT = 12; /** a direction's colour where the test is significant; the neutral grey where it is not */ -const UP = SERIES[0], DOWN = SERIES[1]; +export const UP = SERIES[0], DOWN = SERIES[1]; -interface HRow { +export interface HRow { label: string; /** null: nothing to draw (not tested, no value); ±Infinity: past the scale either way */ v: number | null; @@ -33,7 +28,7 @@ interface HRow { } /** The figure's title line at the top; returns its drawing and the y below it. */ -function titleLine(text: string, W: number, p: Pal): { svg: string; y: number } { +export function titleLine(text: string, W: number, p: Pal): { svg: string; y: number } { return { svg: label(PAD, 16, text, { size: FS.title, weight: 600, fill: p.ink, maxW: W - 2 * PAD }), y: 26 }; } @@ -47,7 +42,7 @@ function topTicks(s: Scale, fmt: (v: number) => string, y: number, p: Pal): stri * (the label column capped at a third of the figure, labels past it cut with their full text kept). * `signed` puts zero in the middle; values past `cap` (or infinite) end in an arrow at the edge. */ -function hbars(title: string, rows: HRow[], o: SvgOpts, a: { +export function hbars(title: string, rows: HRow[], o: SvgOpts, a: { signed: boolean; axisTitle: string; cap?: number; ref?: { at: number; text: string }; legend: LegendItem[]; note: string; fmtTick?: (v: number) => string; }): string { @@ -122,141 +117,5 @@ function hbars(title: string, rows: HRow[], o: SvgOpts, a: { } /** a note ending in a full stop, so a sentence appended to it reads as one */ -const sentence = (s: string) => s.trim() && !/[.!?)]$/.test(s.trim()) ? s.trim() + "." : s.trim(); -const r1 = (n: number) => Math.round(n * 10) / 10; - -export function cooccurrenceSvg(v: DrawnResultOf<"cooccurrence">, o: SvgOpts): string { - const pairs = (v.pairs || []) as CooccurRow[]; - let anyInf = false; - const rows = pairs.map((r): HRow => { - const lor = r.oddsRatioMH != null ? Math.log2(r.oddsRatioMH) : r.log2Odds; - if (lor != null && !Number.isFinite(lor)) anyInf = true; - const p = r.pStratified ?? r.p; - const sig = r.q < 0.05 && lor != null && lor !== 0; - return { - label: r.a + " / " + r.b, v: lor, fill: sig ? (lor! > 0 ? UP : DOWN) : OTHER, - text: (lor == null ? "–" : fmtNum(lor)) + " · " + fmtQ(r.q), - tip: r.a + " / " + r.b + ": both " + fmtInt(r.both) + ", " + r.a + " only " + fmtInt(r.onlyA) + ", " + r.b + " only " + fmtInt(r.onlyB) + - ", neither " + fmtInt(r.neither) + " · " + (r.pStratified != null ? "stratified " : "") + "p " + fmtQ(p).slice(2) - }; - }); - return hbars("Co-occurrence — log₂ odds ratio", rows, o, { - signed: true, axisTitle: "log₂ odds ratio — left: mutually exclusive, right: co-occurring", - legend: [{ label: "co-occur, q < 0.05", color: UP }, { label: "mutually exclusive, q < 0.05", color: DOWN }, { label: "not significant", color: OTHER }], - note: sentence(v.note || "") + (anyInf ? " An odds ratio of 0 or ∞ (a pair never or always seen together) is drawn to the edge of the axis." : "") - }); -} - -export function coexpressionSvg(v: DrawnResultOf<"coexpression">, o: SvgOpts): string { - const rows = (v.coexpression || []) as CoexprRow[]; - return hbars("Co-expression — Pearson r", rows.map((r): HRow => { - const rr = r.rMeta ?? r.r; - const sig = r.q < 0.05 && rr != null && rr !== 0; - return { - label: r.a + " / " + r.b, v: rr, fill: sig ? (rr! > 0 ? UP : DOWN) : OTHER, - text: (rr == null ? "r –" : "r = " + fmtNum(rr, 2)) + " · n = " + fmtInt(r.n) + " · " + fmtQ(r.q), - tip: r.a + " / " + r.b + (r.rMeta != null ? ": r combined across " + (r.sites ?? 0) + " sites" : "") + - (r.perStudy && r.perStudy.length > 1 ? "\n" + r.perStudy.map((s) => s.study + ": r " + (s.r == null ? "–" : fmtNum(s.r, 2)) + ", n " + fmtInt(s.n)).join("\n") : "") - }; - }), o, { - signed: true, axisTitle: "Pearson r — left: anti-correlated, right: correlated", - legend: [{ label: "positive, q < 0.05", color: UP }, { label: "negative, q < 0.05", color: DOWN }, { label: "not significant", color: OTHER }], - note: v.note || "" - }); -} - -/** −log₁₀ q past this runs off the axis as an arrow: q < 1e-50 is beyond any reading difference */ -const Q_CAP = 50; - -export function clinicalSvg(v: DrawnResultOf<"clinical">, o: SvgOpts): string { - const all = (v.clinical || []) as ClinicalRow[]; - const rows = all.map((r): HRow => { - if (r.skipped) return { label: r.attribute, v: null, fill: OTHER, text: "not tested: " + r.skipped, muted: true }; - const q = r.q; - return { - label: r.attribute, v: q == null ? null : q > 0 ? -Math.log10(q) : Infinity, - fill: q != null && q < 0.05 ? SERIES[0] : OTHER, - text: (r.test || "") + " · " + (q == null ? "q –" : fmtQ(q)) - }; - }); - return hbars("Clinical comparison — −log₁₀ q", rows, o, { - signed: false, axisTitle: "−log₁₀ q (Benjamini–Hochberg) — longer is stronger evidence of a difference", cap: Q_CAP, - ref: { at: -Math.log10(0.05), text: "q = 0.05" }, - legend: [{ label: "q < 0.05", color: SERIES[0] }, { label: "not significant", color: OTHER }], - note: v.note || "" - }); -} - -/** below this a column of groups is too narrow to read; more width adds columns */ -const COL_MIN = 340; - -/** - * Altered %, grouped by gene: one bar per cohort in each gene's group, the cohorts keyed once in - * a legend. Genes flow down columns as the width allows, every column on one shared scale. - */ -export function compareSvg(v: DrawnResultOf<"compare">, o: SvgOpts): string { - const p = palette(o.theme); - const rows = (v.rows || []) as CompareRow[]; - const names = v.cohorts && v.cohorts.length ? v.cohorts : (rows[0]?.cells || []).map((c) => c.cohort); - const k = Math.max(1, ...rows.map((r) => r.cells.length)); - let W = Math.max(360, o.width || 720); - const grown = W < COL_MIN + 2 * PAD; - if (grown) W = COL_MIN + 2 * PAD; - const title = "Altered %, by cohort"; - - const out: string[] = []; - const t = titleLine(title, W, p); - out.push(t.svg); - let y = t.y; - const items: LegendItem[] = names.map((n, i) => ({ label: n, color: seriesStyle(i).color, opacity: seriesStyle(i).tint })); - const lg = legend(items, PAD, y, W - 2 * PAD, p, { grid: names.length > 4 }); - out.push(lg.svg); - y += lg.height + 8; - - // one bar per cohort, a gap between genes; the gene's name and q head its group on the left - const BAR = k > 6 ? 10 : 12, GAP = 10; - const groupH = Math.max(k * BAR, FS.body + FS.small + 6) + GAP; - const cols = Math.max(1, Math.min(Math.ceil(rows.length / 4), Math.floor((W - 2 * PAD + GUT * 2) / (COL_MIN + GUT * 2)))); - const perCol = Math.ceil(rows.length / cols); - const colW = (W - 2 * PAD - (cols - 1) * GUT * 2) / cols; - const qText = (r: CompareRow) => r.qAny != null ? fmtQ(r.qAny) : ""; - const geneW = Math.max(...rows.map((r) => Math.max(textWidth(r.gene, FS.body, 600), textWidth(qText(r), FS.small))), 30); - const valW = textWidth("100.0%", FS.small) + 4; - const top = y, plotH = perCol * groupH; - const maxPct = Math.max(1, ...rows.flatMap((r) => r.cells.map((c) => c.anyPct ?? 0))); - let notProfiled = 0; - - for (let c = 0; c < cols; c++) { - const cx = PAD + c * (colW + GUT * 2); - const L = cx + geneW + GUT, R = cx + colW - valW; - const xs = linear(0, Math.min(100, maxPct), L, R); - for (const tk of xs.ticks) out.push(``); - rows.slice(c * perCol, (c + 1) * perCol).forEach((r, gi) => { - const gy = top + gi * groupH; - out.push(label(cx, gy + FS.body, r.gene, { size: FS.body, weight: 600, fill: p.ink, maxW: geneW })); - const q = qText(r); - if (q) out.push(label(cx, gy + FS.body + FS.small + 3, q, { size: FS.small, fill: r.qAny! < 0.05 ? p.ink : p.muted })); - r.cells.forEach((cell, ci) => { - const st = seriesStyle(ci), by = gy + ci * BAR; - const pct = cell.anyPct; - const tip = (names[ci] || cell.cohort) + " — " + r.gene + ": " + (pct == null ? "not profiled" : fmtPct(pct) + " (" + fmtInt(cell.any) + " of " + fmtInt(cell.profiled) + " profiled)"); - if (pct == null) { - notProfiled++; - out.push(label(L + 2, by + BAR / 2 + FS.small * 0.35, "not profiled", { size: FS.small, fill: p.muted })); - return; - } - const w = Math.max(pct > 0 ? 1 : 0, xs(pct) - L); - out.push(`${esc(tip)}`); - out.push(label(L + w + 3, by + BAR / 2 + FS.small * 0.35, fmtPct(pct), { size: FS.small, fill: p.muted })); - }); - }); - out.push(``); - out.push(axisBottom(xs, top + plotH, p, { title: "altered, % of profiled", minX: L - GUT, maxX: cx + colW, fmt: (x) => fmtNum(x) + "%" })); - } - y = top + plotH + bottomAxisHeight("altered") + 8; - const note = sentence(v.note || "") + (notProfiled ? " " + fmtInt(notProfiled) + " cohort–gene pair" + (notProfiled === 1 ? " was" : "s were") + " not profiled and " + (notProfiled === 1 ? "has" : "have") + " no bar." : "") + - (rows.some((r) => r.qAny != null) ? " The q under each gene compares its altered share across the cohorts." : ""); - const cap = caption(note, W, y, p.muted); - return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, grown, title + " — " + names.join(", ")); -} +export const sentence = (s: string) => s.trim() && !/[.!?)]$/.test(s.trim()) ? s.trim() + "." : s.trim(); +export const r1 = (n: number) => Math.round(n * 10) / 10; diff --git a/lib/figures/forest.ts b/lib/figures/forest.ts index 1dce37a..8686800 100644 --- a/lib/figures/forest.ts +++ b/lib/figures/forest.ts @@ -5,19 +5,17 @@ // glance. The scale is set by the estimates, not by the widest interval: a covariate that barely // converged can have an upper bound in the millions, which would otherwise squash every other row // to a dot. An interval running past the scale ends in an arrow, and the caption says so. -import type { DrawnResultOf } from "../types.ts"; import { palette, frame, caption, log, axisBottom, bottomAxisHeight, label, textWidth, fmtRatio, pDigits, FS, SW, SERIES, type SvgOpts } from "../svg-kit.ts"; -import type { ForestPooled } from "../views/forest/types.ts"; interface Coef { label: string; reference?: string; ciLow: number; ciHigh: number; ratio: number; p: number } -interface Panel { heading: string | null; coefs: Coef[] } +export interface Panel { heading: string | null; coefs: Coef[] } -const ROW = 22, HEAD = 24, GAP = 14; +export const ROW = 22, HEAD = 24, GAP = 14; -function forestSvg(panels: Panel[], W: number, theme: SvgOpts["theme"], ratioName: string, note: string, title: string): string { +export function forestSvg(panels: Panel[], W: number, theme: SvgOpts["theme"], ratioName: string, note: string, title: string): string { const p = palette(theme); const all = panels.flatMap((pn) => pn.coefs); const rowLabel = (c: Coef) => c.label + (c.reference ? " vs " + c.reference : ""); @@ -78,24 +76,7 @@ function forestSvg(panels: Panel[], W: number, theme: SvgOpts["theme"], ratioNam return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, false, title); } -const r1 = (n: number) => Math.round(n * 10) / 10; - -/** Cox: hazard ratios; with stratifyByCohort, one panel per cohort that fit */ -export function coxSvg(v: DrawnResultOf<"cox">, o: SvgOpts): string { - const byCohort = v.cox?.byCohort; - const panels: Panel[] = byCohort - ? byCohort.filter((c) => c.coefficients.length).map((c) => ({ - heading: c.cohort + " — n = " + c.n.toLocaleString("en-US") + ", " + c.events.toLocaleString("en-US") + " events", - coefs: c.coefficients.map((cf) => ({ ...cf, ratio: cf.hr })) })) - : [{ heading: null, coefs: (v.cox?.coefficients || []).map((cf) => ({ ...cf, ratio: cf.hr })) }]; - return forestSvg(panels, Math.max(520, o.width || 760), o.theme, "HR", v.caveat || "", "Cox regression — hazard ratios"); -} - -/** logistic: odds ratios, one panel */ -export function logisticSvg(v: DrawnResultOf<"logistic">, o: SvgOpts): string { - const panels: Panel[] = [{ heading: null, coefs: (v.logistic?.coefficients || []).map((cf) => ({ ...cf, ratio: cf.or })) }]; - return forestSvg(panels, Math.max(520, o.width || 760), o.theme, "OR", v.caveat || "", "Logistic regression — odds ratios"); -} +export const r1 = (n: number) => Math.round(n * 10) / 10; /** One row of a two-marker forest: a label, two ratios with their intervals (either may be missing), and a test's p (and q). */ export interface DualForestRow { @@ -170,80 +151,3 @@ export function dualForestSvg(rows: DualForestRow[], o: { width?: number; theme? " past the axis and " + (offScale === 1 ? "is" : "are") + " drawn as an arrow; the numbers beside it are exact." : ""), W, y, p.muted); return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, false, o.title); } - -/** - * The forest view: one row per stratum (n, events, HR [CI], its random-effects weight, the square - * sized by that weight), then the fixed- and random-effects pools as diamonds. - */ -export function metaForestSvg(v: DrawnResultOf<"forest">, o: SvgOpts): string { - const f = v.forest, W = Math.max(520, o.width || 760), p = palette(o.theme); - const pools: [string, ForestPooled][] = []; - if (f.random) pools.push(["Random effects (DerSimonian–Laird)", f.random]); - if (f.fixed) pools.push(["Fixed effect", f.fixed]); - const value = (x: { hr: number; ciLow: number; ciHigh: number }) => fmtRatio(x.hr) + " [" + fmtRatio(x.ciLow) + "–" + fmtRatio(x.ciHigh) + "]"; - const count = (r: { n: number; events: number }) => r.n.toLocaleString("en-US") + " (" + r.events.toLocaleString("en-US") + ")"; - const pct = (w: number) => (100 * w).toFixed(1) + "%"; - const labW = Math.min(W * 0.3, Math.max(textWidth(f.strata, FS.small), ...f.rows.map((r) => textWidth(r.label, FS.body)), - ...pools.map(([l]) => textWidth(l, FS.body, 600) * 0.75))); - const cntW = Math.max(textWidth("n (events)", FS.small), ...f.rows.map((r) => textWidth(count(r), FS.body))); - const valW = Math.max(textWidth("HR [95% CI]", FS.small), ...f.rows.map((x) => textWidth(value(x), FS.body)), ...pools.map(([, x]) => textWidth(value(x), FS.body, 600))); - const wW = pools.length ? Math.max(textWidth("weight / p", FS.small), textWidth("100.0%", FS.body), ...pools.map(([, x]) => textWidth(pDigits(x.p), FS.body))) : 0; - const cntR = 8 + labW + 12 + cntW, L = cntR + 16; - const wR = W - 8, valL = wR - (wW ? wW + 14 : 0) - valW, R = valL - 14; - const all = [...f.rows, ...pools.map(([, x]) => x)]; - const est = all.map((c) => c.hr).filter((r) => Number.isFinite(r) && r > 0); - const eLo = Math.min(1, ...est), eHi = Math.max(1, ...est); - const ci = all.flatMap((c) => [c.ciLow, c.ciHigh]).filter((r) => Number.isFinite(r) && r > 0).map((r) => Math.min(eHi * 4, Math.max(eLo / 4, r))); - const xs = log(Math.min(eLo, ...ci) * 0.9, Math.max(eHi, ...ci) * 1.1, L, R, { nice: false }); - const [d0, d1] = xs.domain; - const at = (r: number) => r1(xs(Math.min(d1, Math.max(d0, r)))); - let offScale = 0; - const out: string[] = []; - out.push(label(8, 14, f.strata, { size: FS.small, fill: p.muted })); - out.push(label(cntR, 14, "n (events)", { size: FS.small, fill: p.muted, anchor: "end" })); - out.push(label(valL, 14, "HR [95% CI]", { size: FS.small, fill: p.muted })); - if (wW) out.push(label(wR, 14, "weight / p", { size: FS.small, fill: p.muted, anchor: "end" })); - let y = HEAD; - const top = y, maxW = Math.max(...f.rows.map((r) => r.weight), 1e-9); - const interval = (lo: number, hi: number, cy: number) => { - const a = Math.max(lo, d0), b = Math.min(hi, d1); - if (b > a) out.push(``); - for (const [past, x, dir] of [[lo < d0, xs(d0), -1], [hi > d1, xs(d1), 1]] as [boolean, number, number][]) { - if (!past) continue; - offScale++; - out.push(``); - } - }; - f.rows.forEach((r, i) => { - const cy = y + ROW / 2, ty = cy + FS.body * 0.35; - if (i % 2 === 0) out.push(``); - out.push(label(8, ty, r.label, { size: FS.body, fill: p.ink, maxW: labW })); - out.push(label(cntR, ty, count(r), { size: FS.body, fill: p.ink, anchor: "end" })); - interval(r.ciLow, r.ciHigh, cy); - const s = pools.length ? 4 + 5 * Math.sqrt(r.weight / maxW) : 8; - out.push(``); - out.push(label(valL, ty, value(r), { size: FS.body, fill: p.ink })); - if (wW) out.push(label(wR, ty, pct(r.weight), { size: FS.body, fill: p.muted, anchor: "end" })); - y += ROW; - }); - if (pools.length) { - out.push(``); - y += 8; - for (const [name, x] of pools) { - const cy = y + ROW / 2, ty = cy + FS.body * 0.35; - out.push(label(8, ty, name, { size: FS.body, fill: p.ink, weight: 600, maxW: cntR - 8 })); - const a = at(x.ciLow), b = at(x.ciHigh), m = at(x.hr); - out.push(``); - out.push(label(valL, ty, value(x), { size: FS.body, fill: p.ink, weight: 600 })); - out.push(label(wR, ty, pDigits(x.p), { size: FS.body, fill: x.p < 0.05 ? p.ink : p.muted, anchor: "end" })); - y += ROW; - } - } - out.push(``); - const axTitle = "HR for " + f.exposedLabel + " (log scale) — right of 1 is worse " + f.endpoint; - out.push(axisBottom(xs, y + 4, p, { title: axTitle, minX: L - 20, maxX: R + 20 })); - y += 4 + bottomAxisHeight(axTitle) + 6; - const cap = caption((pools.length ? "Rows' last column is their weight in the random-effects pool; the pooled rows' is their p. " : "") + (v.caveat || "") + (offScale ? " " + offScale + " interval end" + (offScale === 1 ? " runs" : "s run") + " past the axis and " + - (offScale === 1 ? "is" : "are") + " drawn as an arrow; the numbers beside it are exact." : ""), W, y, p.muted); - return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, false, "Forest plot — " + f.exposedLabel + " by " + f.strata); -} diff --git a/lib/figures/index.ts b/lib/figures/index.ts deleted file mode 100644 index 1e7fbe6..0000000 --- a/lib/figures/index.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Figures for the view kinds drawn outside lib/view-svg.ts, one module each, by kind. Same contract -// and the same two rules as lib/view-svg.ts (every label escaped, uniform scaling), drawn through the -// same frame, palette and caption (lib/svg-kit.ts), so a figure here cannot drift from the others. -import { isDrawn, type ViewResult, type ViewResultOf, type DrawnResultOf, type ViewKind } from "../types.ts"; -import type { SvgOpts } from "../svg-kit.ts"; -import { attritionSvg } from "../views/attrition/figure.ts"; -import { genomeSvg } from "../views/genome/figure.ts"; -import { pathwaysSvg } from "../views/pathways/figure.ts"; -import { diffexprSvg } from "../views/diffexpr/figure.ts"; -import { pairedSvg } from "../views/paired/figure.ts"; -import { clonalitySvg } from "../views/clonality/figure.ts"; -import { incidenceSvg } from "../views/incidence/figure.ts"; -import { labsSvg } from "../views/labs/figure.ts"; -import { riskmodelSvg } from "../views/riskmodel/figure.ts"; -import { cancertypesSvg } from "../views/cancertypes/figure.ts"; -import { overlapSvg } from "../views/overlap/figure.ts"; -import { enrichmentSvg } from "../views/enrichment/figure.ts"; -import { assaysSvg } from "../views/assays/figure.ts"; -import { compositionSvg } from "../views/composition/figure.ts"; -import { correlatesSvg } from "../views/correlates/figure.ts"; -import { acquiredSvg } from "../views/acquired/figure.ts"; -import { ctdnaSvg } from "../views/ctdna/figure.ts"; -import { exclusivitySvg } from "../views/exclusivity/figure.ts"; -import { txendpointsSvg } from "../views/txendpoints/figure.ts"; -import { multistateSvg } from "../views/multistate/figure.ts"; -import { rmstSvg } from "../views/rmst/figure.ts"; -import { predictiveSvg } from "../views/predictive/figure.ts"; -import { cutpointSvg } from "../views/cutpoint/figure.ts"; -import { hlaSvg } from "../views/hla/figure.ts"; -import { metaForestSvg } from "./forest.ts"; -import { adjustedSvg } from "../views/adjusted/figure.ts"; -import { instabilitySvg } from "../views/instability/figure.ts"; -import { actionabilitySvg } from "../views/actionability/figure.ts"; -import { drivernegativeSvg } from "../views/drivernegative/figure.ts"; -import { organotropismSvg } from "../views/organotropism/figure.ts"; -import { molsubtypeSvg } from "../views/molsubtype/figure.ts"; -import { germlineSvg } from "../views/germline/figure.ts"; - -/** a kind's figure, drawn only when its result has something to draw, and its one-line title */ -interface Figure { draw(v: DrawnResultOf, o: SvgOpts): string | null; title(v: ViewResultOf): string } - -/** each entry is given only its own kind's result */ -const FIGURES: { [K in ViewKind]?: Figure } = { - attrition: { - draw: (v, o) => v.attrition && v.attrition.length ? attritionSvg(v, o) : null, - title: () => "Cohort attrition" - }, - genome: { - draw: (v, o) => v.genome && v.genome.series.length ? genomeSvg(v, o) : null, - title: () => "Genome-wide copy number" - }, - pathways: { - draw: (v, o) => v.pathways && v.pathways.rows.length ? pathwaysSvg(v, o) : null, - title: () => "Oncogenic signalling pathways" - }, - diffexpr: { - draw: (v, o) => v.diffexpr && v.diffexpr.tested ? diffexprSvg(v, o) : null, - title: (v) => "Differential expression" + (v.diffexpr ? " — " + v.diffexpr.profileType : "") - }, - paired: { - draw: (v, o) => v.paired && v.paired.panels.some((pn) => pn.patients) ? pairedSvg(v, o) : null, - title: (v) => "Paired samples" + (v.paired ? " — " + v.paired.labels.a + " vs " + v.paired.labels.b : "") - }, - clonality: { - draw: (v, o) => v.clonality && v.clonality.panels.length ? clonalitySvg(v, o) : null, - title: () => "Clonality (FACETS)" - }, - incidence: { - draw: (v, o) => v.incidence && v.incidence.curves.some((c) => c.n) ? incidenceSvg(v, o) : null, - title: (v) => "Cumulative incidence" + (v.incidence ? " — " + v.incidence.eventLabel + (v.incidence.anchoredTo ? " from " + v.incidence.anchoredTo : "") : "") - }, - labs: { - draw: (v, o) => v.labs && v.labs.panels.some((pn) => pn.evaluable) ? labsSvg(v, o) : null, - title: (v) => "Lab course" + (v.labs ? " — " + v.labs.test + " from " + v.labs.anchoredTo : "") - }, - riskmodel: { - draw: (v, o) => v.riskmodel ? riskmodelSvg(v, o) : null, - title: (v) => "Risk model" + (v.riskmodel ? " — elastic-net Cox, " + v.riskmodel.runs + " runs" : "") - }, - cancertypes: { - draw: (v, o) => v.cancertypes && v.cancertypes.panels.some((pn) => pn.profiled) ? cancertypesSvg(v, o) : null, - title: (v) => "Cancer types summary" + (v.cancertypes ? " — " + v.cancertypes.genes.join(", ") + " by " + v.cancertypes.groupBy : "") - }, - overlap: { - draw: (v, o) => v.overlap && v.overlap.union > 0 ? overlapSvg(v, o) : null, - title: () => "Cohort overlap" - }, - enrichment: { - draw: (v, o) => v.enrichment && v.enrichment.tested > 0 ? enrichmentSvg(v, o) : null, - title: (v) => "Alteration enrichment" + (v.enrichment ? " — " + v.enrichment.alteration : "") - }, - assays: { - draw: (v, o) => v.assays && v.assays.tested > 0 ? assaysSvg(v, o) : null, - title: (v) => "Generic assays" + (v.assays ? " — " + v.assays.profileName : "") - }, - composition: { - draw: (v, o) => v.composition && v.composition.groups.some((g) => g.n > 0) ? compositionSvg(v, o) : null, - title: (v) => "Composition" + (v.composition ? " — " + v.composition.sourceName : "") - }, - forest: { - draw: (v, o) => v.forest.rows.length ? metaForestSvg(v, o) : null, - title: (v) => "Forest plot" + (v.forest ? " — " + v.forest.exposedLabel + " by " + v.forest.strata + ", " + v.forest.endpoint : "") - }, - adjusted: { - draw: (v, o) => v.adjusted.rows.length ? adjustedSvg(v, o) : null, - title: (v) => "Adjusted enrichment" + (v.adjusted ? " — " + v.adjusted.groups.join(" vs ") + (v.adjusted.covariates.length ? ", adjusted for " + v.adjusted.covariates.join(", ") : "") : "") - }, - actionability: { - draw: (v, o) => v.actionability.panels.some((pn) => pn.sequenced) ? actionabilitySvg(v, o) : null, - title: (v) => "Clinical actionability" + (v.actionability ? " — OncoKB " + v.actionability.pin + (v.actionability.groupBy ? " by " + v.actionability.groupBy : "") : "") - }, - drivernegative: { - draw: (v, o) => v.drivernegative.sequenced ? drivernegativeSvg(v, o) : null, - title: (v) => "Driver-negative analysis" + (v.drivernegative ? " — " + v.drivernegative.alteration + ", driver-negative vs driver-positive" : "") - }, - organotropism: { - draw: (v, o) => v.organotropism.sites.length && v.organotropism.genes.length ? organotropismSvg(v, o) : null, - title: (v) => "Organotropism" + (v.organotropism ? " — " + v.organotropism.genes.length + " genes × " + v.organotropism.sites.length + " sites, " + v.organotropism.source : "") - }, - molsubtype: { - draw: (v, o) => v.molsubtype.panels.some((pn) => pn.classified) ? molsubtypeSvg(v, o) : null, - title: (v) => "Molecular subtypes" + (v.molsubtype ? " — " + v.molsubtype.classifier + (v.molsubtype.by ? " by " + v.molsubtype.by : "") : "") - }, - germline: { - draw: (v, o) => v.germline.panels.some((pn) => pn.genes.some((g) => g.sequenced)) ? germlineSvg(v, o) : null, - title: (v) => "Germline carriers and second hits" + (v.germline ? " — " + v.germline.genes.length + " genes" : "") - }, - correlates: { - draw: (v, o) => v.correlates && v.correlates.tested > 0 ? correlatesSvg(v, o) : null, - title: (v) => "Co-expression" + (v.correlates ? " — " + v.correlates.gene + " against every gene" : "") - }, - acquired: { - draw: (v, o) => v.acquired && v.acquired.panels.some((pn) => pn.patients && pn.genes.length) ? acquiredSvg(v, o) : null, - title: (v) => "Acquired under therapy" + (v.acquired ? " — samples before and after " + v.acquired.anchoredTo : "") - }, - ctdna: { - draw: (v, o) => v.ctdna && v.ctdna.panels.some((pn) => pn.patients) ? ctdnaSvg(v, o) : null, - title: () => "ctDNA vs tissue" - }, - exclusivity: { - draw: (v, o) => v.exclusivity && v.exclusivity.pairs.length ? exclusivitySvg(v, o) : null, - title: (v) => "Mutual exclusivity (DISCOVER)" + (v.exclusivity ? " — " + v.exclusivity.cohort : "") - }, - hla: { - draw: (v, o) => v.hla.panels.some((pn) => pn.typed || pn.lohEvaluated) ? hlaSvg(v, o) : null, - title: (v) => "HLA class I genotype and " + (v.hla ? v.hla.endpoint : "OS") - }, - cutpoint: { - draw: (v, o) => v.cutpoint.candidates.length ? cutpointSvg(v, o) : null, - title: (v) => "Cut-point" + (v.cutpoint ? " — " + v.cutpoint.attribute + " on " + v.cutpoint.endpoint : "") - }, - predictive: { - draw: (v, o) => v.predictive.genes.length ? predictiveSvg(v, o) : null, - title: (v) => "Predictive biomarkers" + (v.predictive ? " — gene × " + v.predictive.exposure : "") - }, - rmst: { - draw: (v, o) => v.rmst.panels.some((pn) => pn.curves.some((c) => c.n)) ? rmstSvg(v, o) : null, - title: (v) => "Restricted mean survival time" + (v.rmst ? " — " + v.rmst.endpointLabel : "") - }, - multistate: { - draw: (v, o) => v.multistate.cohorts.some((c) => c.n) ? multistateSvg(v, o) : null, - title: () => "Multistate model — progression and death" - }, - txendpoints: { - draw: (v, o) => v.txendpoints.curves.some((c) => c.n) ? txendpointsSvg(v, o) : null, - title: (v) => "Treatment endpoints" + (v.txendpoints ? " — " + v.txendpoints.endpointLabel + " from " + v.txendpoints.anchoredTo : "") - }, - instability: { - draw: (v, o) => v.instability && v.instability.panels.some((pn) => pn.score.some((n) => n > 0)) ? instabilitySvg(v, o) : null, - title: () => "Genomic instability — aneuploidy score and FGA" - }, -}; - -/** the figure for a view of one of these kinds, or null (another kind, or nothing to draw) */ -export function kindFigure(v: ViewResult, o: SvgOpts): string | null { - return isDrawn(v) ? figureOf(v)?.draw(v, o) ?? null : null; -} - -/** the one-line title for a view of one of these kinds, or null */ -export function kindTitle(v: ViewResult): string | null { - return figureOf(v)?.title(v) ?? null; -} - -/** FIGURES' entry for v's kind: keyed by that kind, so it takes v (TypeScript cannot follow the key) */ -function figureOf(v: ViewResult): { draw(v: ViewResult, o: SvgOpts): string | null; title(v: ViewResult): string } | undefined { - return Object.hasOwn(FIGURES, v.kind) ? FIGURES[v.kind] as unknown as ReturnType : undefined; -} diff --git a/lib/new-in-lab.ts b/lib/new-in-lab.ts index 245c11d..c0d20d8 100644 --- a/lib/new-in-lab.ts +++ b/lib/new-in-lab.ts @@ -1,14 +1,10 @@ // View kinds cBioPortal has no counterpart for. The Examples tab and the add-view buttons mark // them NEW. A kind the portal draws in some form (its Comparison, Plots, Mutations, Pathways or -// patient-view tabs) is left out, even where this one goes further. Browser-safe. -const NEW_KINDS = new Set([ - "cox", "logistic", "riskmodel", "txendpoints", "multistate", "rmst", "predictive", "cutpoint", "forest", "hla", - "course", "sequence", "incidence", "labs", "attrition", - "adjusted", "actionability", "drivernegative", "exclusivity", "organotropism", "molsubtype", "germline", - "paired", "acquired", "ctdna", "clonality", "genome", "instability", "composition", -]); +// patient-view tabs) is left out, even where this one goes further. Each kind's descriptor says +// (`isNew`, lib/views//kind.ts). Browser-safe. +import { kindOf } from "./views/kinds.ts"; -export const isNewKind = (kind: unknown): boolean => NEW_KINDS.has(String(kind)); +export const isNewKind = (kind: unknown): boolean => kindOf(kind)?.isNew === true; /** the mark's hover text */ export const NEW_TITLE = "not in cBioPortal"; diff --git a/lib/preview-kinds.ts b/lib/preview-kinds.ts index 0c48fb6..57b0a55 100644 --- a/lib/preview-kinds.ts +++ b/lib/preview-kinds.ts @@ -1,26 +1,10 @@ // View kinds shown as previews: drawn and usable, but not yet held to invariant 12 (no method // record, no golden test of their statistic). Each entry says what is pending. The view header // marks these with a Preview badge, and scripts/check-method-scripts.ts exempts them by this list, -// so a kind leaves both the moment its entry is removed. Browser-safe. -export const PREVIEW_KINDS: Record = { - acquired: "no method record or golden test of the before/after pairing and McNemar counts yet", - ctdna: "no method record or golden test of the agreement counts, Wilson intervals and survival split, or of the clonal-haematopoiesis filter (chFilter) yet", - exclusivity: "no method record or golden test of the DISCOVER background fit against the R package yet", - hla: "no method record or golden test of the homozygosity and LOH counts or the survival splits yet", - cutpoint: "no method record or golden test of the maximally selected log-rank scan against R maxstat yet", - predictive: "no method record or golden test of the per-gene interaction fits yet", - rmst: "no method record or golden test of the restricted means, their variances and the landmark sets against R survRM2 yet", - multistate: "no method record or golden test of the Aalen–Johansen occupation, Nelson–Aalen hazards or the delayed-entry Cox fits yet (no interval on the occupation)", - txendpoints: "no method record or golden test of the endpoint derivation (lines, stops, progression) or the median intervals yet", - forest: "no method record or golden test of the per-stratum Cox fits and the DerSimonian–Laird pooling yet", - adjusted: "no method record or golden test of the per-gene logistic fits yet", - instability: "no method record, and no check of the arm calls and aneuploidy scores against Taylor et al.'s published values yet", - actionability: "no method record of the per-patient highest-level counts yet", - drivernegative: "no method record or golden test of the group split and the per-gene Fisher tests yet", - germline: "no method record or golden test of the carrier and second-hit counts or the Fisher tests yet", - molsubtype: "no method record or golden test of the per-patient classification, the chi-squared tests or the survival split yet", - organotropism: "no method record or golden test of the per-cell Cochran–Mantel–Haenszel tests or the site incidence yet", -}; +// so a kind leaves both the moment its descriptor's `preview` is removed. Browser-safe. +import { KINDS } from "./views/kinds.ts"; +export const PREVIEW_KINDS: Record = Object.fromEntries( + Object.values(KINDS).flatMap((k) => k.preview ? [[k.kind, k.preview]] : [])); /** what a preview kind still lacks, or null for a kind held to the full checks */ export const previewPending = (kind: unknown): string | null => diff --git a/lib/result-text.ts b/lib/result-text.ts index db4a97e..6d4fecf 100644 --- a/lib/result-text.ts +++ b/lib/result-text.ts @@ -1,18 +1,10 @@ -// The text description of an analysis result: every view kind, with the engine's caveats and -// the author's notes, the way a model should read it. One renderer for every agent-facing +// The text description of an analysis result: every view kind (its descriptor's text, +// lib/views//kind.ts), with the engine's caveats and the author's notes, the way a model +// should read it. One renderer for every agent-facing // surface — the MCP run_analysis tool, the analysis page's WebMCP tools — so they cannot // describe the same result differently. Pure and browser-safe. import { isDrawn, type AnalysisDoc, type AnalysisResult, type ViewResult } from "./types.ts"; -import { lollipopSummaryText } from "./lollipop-svg.ts"; -import { riskSpan } from "./views/riskmodel/figure.ts"; -import { txMedianText } from "./views/txendpoints/figure.ts"; -import { MS_TRANSITIONS, msRateText } from "./views/multistate/figure.ts"; -import { rmstText, rmstDiffText, rmstRatioText } from "./views/rmst/figure.ts"; -import type { AssaysEntity } from "./views/assays/types.ts"; -import type { CorrelatesGene } from "./views/correlates/types.ts"; - -/** a timeline's events listed in text (MCP); the figure draws every one */ -const TIMELINE_TEXT_EVENTS = 400; +import { kindOf } from "./views/kinds.ts"; export function analysisText(document: AnalysisDoc, r: AnalysisResult): string[] { const out: string[] = []; @@ -76,483 +68,10 @@ export function analysisText(document: AnalysisDoc, r: AnalysisResult): string[] out.push(v.empty ? `View ${v.kind}: EMPTY — it names no cohorts yet, so nothing was computed; give it a \`cohorts\` list` : `View ${v.kind}: ERROR — ${v.error}`); continue; } - if (v.kind === "compare") { - const rows = v.rows.slice(0, 20).map((row) => - ` ${row.gene}: ` + row.cells.map((c) => `${c.cohort} ${c.driverPct}%/${c.anyPct}%`).join(" | ") + - (row.pAny != null ? ` p=${row.pAny < 1e-4 ? row.pAny.toExponential(1) : row.pAny.toFixed(4)}` : "")); - out.push(`Compare (driver%/any%, panel-aware denominators; annotation ${v.annotation}):\n` + rows.join("\n") + - noteOf(v)); - } else if (v.kind === "survival") { - out.push(`Survival (KM, ${v.endpoint || "OS"}${v.anchoredTo ? " from " + v.anchoredTo : ""}):\n` + v.curves.map((c) => - ` ${c.cohort}: median ${c.median ? c.median.toFixed(1) + "mo" : "not reached"} (n=${c.n}, ${c.events} events${c.missing ? ", " + c.missing + " missing OS" : ""}${c.unobserved ? ", " + c.unobserved + " with no follow-up after entry left out" : ""})`).join("\n") + - (v.logrank ? `\n log-rank p=${pFmt(v.logrank.p)}${v.logrank.strata > 1 ? " (stratified by study)" : ""}` - : v.logrankK ? `\n k-group log-rank p=${pFmt(v.logrankK.p)}` : "") + - "\nCAVEAT (tell the user): " + v.caveat); - } else if (v.kind === "cox") { - // stratifyByCohort: no pooled coefficients — one independent fit per cohort instead. - out.push(v.cox.byCohort - ? `Cox regression (${v.endpoint || "OS"}, stratified by cohort):\n` + - v.cox.byCohort.map((c) => c.error - ? ` ${c.cohort}: ERROR — ${c.error}` - : ` ${c.cohort} (n=${c.n.toLocaleString()}, ${c.events.toLocaleString()} events):\n` + - c.coefficients.map((cf) => - ` ${cf.label}${cf.reference ? " vs " + cf.reference : ""}: HR ${cf.hr.toFixed(2)} ` + - `(95% CI ${cf.ciLow.toFixed(2)}-${cf.ciHigh.toFixed(2)}) p=${pFmt(cf.p)}`).join("\n") - ).join("\n") + caveatOf(v) - : `Cox regression (${v.endpoint || "OS"}, n=${v.cox.n.toLocaleString()}, ${v.cox.events.toLocaleString()} events):\n` + - v.cox.coefficients.map((c) => - ` ${c.label}${c.reference ? " vs " + c.reference : ""}: HR ${c.hr.toFixed(2)} ` + - `(95% CI ${c.ciLow.toFixed(2)}-${c.ciHigh.toFixed(2)}) p=${pFmt(c.p)}`).join("\n") + - caveatOf(v)); - } else if (v.kind === "forest") { - const f = v.forest, hr = (x: { hr: number; ciLow: number; ciHigh: number; p: number }) => - `HR ${x.hr.toFixed(2)} (95% CI ${x.ciLow.toFixed(2)}-${x.ciHigh.toFixed(2)}) p=${pFmt(x.p)}`; - out.push(`Forest plot: ${f.endpoint} hazard ratio for ${f.exposedLabel} by ${f.strata}` + (f.covariates.length ? `, adjusted for ${f.covariates.join(", ")}` : "") + - ` (n=${f.n.toLocaleString()}, ${f.events.toLocaleString()} events):\n` + - f.rows.map((r) => ` ${r.label} (n=${r.n.toLocaleString()}, ${r.events} events, ${r.exposed} exposed): ${hr(r)}${f.random ? `, weight ${(100 * r.weight).toFixed(1)}%` : ""}`).join("\n") + - (f.random && f.fixed ? `\n pooled, random effects (DerSimonian–Laird): ${hr(f.random)}\n pooled, fixed effect: ${hr(f.fixed)}` + - `\n heterogeneity: tau²=${f.tau2.toFixed(3)}, I²=${(100 * f.i2).toFixed(0)}%, Q=${f.q.toFixed(1)} on ${f.qDf} df, p=${pFmt(f.qP)}` : "") + - (f.left.length ? `\n not estimated: ` + f.left.map((l) => `${l.label} (${l.reason})`).join("; ") : "") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "adjusted") { - const a = v.adjusted, pct = (x: number, n: number) => n ? (100 * x / n).toFixed(1) + "%" : "–"; - out.push(`Adjusted enrichment: ${a.groups[0]} vs ${a.groups[1]}, mutation${a.measure === "driver" ? " (OncoKB drivers)" : ""} per gene by logistic regression` + - (a.covariates.length ? ` adjusted for ${a.covariates.join(", ")}` : " (no covariates)") + `; ${a.rows.length} genes fit, ${a.significant} at q<0.05:\n` + - a.rows.slice(0, 30).map((g) => ` ${g.gene}: ${a.groups[0]} ${g.altered[0]}/${g.profiled[0]} (${pct(g.altered[0], g.profiled[0])}), ${a.groups[1]} ${g.altered[1]}/${g.profiled[1]} (${pct(g.altered[1], g.profiled[1])}); ` + - `raw OR ${g.rawOr == null ? "undefined" : g.rawOr.toFixed(2)} (Fisher p=${pFmt(g.fisherP)}); adjusted OR ${g.or.toFixed(2)} (95% CI ${g.ciLow.toFixed(2)}-${g.ciHigh.toFixed(2)}) p=${pFmt(g.p)} q=${pFmt(g.q)}`).join("\n") + - (a.rows.length > 30 ? `\n … ${a.rows.length - 30} more genes fit` : "") + - (a.failed.length ? `\n not fit: ` + a.failed.map((f) => `${f.gene} (${f.reason})`).join("; ") : "") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "actionability") { - const a = v.actionability, pct = (k: number, d: number) => d ? (100 * k / d).toFixed(1) + "%" : "–"; - const line = (label: string, g: { sequenced: number; levels: number[]; resistance: number[] }) => - ` ${label} (${g.sequenced.toLocaleString()} sequenced): ` + a.levels.map((l, i) => `${l} ${g.levels[i]} (${pct(g.levels[i], g.sequenced)})`).join(", ") + - `; resistance R1 ${g.resistance[0]}, R2 ${g.resistance[1]}`; - out.push(`Clinical actionability (highest OncoKB level per patient, ${a.pin}, any cancer type${a.groupBy ? `, by ${a.groupBy}` : ""}):\n` + - a.panels.map((pn) => [line(pn.cohort, pn), ...(pn.groups || []).map((g) => line(" " + g.label, g)), - ...(pn.concordance ? [` ${a.pairBy!.a.join("/")} vs ${a.pairBy!.b.join("/")} highest level, ${pn.concordance.pairs} patients with both: ${pn.concordance.discordant} discordant (${pct(pn.concordance.discordant, pn.concordance.pairs)}); rows ${a.pairBy!.a.join("/")}, columns ${a.pairBy!.b.join("/")} (1, 2, 3A, 3B, 4, none): ` + - pn.concordance.table.map((r) => "[" + r.join(", ") + "]").join(" ")] : [])].join("\n")).join("\n") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "germline") { - const gm = v.germline, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; - out.push(`Germline carriers and somatic second hits (${gm.chosen || gm.genes.length + " genes"}; LOH ${gm.loh}):\n` + - gm.panels.map((pn) => ` ${pn.cohort} (${pn.patients.toLocaleString()} patients):\n` + pn.genes.map((g) => - ` ${g.gene}: ${g.carriers}/${g.sequenced} sequenced carry a germline call (${pct(g.carriers, g.sequenced)})${g.outsidePanel ? `, ${g.outsidePanel} more off their panel` : ""}; ` + - `second hit in carriers ${g.carrierEither}/${g.carriers} (${pct(g.carrierEither, g.carriers)}: ${g.carrierSomatic} somatic mutation, ${g.carrierDeletion}/${g.carrierCnaProfiled} deep deletion) vs non-carriers ${g.noncarrierEither}/${g.noncarriers} (${pct(g.noncarrierEither, g.noncarriers)})` + - (g.p != null ? `, Fisher p=${pFmt(g.p)} q=${pFmt(g.q!)}` : "")).join("\n")).join("\n") + - (gm.survival || []).map((s) => `\n OS by ${s.gene} carrier status, ${s.cohort}: ` + s.curves.map((c) => `${c.group} n=${c.n}, ${c.events} deaths, median ${c.median == null ? "not reached" : c.median.toFixed(1) + " mo"}`).join("; ") + - (s.logrank ? `; log-rank p=${pFmt(s.logrank.p)}` : s.noTest ? "; " + s.noTest : "")).join("") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "molsubtype") { - const m = v.molsubtype, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; - const line = (label: string, c: { counts: number[]; classified: number; notClassifiable: number }) => - ` ${label} (${c.classified.toLocaleString()} classified, ${c.notClassifiable.toLocaleString()} not classifiable): ` + m.subtypes.map((s, i) => `${s} ${c.counts[i]} (${pct(c.counts[i], c.classified)})`).join(", "); - out.push(`Molecular subtypes — ${m.classifierLabel}; MSI by ${m.msi}; p53 abnormal = ${m.p53}:\n` + - m.panels.map((pn) => [line(pn.cohort, pn), ...(pn.groups || []).map((g) => line(" " + g.label, g)), - ...(pn.byTest ? [` by ${m.by}: chi-squared ${pn.byTest.chi2.toFixed(1)} (${pn.byTest.df} df), p=${pFmt(pn.byTest.p)}`] : [])].join("\n")).join("\n") + - (m.cohortTest ? `\n Between cohorts: chi-squared ${m.cohortTest.chi2.toFixed(1)} (${m.cohortTest.df} df), p=${pFmt(m.cohortTest.p)}` : "") + - (m.survival || []).map((s) => `\n OS by subtype, ${s.cohort}: ` + s.curves.map((c) => `${c.subtype} n=${c.n}, ${c.events} deaths, median ${c.median == null ? "not reached" : c.median.toFixed(1) + " mo"}`).join("; ") + - (s.logrank ? `; log-rank chi-squared ${s.logrank.chi2.toFixed(1)} (${s.logrank.df} df), p=${pFmt(s.logrank.p)}` : s.noTest ? "; " + s.noTest : "")).join("") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "organotropism") { - const o = v.organotropism, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; - const lab = new Map(o.sites.map((s) => [s.site, s.label])); - const sig = o.cells.filter((c) => c.q != null && c.q < 0.05).sort((a, b) => a.p! - b.p!); - out.push(`Metastatic organotropism (${o.patients.toLocaleString()} patients; sites from ${o.sourceLabel}; ${o.measure === "driver" ? "OncoKB-driver " : ""}${o.alteration}s; ` + - `${o.stratifyBy ? "CMH stratified by " + o.stratifyBy : "unstratified"}): ${o.genes.length} genes × ${o.sites.length} sites, ${o.tested} cells tested, ${o.significant} at q<0.05.\n` + - ` Sites (patients with a metastasis there / evaluable): ` + o.sites.map((s) => `${s.label} ${s.withSite}/${s.evaluable}`).join(", ") + "\n" + - sig.slice(0, 30).map((c) => ` ${lab.get(c.site) || c.site} × ${c.gene}: altered ${c.withAltered}/${c.withSite} (${pct(c.withAltered, c.withSite)}) with the site vs ${c.withoutAltered}/${c.without} (${pct(c.withoutAltered, c.without)}) without, ` + - `MH OR ${c.or == null ? "undefined" : c.or.toFixed(2)}, p=${pFmt(c.p!)} q=${pFmt(c.q!)}`).join("\n") + - (o.incidence ? `\n Cumulative incidence of ${o.incidence.site} by ${o.incidence.gene} (death competing): ` + - o.incidence.horizons.map((h) => `${h.months} mo ` + h.cells.map((c) => `${c.cohort} ${(100 * c.f1).toFixed(1)}% [${(100 * c.lo).toFixed(1)}–${(100 * c.hi).toFixed(1)}]`).join(" vs ")).join("; ") + - (o.incidence.gray ? `; Gray's test p=${pFmt(o.incidence.gray.p)}` : "") : o.noIncidence ? `\n No incidence panel: ${o.noIncidence}` : "") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "drivernegative") { - const d = v.drivernegative, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; - out.push(`Driver-negative analysis (drivers: ${d.drivers.join(", ")}): of ${d.sequenced.toLocaleString()} patients sequenced, ` + - `${d.positive.toLocaleString()} driver-positive (${pct(d.positive, d.sequenced)}), ${d.negative.toLocaleString()} driver-negative (${pct(d.negative, d.sequenced)}), ` + - `${d.unprofiled.toLocaleString()} not fully profiled (in neither group).\n` + - ` ${d.alteration} frequency, driver-negative vs driver-positive, Fisher + BH over ${d.tested} genes, ${d.significant} at q<0.05:\n` + - d.rows.slice(0, 30).map((g) => ` ${g.gene}: ${g.altered[0]}/${g.profiled[0]} (${pct(g.altered[0], g.profiled[0])}) vs ${g.altered[1]}/${g.profiled[1]} (${pct(g.altered[1], g.profiled[1])}), ` + - `log2 ratio ${g.log2Ratio == null ? "undefined" : g.log2Ratio.toFixed(2)}, p=${pFmt(g.p)} q=${pFmt(g.q)}`).join("\n") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "logistic") { - out.push(`Logistic regression (n=${v.logistic.n.toLocaleString()}, ${v.logistic.events.toLocaleString()} with the outcome):\n` + - v.logistic.coefficients.map((c) => - ` ${c.label}${c.reference ? " vs " + c.reference : ""}: OR ${c.or.toFixed(2)} ` + - `(95% CI ${c.ciLow.toFixed(2)}-${c.ciHigh.toFixed(2)}) p=${pFmt(c.p)}`).join("\n") + - caveatOf(v)); - } else if (v.kind === "cooccurrence") { - out.push("Co-occurrence (2x2 per pair, among patients profiled for BOTH genes):\n" + - (v.pairs || []).slice(0, 20).map((x) => - ` ${x.a}/${x.b}: both ${x.both.toLocaleString()}, ${x.a} only ${x.onlyA.toLocaleString()}, ` + - `${x.b} only ${x.onlyB.toLocaleString()}, neither ${x.neither.toLocaleString()} (of ${x.profiled.toLocaleString()} profiled)` + - (x.log2Odds != null ? ` log2OR=${x.log2Odds.toFixed(2)} ${x.log2Odds > 0 ? "(co-occur)" : "(mutually exclusive)"}` : "") + - ` p=${pFmt(x.p)} q=${pFmt(x.q)}` + - (x.pStratified != null ? ` stratified p=${pFmt(x.pStratified)}` : "")).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "clinical") { - out.push("Clinical comparison across cohorts:\n" + - (v.clinical || []).slice(0, 20).map((row) => { - const cells = row.cells.map((c) => row.kind === "num" - ? `${c.cohort} mean ${c.mean != null ? c.mean.toFixed(2) : "—"}` + - (c.sd != null ? ` sd ${c.sd.toFixed(2)}` : "") + ` (n=${c.n.toLocaleString()})` - : `${c.cohort} ` + (c.top && c.top.length - ? c.top.slice(0, 3).map((t) => `${t.v} ${c.n ? Math.round(1000 * t.n / c.n) / 10 : 0}%`).join(", ") - : "—")).join(" | "); - return ` ${row.attribute} [${row.per}, ${row.kind}]: ${cells}` + - (row.p != null ? ` ${row.test || "test"} p=${pFmt(row.p)} q=${pFmt(row.q!)}` : "") + - (row.skipped ? ` SKIPPED: ${row.skipped}` : ""); - }).join("\n") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "coexpression") { - out.push("Co-expression (Pearson r, computed from six per-site sums so the pooled value is exact):\n" + - (v.coexpression || []).slice(0, 20).map((x) => - ` ${x.a}/${x.b}: r=${x.r != null ? x.r.toFixed(3) : "—"} (n=${x.n.toLocaleString()}) p=${pFmt(x.p)} q=${pFmt(x.q)}` + - (x.rMeta != null ? ` meta-r=${x.rMeta.toFixed(3)} across ${x.sites} sites` : "")).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "hotspots") { - out.push("Mutations by protein position (commonest changes; share of patients mutated in THAT gene):\n" + - (v.hotspots || []).map((g) => - ` ${g.gene}: ${g.mutated.toLocaleString()} mutated, ${g.mutations.toLocaleString()} mutations on ${g.positions.length.toLocaleString()} codons — ` + - (g.topChanges.slice(0, 6).map((x) => `${x.change} ${x.n.toLocaleString()} (${x.pct}%)`).join(", ") || "no change above the floor") + - (g.unpositioned ? ` [${g.unpositioned.toLocaleString()} with no protein position, not drawn]` : "") + - (g.withheld ? ` [${g.withheld.toLocaleString()} below the suppression floor]` : "")).join("\n") + - "\n " + lollipopSummaryText(v.hotspots || [], v.hide || []) + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "course") { - const axis = v.courseAxis; - out.push(`Clinical course (${axis ? axis.binMonths + "-month bins, " + axis.from + " to +" + axis.to + "mo" : "binned"} ` + - `relative to the anchor; DENOMINATOR IS THE PATIENTS STILL OBSERVABLE IN EACH BIN, not the cohort):\n` + - (v.course || []).map((s2) => { - const pts = s2.points.filter((q) => q.pct != null && q.atRisk > 0); - const at = (t: number) => { const q = pts.find((z) => z.t === t); return q ? `${q.pct}% (n=${q.atRisk.toLocaleString()})` : "—"; }; - return ` ${s2.cohort} / ${s2.eventType} [${s2.isInterval ? "a state patients are IN" : "an event that HAPPENED"}]: ` + - `0mo ${at(0)}, 12mo ${at(12)}, 24mo ${at(24)}, 60mo ${at(60)}`; - }).join("\n") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "sequence") { - const sq2 = v.sequence; - out.push(`Treatment sequence (lines of therapy by ${sq2 ? sq2.labelKey : "?"}, ` + - `${sq2 ? sq2.lineGap : 90}-day line gap):\n` + - (sq2?.panels || []).map((pn) => { - const first = pn.nodes.filter((n) => n.column === 1 && n.kind === "regimen") - .sort((a, b) => b.n - a.n).slice(0, 5) - .map((n) => `${n.label} ${n.n.toLocaleString()}`).join(", "); - const stopped = pn.nodes.filter((n) => n.kind === "stopped").reduce((t, n) => t + n.n, 0); - const cens = pn.censored; - return ` ${pn.cohort}: ${pn.started.toLocaleString()} with a first line — ${first}\n` + - ` of all paths, ${stopped.toLocaleString()} were WATCHED and began no further line; ` + - `${cens.toLocaleString()} (${pn.started ? Math.round(100 * cens / pn.started) : 0}%) end because the RECORD stops — nothing can be concluded about those`; - }).join("\n") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "plot") { - const pd = v.plot; - if (!pd) { out.push("Plot: no data"); } - else if (pd.shape === "scatter") { - const st = pd.stats; - out.push(`Plot (scatter): ${pd.x.label} vs ${pd.y.label} — ${pd.n.toLocaleString()} ${plotUnit(v)}` + - (pd.grid ? `, binned onto a ${pd.grid[0]}x${pd.grid[1]} grid (the payload is bins, never points)` : "") + "." + - (st ? `\n Pearson r=${st.pearson != null ? st.pearson.toFixed(3) : "—"}` + - (st.pPearson != null ? ` p=${pFmt(st.pPearson)}` : "") + - `; Spearman rho=${st.spearman != null ? st.spearman.toFixed(3) : "—"}` + - (st.pSpearman != null ? ` p=${pFmt(st.pSpearman)}` : "") + - (st.slope != null ? `; least-squares slope ${st.slope.toFixed(3)}` : "") : "") + - caveatOf(v) + noteOf(v)); - } else if (pd.shape === "box") { - out.push(`Plot (box): ${pd.y.label} by ${pd.x.label} — ${pd.n.toLocaleString()} ${plotUnit(v)}.\n` + - (pd.groups || []).slice(0, 12).map((g) => - ` ${g.g}: median ${g.median.toFixed(2)} (IQR ${g.q1.toFixed(2)}–${g.q3.toFixed(2)}, ` + - `whiskers ${g.loWhisker.toFixed(2)}–${g.hiWhisker.toFixed(2)}, n=${g.n.toLocaleString()})`).join("\n") + - // WHICH overlay was drawn is a disclosure, not decoration: points - // mean the reader is seeing cases, a density means they are not. - (pd.overlay === "points" ? `\n Values are drawn as individual points.` - : pd.overlay === "violin" ? `\n Values are drawn as a DENSITY, not points — either above the point cap or from a participant that discloses no cases.` - : "") + - // a cohort layer drawn as boxes is a second set of quartiles the figure shows and the note does not - (pd.layers || []).filter((h) => h.groups && h.groups.length).map((h) => `\n Point layer “${h.name}”:\n` + - h.groups!.slice(0, 12).map((g) => ` ${g.g}: median ${g.median.toFixed(2)} (IQR ${g.q1.toFixed(2)}–${g.q3.toFixed(2)}, n=${g.n.toLocaleString()})`).join("\n")).join("") + - caveatOf(v) + noteOf(v)); - } else { - out.push(`Plot (table): ${pd.x.label} vs ${pd.y.label} — ${pd.n.toLocaleString()} ${plotUnit(v)}.\n` + - (pd.cells || []).slice(0, 24).map((c) => ` ${c.cx} x ${c.cy}: ${c.n.toLocaleString()}`).join("\n") + - caveatOf(v) + noteOf(v)); - } - } else if (v.kind === "oncoprint" && !v.svg && v.spec) { - // the web page draws its oncoprints itself, so its results carry the spec, not the figure - const sp = v.spec as { genes?: unknown[] }; - out.push(`Oncoprint of ${(sp.genes || []).length} gene track(s) across ${(v.cohorts || []).join(", ")} — drawn in the page from its spec; ` + - `per-gene frequencies are in the figure's header, not in this summary.` + noteOf(v)); - } else if (v.kind === "oncoprint" && v.svg) { - out.push(`Oncoprint: ${v.nColumns!.toLocaleString()} patients — ` + - v.genes!.map((g) => `${g.gene} ${g.driverPct}%/${g.anyPct}%`).join(", ") + - ` — annotation ${v.annotation}.` + noteOf(v)); - } else if (v.kind === "genome") { - const g = v.genome; - // per chromosome, the highest gained and lost share of each cohort — enough to name the - // regions without reciting every bin - const perChrom = (s: typeof g.series[number]) => { - const best = new Map(); - g.bins.forEach((b, i) => { - if (!s.profiled[i]) return; - const cur = best.get(b.chrom) || { gain: 0, loss: 0 }; - cur.gain = Math.max(cur.gain, 100 * s.gain[i] / s.profiled[i]); - cur.loss = Math.max(cur.loss, 100 * s.loss[i] / s.profiled[i]); - best.set(b.chrom, cur); - }); - return [...best].map(([c, x]) => `chr${c} +${x.gain.toFixed(0)}/−${x.loss.toFixed(0)}`).join(", "); - }; - out.push(`Genome-wide copy number (${g.build}, ${g.binMb} Mb bins, |log2| ≥ ${g.threshold}; peak % gained/lost per chromosome):\n` + - g.series.map((s) => ` ${s.cohort} (${s.segmented.toLocaleString()} segmented samples): ${perChrom(s)}`).join("\n") + - (g.test ? `\n bins differing at q<0.05: ${g.test.significantGain} for gains, ${g.test.significantLoss} for losses, of ${g.bins.length}` : "") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "pathways") { - const pw = v.pathways; - out.push(`Oncogenic signalling pathways (% of patients with ≥1 member ${pw.measure === "driver" ? "driver" : "altered"}; union-of-panels denominator):\n` + - pw.rows.map((r) => ` ${r.name} (${r.profiledGenes}/${r.genes} genes profiled): ` + - r.cells.map((c) => `${c.cohort} ${c.pct == null ? "not profiled" : c.pct + "%"}`).join(" | ") + - (r.q != null ? ` q=${pFmt(r.q)}` : "") + - (r.top.length ? `; top: ` + r.top.map((t) => `${t.gene} ${t.cells.map((c) => c.pct == null ? "–" : c.pct + "%").join("/")}`).join(", ") : "") + - (r.otherAltered ? `, +${r.otherAltered} more altered` : "")).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "diffexpr") { - const d = v.diffexpr; - const top = (sign: 1 | -1) => d.points.filter((g) => g.q < 0.05 && Math.sign(g.effect) === sign).slice(0, 12) - .map((g) => `${g.gene} ${g.effect > 0 ? "+" : ""}${g.effect.toFixed(2)} (q=${pFmt(g.q)})`).join(", "); - out.push(`Differential expression, ${d.groups[0]} vs ${d.groups[1]} on ${d.profileType}${d.log2 ? " (log2)" : ""}: ` + - `${d.tested.toLocaleString()} genes tested, ${d.up.toLocaleString()} higher in ${d.groups[0]} and ${d.down.toLocaleString()} higher in ${d.groups[1]} at q<0.05 (${d.effectLabel}).\n` + - ` most significant, higher in ${d.groups[0]}: ${top(1) || "none"}\n` + - ` most significant, higher in ${d.groups[1]}: ${top(-1) || "none"}` + - (d.gsea ? `\n GSEA (${d.gsea.ranking}; ${d.gsea.permutations} gene-set permutations):\n` + - d.gsea.sets.slice(0, 12).map((x) => ` ${x.name}: NES ${x.nes.toFixed(2)}, q=${pFmt(x.q)}, ${x.size} genes, leading edge ${x.leadingEdge.slice(0, 8).join(" ")}${x.leadingEdge.length > 8 ? " …" : ""}`).join("\n") : "") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "paired") { - const pr = v.paired, aL = pr.labels.a, bL = pr.labels.b; - const sides = pr.perStudy ? Object.entries(pr.perStudy).map(([st, sp]) => `${st}: ${sp.attribute} ${sp.a.join("/")} vs ${sp.b.join("/")}`).join("; ") - : `${pr.pairBy.attribute} ${pr.pairBy.a.join("/")} vs ${pr.pairBy.b.join("/")}`; - out.push(`Paired samples, ${aL} vs ${bL} (${sides}; ${pr.measure === "driver" ? "drivers" : "any alteration"}; profiled on both sides):\n` + - pr.panels.map((pn) => ` ${pn.cohort} (${pn.patients.toLocaleString()} patients with both` + - (pn.tested != null ? `; scanned ${pn.tested.toLocaleString()} genes with a discordant patient, the ${pn.genes.length} most one-sided listed` : "") + `):\n` + - pn.genes.map((g) => ` ${g.gene}: both ${g.both}, ${aL}-only ${g.aOnly}, ${bL}-only ${g.bOnly} of ${g.pairs} pairs` + - (g.concordance != null ? `, ${Math.round(100 * g.concordance)}% concordant` : "") + - (g.aOnly + g.bOnly ? `, McNemar q=${pFmt(g.q)}` : "")).join("\n")).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "acquired") { - const ar = v.acquired; - out.push(`Acquired under therapy: last sample on or before the first ${ar.anchoredTo} against the first at least ${ar.minGapDays} days after (${ar.measure === "driver" ? "OncoKB oncogenic" : "any alteration"}; both samples' panels cover the gene):\n` + - ar.panels.map((pn) => ` ${pn.cohort} (${pn.anchored.toLocaleString()} started, ${pn.withPre.toLocaleString()} with a sample before, ${pn.withPost.toLocaleString()} after, ${pn.patients.toLocaleString()} with both` + - (pn.gapDays ? `; samples a median ${pn.gapDays.median} days apart` : "") + - (pn.tested != null ? `; ${pn.tested.toLocaleString()} genes tested, the ${pn.genes.length} smallest p listed` : "") + `):\n` + - pn.genes.map((g) => ` ${g.gene}: acquired ${g.acquired}, lost ${g.lost}, shared ${g.shared}, neither ${g.neither} of ${g.pairs} pairs` + - (g.acquired + g.lost ? `, McNemar p=${pFmt(g.p)} q=${pFmt(g.q)}` : "")).join("\n")).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "ctdna") { - const cr = v.ctdna, pc = (x: number | null) => x == null ? "—" : (100 * x).toFixed(0) + "%"; - out.push(`ctDNA vs tissue (plasma ${cr.attribute} = ${cr.plasma.join("/")}${cr.plasmaPanels.length ? " or panel " + cr.plasmaPanels.join("/") : ""}, tissue ${cr.tissue.join("/")}; ` + - `${cr.measure === "driver" ? "OncoKB oncogenic" : "any alteration"}${cr.withinDays != null ? `; samples within ${cr.withinDays} days` : ""}; both panels cover the gene):\n` + - cr.panels.map((pn) => ` ${pn.cohort} (${pn.patients.toLocaleString()} patients with both; ctDNA detected in ${pn.detected.toLocaleString()} of ${pn.plasmaPatients.toLocaleString()} with plasma, ` + - `${pc(pn.plasmaPatients ? pn.detected / pn.plasmaPatients : null)} [95% CI ${pc(pn.detectedLow)}–${pc(pn.detectedHigh)}]):\n` + - pn.genes.map((g) => ` ${g.gene}: both ${g.both}, tissue-only ${g.tissueOnly}, plasma-only ${g.plasmaOnly}, neither ${g.neither} of ${g.pairs}; ` + - `PPA ${pc(g.ppa)}${g.ppa != null ? ` [${pc(g.ppaLow)}–${pc(g.ppaHigh)}]` : ""}, concordance ${pc(g.concordance)}`).join("\n") + - (pn.survival ? `\n OS: ` + pn.survival.curves.map((c) => `${c.label} n=${c.n}, ${c.events} deaths, median ${c.median == null ? "not reached" : c.median.toFixed(1) + "mo"}`).join("; ") + - (pn.survival.logrank ? `; log-rank p=${pFmt(pn.survival.logrank.p)}` : "") : pn.survivalWhy ? `\n OS by ctDNA not drawn: ${pn.survivalWhy}` : "") + - (pn.ch ? `\n CH filter: removed ${pn.ch.removed} of ${pn.ch.calls} plasma mutation calls in ${pn.ch.patients} patients (${pn.ch.byGene} by gene, ${pn.ch.byNormal} by the matched normal)` + - (pn.ch.genes.length ? ": " + pn.ch.genes.map((g) => `${g.gene} ${g.calls}`).join(", ") : "") + - `; unfiltered: ctDNA detected ${pn.ch.before.detected.toLocaleString()} (${pc(pn.plasmaPatients ? pn.ch.before.detected / pn.plasmaPatients : null)})` + - (pn.ch.before.survival && pn.ch.before.survival.p != null ? `, OS log-rank p=${pFmt(pn.ch.before.survival.p)}` : "") + - `; plasma-only unfiltered ` + pn.ch.before.genes.map((g) => `${g.gene} ${g.plasmaOnly}`).join(", ") : "")).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "exclusivity") { - const ex = v.exclusivity; - out.push(`Mutual exclusivity (DISCOVER background over ${ex.samples.toLocaleString()} samples and ${ex.matrixGenes} genes${ex.stratifyBy ? `, within ${ex.strata.length} ${ex.stratifyBy} strata` : ""}; ` + - `${ex.measure === "driver" ? "OncoKB oncogenic" : "any alteration"}; fit ${ex.converged ? "converged" : "NOT converged"} in ${ex.iterations} rounds) in ${ex.cohort}:\n` + - ex.pairs.map((x) => ` ${x.a}/${x.b}: both ${x.both} of ${x.profiled} profiled, ${x.expected} expected; DISCOVER exclusivity q=${pFmt(x.qExclusive)}, co-occurrence q=${pFmt(x.qCooccur)}` + - `${x.exact ? "" : " (normal approx.)"}; Fisher q=${pFmt(x.fisherQ)}${x.log2Odds != null ? `, log2 OR ${x.log2Odds.toFixed(2)}` : ""}`).join("\n") + - (ex.unassessable.length ? `\n not assessable: ${ex.unassessable.join(", ")}` : "") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "instability") { - const ir = v.instability, f = (s: { median: number | null; q1: number | null; q3: number | null }) => s.median == null ? "—" : `${s.median} [${s.q1}–${s.q3}]`; - out.push(`Genomic instability (hg19 segments, threshold ±${ir.threshold}, arm called past ${Math.round(ir.armShare * 100)}% of its length; ${ir.arms.length} arms):\n` + - ir.panels.map((pn) => ` ${pn.cohort}: ${pn.score.reduce((s, x) => s + x, 0).toLocaleString()} samples scored; aneuploidy score median [IQR] ${f(pn.scoreSpread)}, FGA ${f(pn.fgaSpread)}%\n` + - ` most often gained: ${[...pn.arms].filter((a) => a.measured).sort((a, b) => b.gain / b.measured - a.gain / a.measured).slice(0, 5).map((a) => `${a.arm} ${Math.round(100 * a.gain / a.measured)}%`).join(", ")}\n` + - ` most often lost: ${[...pn.arms].filter((a) => a.measured).sort((a, b) => b.loss / b.measured - a.loss / a.measured).slice(0, 5).map((a) => `${a.arm} ${Math.round(100 * a.loss / a.measured)}%`).join(", ")}`).join("\n") + - (ir.test ? `\n ${ir.test.name}: aneuploidy score p=${pFmt(ir.test.scoreP)}, FGA p=${pFmt(ir.test.fgaP)}` : "") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "clonality") { - const cr = v.clonality; - out.push(`Clonality (FACETS; ${cr.measure === "driver" ? "driver mutation calls" : "all mutation calls"}${cr.qc ? ", QC-passing samples" : ""}):\n` + - cr.panels.map((pn) => ` ${pn.cohort}: WGD ${pn.wgd.yes + pn.wgd.no ? (100 * pn.wgd.yes / (pn.wgd.yes + pn.wgd.no)).toFixed(1) + "%" : "—"} of ${(pn.wgd.yes + pn.wgd.no).toLocaleString()} samples\n` + - pn.genes.map((g) => ` ${g.gene}: ${g.calls} calls, ${g.clonal + g.subclonal ? Math.round(100 * g.clonal / (g.clonal + g.subclonal)) + "% clonal" : "—"}` + - ` (${g.clonal} clonal, ${g.subclonal} subclonal, ${g.indeterminate} indeterminate), LOH ${g.lohKnown ? Math.round(100 * g.loh / g.lohKnown) + "%" : "—"}` + - (g.q != null ? `, q=${pFmt(g.q)}` : "")).join("\n")).join("\n") + - (cr.wgdP != null ? `\n WGD difference: Fisher p=${pFmt(cr.wgdP)}` : "") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "incidence") { - const inc = v.incidence; - out.push(`Cumulative incidence of ${inc.eventLabel}${inc.anchoredTo ? " from " + inc.anchoredTo : ""}, death competing (Aalen-Johansen):\n` + - inc.curves.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()}, ${c.events} events, ${c.competing} died first, ${c.censored} censored; ` + - `not at risk: ${c.excluded.prior} already had it, ${c.excluded.unobserved} before observation, ${c.excluded.noAnchor} no anchor, ${c.excluded.noFollowup} no follow-up`).join("\n") + "\n" + - inc.horizons.map((h) => ` at ${h.months} months: ` + h.cells.map((c) => `${c.cohort} ${(100 * c.f1).toFixed(1)}% (95% CI ${(100 * c.lo).toFixed(1)}-${(100 * c.hi).toFixed(1)}; 1-KM would say ${(100 * c.naive).toFixed(1)}%)`).join(" | ")).join("\n") + - (inc.gray ? `\n Gray's test: statistic ${inc.gray.statistic.toFixed(2)}, permutation p=${pFmt(inc.gray.p)} (${inc.gray.permutations} shuffles)` : "") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "riskmodel" && v.riskmodel) { - const m = v.riskmodel; - const pc = (x: number | null) => x == null ? "n/a" : Math.round(x * 100) + "%"; - out.push(`Risk model (elastic-net Cox, ${m.runs} held-out splits, alpha ${m.alpha}, seed ${m.seed}; ${m.n.toLocaleString()} patients, ${m.events.toLocaleString()} events, ` + - `${m.candidates} candidate genes${m.strata ? ", stratified by " + m.strata : ""}${m.anchoredTo ? ", clock from " + m.anchoredTo : ""}):\n` + - ` held-out C-index ${m.cIndex.median.toFixed(3)} (IQR ${m.cIndex.q1.toFixed(3)}–${m.cIndex.q3.toFixed(3)})` + - (m.validation ? `; trained on the discovery half only (${m.validation.discovery} patients), C-index on the validation half ${m.validation.cIndex.toFixed(3)} (${m.validation.validation} patients)` : "") + "\n" + - (m.covariates.length ? " adjusted for: " + m.covariates.map((c) => `${c.name} HR ${c.hr.toFixed(3)}`).join(", ") + "\n" : "") + - ` risk score on OncoCast's 0–10 scale (0 and 10 the lowest and highest held-out score${m.validation ? " in the discovery half" : ""}; raw ${m.scale.min.toFixed(3)} to ${m.scale.max.toFixed(3)})\n` + - ` risk groups (${m.reportedOn}${m.cutsChosen ? `; cut at the view's chosen quantiles ${m.cutsChosen.join(", ")}` : ", equal quantiles"}):\n` + - m.groups.map((g) => ` ${g.name} (score ${riskSpan(m, g)}): n=${g.curve.n}, ${g.curve.events} events, median ${g.curve.median == null ? "not reached" : g.curve.median.toFixed(1) + "mo"}, ` + - `12-month ${pc(g.at12)}, 36-month ${pc(g.at36)}` + (g.hr != null ? `, HR vs lowest ${g.hr.toFixed(2)} (95% CI ${g.ciLow!.toFixed(2)}–${g.ciHigh!.toFixed(2)}, ${pFmt(g.p!)})` : "")).join("\n") + - (m.logrank ? `\n log-rank p=${pFmt(m.logrank.p)}` : "") + - (m.refit ? `\n risk score alone: HR ${m.refit.hr.toFixed(2)} per SD (95% CI ${m.refit.ciLow.toFixed(2)}–${m.refit.ciHigh.toFixed(2)}, ${pFmt(m.refit.p)})` : "") + - "\n genes kept most often: " + m.features.slice(0, 15).map((f) => `${f.gene} ${Math.round(f.selected * 100)}% HR ${f.hr.toFixed(2)}`).join(", ") + - (m.neverHeldOut ? `\n ${m.neverHeldOut} patients were never held out, so have no score` : "") + - (m.external ? `\n external validation on ${m.external.cohort} (${m.external.study}; ${m.external.n} patients scored, ${m.external.notCovered} not covered by the model's genes): ` + - `C-index ${m.external.cIndex.toFixed(3)} (95% bootstrap CI ${m.external.cIndexLo.toFixed(3)}–${m.external.cIndexHi.toFixed(3)})` + - (m.external.logrank ? `, log-rank p=${pFmt(m.external.logrank.p)}` : "") + "\n" + - m.external.groups.map((g) => ` ${g.name}: n=${g.curve.n}, ${g.curve.events} events, median ${g.curve.median == null ? "not reached" : g.curve.median.toFixed(1) + "mo"}` + - (g.hr != null ? `, HR vs lowest ${g.hr.toFixed(2)} (95% CI ${g.ciLow!.toFixed(2)}–${g.ciHigh!.toFixed(2)})` : "")).join("\n") : "") + - caveatOf(v) + methodOf(v) + noteOf(v)); - } else if (v.kind === "cutpoint") { - const m = v.cutpoint; - const side = (s: { n: number; events: number; median: number | null }) => `n=${s.n}, ${s.events} events, median ${s.median == null ? "not reached" : s.median.toFixed(1) + "mo"}`; - out.push(`Cut-point of ${m.attribute} on ${m.endpoint} (maximally selected log-rank, ${m.candidates.length} candidate cuts between the 10th and 90th percentiles; ${m.n.toLocaleString()} patients, ${m.events.toLocaleString()} events):\n` + - ` best cut ${m.attribute} > ${m.cut}: high ${side(m.high)}; low ${side(m.low)}` + (m.hr ? `; HR ${m.hr.hr.toFixed(2)} (95% CI ${m.hr.ciLow.toFixed(2)}-${m.hr.ciHigh.toFixed(2)})` : "") + "\n" + - ` corrected p=${pFmt(m.permP)} (${m.permutations} permutations, seed ${m.seed}); naive log-rank p=${pFmt(m.naiveP)}, which ignores the search` + - (m.strata ? "\n per " + m.strata.attribute + ":\n" + m.strata.rows.map((s) => ` ${s.stratum}: n=${s.n}, cut ${s.cut}, high ${s.high.n}, ` + - (s.hr ? `HR ${s.hr.hr.toFixed(2)}, ` : "") + `corrected p=${pFmt(s.permP)}, naive p=${pFmt(s.naiveP)}`).join("\n") : "") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "hla") { - const m = v.hla, pc = (a: number, b: number) => b ? (100 * a / b).toFixed(1) + "%" : "—"; - const hr = (h: { hr: number; ciLow: number; ciHigh: number; p: number; n: number } | null) => h ? `HR ${h.hr.toFixed(2)} (95% CI ${h.ciLow.toFixed(2)}-${h.ciHigh.toFixed(2)}, p=${pFmt(h.p)}, n=${h.n})` : "n/a"; - const split = (name: string, s: (typeof m.panels)[number]["byLoh"], why?: string) => s - ? `\n ${m.endpoint} by ${name}: ` + s.curves.map((c) => `${c.label} n=${c.n}, ${c.events} events, median ${c.median == null ? "not reached" : c.median.toFixed(1) + "mo"}`).join("; ") + - (s.logrank ? `; log-rank p=${pFmt(s.logrank.p)}` : "") + `; ${hr(s.hr)}` + (m.covariates.length ? `; adjusted for ${m.covariates.join(", ")} ${hr(s.adjusted)}` : "") - : `\n ${m.endpoint} by ${name} not drawn: ${why || "no data"}`; - out.push(`HLA class I genotype (alleles compared at ${m.resolution === "full" ? "full" : m.resolution + "-field"} resolution) and HLA LOH:\n` + - m.panels.map((pn) => ` ${pn.cohort} (${pn.typed.toLocaleString()} of ${pn.patients.toLocaleString()} patients typed at A, B and C): homozygous at 0 loci ${pc(pn.homozygous[0], pn.typed)}, ` + - `1 ${pc(pn.homozygous[1], pn.typed)}, 2+ ${pc(pn.homozygous[2] + pn.homozygous[3], pn.typed)}; ` + pn.loci.map((l) => `HLA-${l.locus} ${pc(l.homozygous, l.typed)}`).join(", ") + - `; HLA LOH ${pn.loh.toLocaleString()} of ${pn.lohEvaluated.toLocaleString()} with a call (${pc(pn.loh, pn.lohEvaluated)})` + - split("homozygosity (≥1 locus vs none)", pn.byHomozygosity, pn.byHomozygosityWhy) + split("HLA LOH", pn.byLoh, pn.byLohWhy)).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "predictive") { - const m = v.predictive; - const eff = (e: { hr: number; ciLow: number; ciHigh: number; p: number } | null) => e ? `HR ${e.hr.toFixed(2)} (95% CI ${e.ciLow.toFixed(2)}-${e.ciHigh.toFixed(2)}, ${pFmt(e.p)})` : "n/a"; - out.push(`Predictive biomarkers: gene x ${m.exposure} on ${m.endpoint} (Cox per gene; ${m.n.toLocaleString()} patients, ${m.exposed.toLocaleString()} exposed, ${m.events.toLocaleString()} events` + - (m.covariates.length ? `; adjusted for ${m.covariates.join(", ")}` : "") + `):\n` + - m.genes.map((g) => ` ${g.gene}: mutated ${g.mutatedExposed}/${g.exposed} exposed, ${g.mutatedUnexposed}/${g.unexposed} unexposed; ` + - (g.interaction ? `among exposed ${eff(g.inExposed)}; among unexposed ${eff(g.inUnexposed)}; interaction ${eff(g.interaction)}, q=${pFmt(g.q!)}` : `not fitted: ${g.skipped}`)).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "rmst") { - const r = v.rmst; - out.push(`Restricted mean survival time, ${r.endpointLabel}:\n` + - r.panels.map((pn) => ` ${pn.landmark ? `from the ${pn.landmark}-month landmark` : "from the start"}, tau=${pn.tau} months${pn.tauSet ? "" : " (default: every cohort >= 10 at risk)"}:\n` + - pn.curves.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()}, RMST ${rmstText(c)}` + - ((x) => x ? `; vs ${x.vs} difference ${rmstDiffText(x)}, ratio ${rmstRatioText(x)}` : "")(pn.comparisons.find((x) => x.cohort === c.cohort))).join("\n")).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "multistate") { - const m = v.multistate; - out.push(`Multistate (illness-death) model, months from ${m.origin}:\n` + - m.cohorts.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()} (${c.prevalent.toLocaleString()} progressed by day 0); ` + - c.rates.map((r) => `${MS_TRANSITIONS[r.key]} ${r.events} events, ${msRateText(r)} per 100 person-months`).join("; ") + - `\n in each state (not progressed / progressed / dead): ` + - c.atHorizon.map((h) => `${h.months} mo ` + (h.p0 == null ? "n/a" : `${(100 * h.p0).toFixed(0)}% / ${(100 * h.p1!).toFixed(0)}% / ${(100 * h.p2!).toFixed(0)}%`)).join(", ")).join("\n") + - m.cox.map((x) => `\n ${MS_TRANSITIONS[x.key]}: ` + (x.noFit ? `no hazard ratio (${x.noFit})` : - x.rows.map((r) => `${r.cohort} vs ${m.reference} HR ${r.hr.toFixed(2)} (95% CI ${r.lo.toFixed(2)}-${r.hi.toFixed(2)}, ${pFmt(r.p)})`).join("; "))).join("") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "txendpoints") { - const t = v.txendpoints; - out.push(`Treatment endpoint: ${t.endpointLabel} from ${t.anchoredTo} (Kaplan-Meier):\n` + - t.curves.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()}, ${c.events} events, ${txMedianText(c)}; ` + - `${c.anchored} of ${c.cohortPatients} anchored; left out ${c.excluded.noAnchor} without the anchor, ${c.excluded.noFollowup} without follow-up` + - (t.endpoint === "tot" ? `, ${c.excluded.noStop} without a stop date` : "")).join("\n") + - (t.logrank ? `\n log-rank chi2=${t.logrank.chi2.toFixed(2)} (${t.logrank.df} df), p=${pFmt(t.logrank.p)}` : t.noTest ? `\n ${t.noTest}` : "") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "labs") { - const lb = v.labs; - out.push(`Lab course: ${lb.test} % change from baseline, weeks from ${lb.anchoredTo}:\n` + - lb.panels.map((pn) => ` ${pn.cohort}: ${pn.anchored.toLocaleString()} started, ${pn.baseline.toLocaleString()} with a baseline, ${pn.evaluable.toLocaleString()} measured after; ` + - `${pn.evaluable ? (100 * pn.responders / pn.evaluable).toFixed(1) : "—"}% fell ≥${lb.responseDrop}% (${pn.responders}/${pn.evaluable})\n` + - ` median [IQR] by week: ` + pn.course.filter((c) => c.n >= 5).map((c) => `${c.week}w ${c.median}% [${c.q1}, ${c.q3}]`).join(", ")).join("\n") + - (lb.responseP != null ? `\n response rates differ: Fisher p=${pFmt(lb.responseP)}` : "") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "enrichment") { - const en = v.enrichment; - out.push(`Alteration enrichment (${en.alteration}${en.measure === "driver" ? ", drivers only" : ""}; ${en.test}, BH q over ${en.tested.toLocaleString()} genes; ${en.significant} at q<0.05):\n` + - en.rows.slice(0, 40).map((g) => ` ${g.gene}: ` + g.cells.map((c) => `${c.cohort} ${c.pct == null ? "—" : c.pct + "%"} (${c.altered}/${c.profiled})`).join(" | ") + - (g.log2Ratio != null && g.log2Ratio !== undefined ? `, log2 ratio ${g.log2Ratio.toFixed(2)}` : "") + `, q=${pFmt(g.q)}, enriched in ${g.enrichedIn}`).join("\n") + - (en.rows.length > 40 ? `\n … ${en.tested - 40} more genes tested` : "") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "assays") { - const as = v.assays; - const cell = (c: AssaysEntity["cells"][number]) => as.datatype === "numeric" - ? `${c.cohort} mean ${c.mean == null ? "—" : c.mean.toPrecision(3)} (n=${c.n})` - : `${c.cohort} ` + Object.entries(c.values || {}).map(([k, n]) => `${k} ${c.n ? (100 * n / c.n).toFixed(1) : "—"}%`).join(", ") + ` (n=${c.n})`; - out.push(`Generic-assay comparison, ${as.profileName} (${as.profileType}; ${as.datatype}, per ${as.unit.replace(/s$/, "")}; ${as.test}, BH q over ${as.tested.toLocaleString()} entities; ${as.significant} at q<0.05):\n` + - as.rows.slice(0, 40).map((g) => ` ${g.name}: ` + g.cells.map(cell).join(" | ") + - (g.diff != null ? `, difference ${g.diff.toPrecision(3)}` : "") + `, q=${pFmt(g.q)}, higher in ${g.higherIn}${g.value ? " (" + g.value + ")" : ""}`).join("\n") + - (as.rows.length > 40 ? `\n … ${as.tested - 40} more entities tested` : "") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "composition" && v.composition) { - const cm = v.composition; - const fmt = (x: number) => cm.normalize ? (100 * x).toFixed(1) + "%" : x.toPrecision(3); - out.push(`Composition of ${cm.sourceName} (${cm.parts.length} parts${cm.normalize ? ", shares of each " + cm.unit.replace(/s$/, "") + "'s total" : ""}; ` + - `each cohort against the others by Welch's t, BH q over ${cm.tested} comparisons; ${cm.significant} at q<0.05):\n` + - cm.groups.map((g) => ` ${g.cohort} (n=${g.n}${g.entropy ? ", entropy " + g.entropy.mean.toFixed(2) : ""}): ` + - cm.parts.map((pt) => { const c = cm.cells.find((x) => x.cohort === g.cohort && x.part === pt.id)!; - return `${pt.name} ${fmt(c.mean)}` + (c.q != null ? ` (log2 ${c.log2!.toFixed(2)}, q=${pFmt(c.q)})` : ""); }).join(", ")).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "correlates") { - const co = v.correlates; - const line = (g: CorrelatesGene) => ` ${g.gene}: ρ ${g.rho} (q=${pFmt(g.q)}), r ${g.r}, n=${g.n}`; - out.push(`Genes co-expressed with ${co.gene} (${co.profileType}${co.otherProfile !== co.profileType ? " against " + co.otherProfile : ""}) in ${co.cohort}; ` + - `Spearman ρ${co.rhoFrom === "meta" ? " combined across studies by Fisher z" : ""}, BH q over ${co.tested.toLocaleString()} genes; ${co.significant.toLocaleString()} at q<0.05:\n` + - ` most positively correlated:\n` + co.positive.map(line).join("\n") + - `\n most negatively correlated:\n` + co.negative.map(line).join("\n") + caveatOf(v) + noteOf(v)); - } else if (v.kind === "mutations" || v.kind === "table") { - const t = (v.kind === "mutations" ? v.mutations : v.table)!; - const title = v.kind === "mutations" ? `${v.mutations.gene} mutations` : `Clinical data table (one row per ${v.table.unit})`; - out.push(`${title}: ${t.total < t.all ? `${t.total.toLocaleString()} of ${t.all.toLocaleString()} rows kept by the query` : `${t.total.toLocaleString()} rows`}; ` + - tableText(t) + caveatOf(v) + noteOf(v)); - } else if (v.kind === "timeline" && v.timeline) { - const t = v.timeline, pt = t.patient; - const head = `Patient timeline — patients to choose from: ${t.total < t.all ? `${t.total.toLocaleString()} of ${t.all.toLocaleString()} kept by the query` : `${t.total.toLocaleString()}`}; ` + tableText(t); - if (!pt) out.push(head + "\n no patient drawn: the list is empty" + caveatOf(v) + noteOf(v)); - else { - const shown = pt.events.slice(0, TIMELINE_TEXT_EVENTS); - const line = (e: (typeof pt.events)[number]) => ` day ${e.start}${e.end != null && e.end > e.start ? `–${e.end}` : ""}: ${e.type}` + - (e.attrs.length ? " — " + e.attrs.filter(([k]) => !/^STYLE_/i.test(k)).map(([k, x]) => `${k}=${x}`).join(", ") : ""); - out.push(head + `\n${pt.patient} (${pt.study}${pt.source.remote ? ", from " + pt.source.label + " over MCP" : ""}): ${pt.events.length} events, days from diagnosis; ` + - `samples: ${pt.samples.map((x) => x.id + (x.type ? " (" + x.type + ")" : "")).join(", ") || "none"}\n` + - shown.map(line).join("\n") + - (pt.events.length > shown.length ? `\n … ${pt.events.length - shown.length} later events not listed here (the figure draws all of them)` : "") + - caveatOf(v) + noteOf(v)); - } - } else if (v.kind === "alterations" && v.alterations) { - const a = v.alterations, pt = a.patient; - const head = `Patient alterations — patients to choose from: ${a.total < a.all ? `${a.total.toLocaleString()} of ${a.all.toLocaleString()} kept by the query` : `${a.total.toLocaleString()}`}; ` + tableText(a); - if (!pt) out.push(head + "\n no patient listed: the list is empty" + caveatOf(v) + noteOf(v)); - else { - const names: Record = { mutations: "Mutations", cna: "Copy Number Alterations", sv: "Structural Variants" }; - out.push(head + `\n${pt.id} (${pt.study}); samples: ${pt.samples.map((x) => x.id + (x.type ? " (" + x.type + ")" : "")).join(", ") || "none"}` + - Object.entries(a.tables).map(([k, t]) => `\n${names[k] || k}: ${t!.total < t!.all ? `${t!.total.toLocaleString()} of ${t!.all.toLocaleString()} rows kept by the query` : `${t!.total.toLocaleString()} rows`}; ` + tableText(t!)).join("") + - caveatOf(v) + noteOf(v)); - } - } else if (v.kind === "overlap") { - const ov = v.overlap; - out.push(`Cohort overlap (patients in exactly these cohorts and no other):\n` + - ` cohort sizes: ` + ov.sets.map((x) => `${x.name} ${x.size.toLocaleString()}`).join(", ") + `\n` + - ov.intersections.map((x) => ` ${x.members.join(" ∩ ")}${x.members.length < ov.sets.length ? " only" : ""}: ${x.n.toLocaleString()}`).join("\n") + - `\n ${ov.union.toLocaleString()} patients in at least one cohort` + caveatOf(v) + noteOf(v)); - } else if (v.kind === "cancertypes") { - const ct = v.cancertypes; - const line = (g: { label: string; altered: number; profiled: number; mutation: number; amp: number; homdel: number; sv: number; multiple: number }) => - ` ${g.label}: ${(100 * g.altered / (g.profiled || 1)).toFixed(1)}% (${g.altered}/${g.profiled}; mutation ${g.mutation}, amp ${g.amp}, deep del ${g.homdel}, SV ${g.sv}, multiple ${g.multiple})`; - out.push(`Cancer types summary: ${ct.genes.join(", ")} by ${ct.groupBy} (${ct.measure === "driver" ? "drivers" : "any alteration"}; samples profiled for at least one gene):\n` + - ct.panels.map((pn) => ` ${pn.cohort}: ${pn.altered.toLocaleString()} of ${pn.profiled.toLocaleString()} profiled samples altered\n` + - [...pn.groups, ...(pn.pooled ? [pn.pooled] : [])].map(line).join("\n")).join("\n") + - caveatOf(v) + noteOf(v)); - } else if (v.kind === "attrition") { - out.push("Cohort attrition (patients left after each clause, in order):\n" + v.attrition.map((pn) => - ` ${pn.cohort}: study base ${pn.base.toLocaleString()}\n` + - pn.steps.map((st) => ` → ${st.label}: ${st.n.toLocaleString()} (−${st.excluded.toLocaleString()})`).join("\n")).join("\n") + - caveatOf(v) + noteOf(v)); - } else { - // A view kind with no branch above used to append nothing at all: the - // numbers were computed and the model was simply never told. Saying so - // is worse than a proper description but far better than silence. + if (kindOf(v.kind)?.text?.(v, { out, pFmt, caveatOf, noteOf, plotUnit, methodOf }) === false || !kindOf(v.kind)?.text) { + // A view kind with no text for this result would otherwise append nothing at all: the + // numbers were computed and the model was simply never told. Saying so is worse than a + // proper description but far better than silence. out.push(`View "${v.kind}" ran but this tool has no text rendering for it yet — ` + `open the interactive editor link below to see it.`); } @@ -560,13 +79,3 @@ export function analysisText(document: AnalysisDoc, r: AnalysisResult): string[] } return out; } - -/** A table view's first rows, shown columns only, tab-separated — the web app's view pages and downloads every row. */ -function tableText(t: { columns: { id: string }[]; shown: string[]; rows: { cohort: string; values: unknown[] }[]; total: number }): string { - const idx = t.shown.map((id) => t.columns.findIndex((c) => c.id === id)).filter((i) => i >= 0); - const shown = t.rows.slice(0, 50); - return `the first ${shown.length}, tab-separated (the web app's view pages and downloads every row, with every column):\n` + - [["cohort", ...idx.map((i) => t.columns[i].id)], ...shown.map((r) => [r.cohort, ...idx.map((i) => r.values[i])])] - .map((xs) => " " + xs.map((x) => String(x ?? "").replace(/[\t\n]+/g, " ")).join("\t")).join("\n") + - (t.total > shown.length ? `\n … ${(t.total - shown.length).toLocaleString()} more rows` : ""); -} diff --git a/lib/view-coverage.ts b/lib/view-coverage.ts index 453a09a..d060ef2 100644 --- a/lib/view-coverage.ts +++ b/lib/view-coverage.ts @@ -5,6 +5,8 @@ // is not drawn: the engine refuses it (lib/analysis.ts) and the page does not offer it // (app/lab/use-view-options.ts, use-view-edits.ts), both from this one rule. Browser-safe. +import { kindOf } from "./views/kinds.ts"; + /** How this app reaches one study, as far as drawing views goes. */ export interface StudyAccess { /** the instance that answers for it, as the page names it */ @@ -17,21 +19,11 @@ export interface StudyAccess { shares: string[]; } -/** What a view kind needs of every study it draws; a kind not listed needs only counts. */ -type Need = "patients" | "timeline" | "alterations" | "local"; -const NEEDS: Record = { - // their output is patients: columns, rows, or a model fitted on each patient - oncoprint: "patients", table: "patients", mutations: "patients", cox: "patients", logistic: "patients", riskmodel: "patients", predictive: "patients", cutpoint: "patients", hla: "patients", forest: "patients", adjusted: "patients", - // patient views a peer may share by kind - timeline: "timeline", alterations: "alterations", - // aggregates every local participant computes and a remote peer does not serve yet - genome: "local", pathways: "local", enrichment: "local", diffexpr: "local", paired: "local", clonality: "local", - incidence: "local", labs: "local", cancertypes: "local", overlap: "local", assays: "local", composition: "local", - correlates: "local", plot: "local", acquired: "local", ctdna: "local", exclusivity: "local", txendpoints: "local", multistate: "local", rmst: "local", instability: "local", actionability: "local", drivernegative: "local", organotropism: "local", molsubtype: "local", germline: "local" -}; +/** What a view kind needs of every study it draws (its descriptor says); a kind without a need needs only counts. */ +export type Need = "patients" | "timeline" | "alterations" | "local"; -/** What `kind` needs of every study it draws (null: only counts, which every participant gives). */ -export const viewNeed = (kind: string): Need | null => NEEDS[kind] ?? null; +/** What `kind` needs of every study it draws (its descriptor's `need`; null: only counts, which every participant gives). */ +export const viewNeed = (kind: string): Need | null => kindOf(kind)?.need ?? null; const why = (need: Need, a: StudyAccess): string | null => { if (need === "local") return a.remote ? "is a remote peer, which does not answer this view yet" : null; @@ -47,7 +39,7 @@ const why = (need: Need, a: StudyAccess): string | null => { * when it can. A study whose access is unknown (`access` returns undefined) is not held against it. */ export function cantDraw(kind: string, studies: string[], access: (study: string) => StudyAccess | undefined): string | null { - const need = NEEDS[kind]; + const need = viewNeed(kind); if (!need) return null; const out = studies.flatMap((s) => { const a = access(s); diff --git a/lib/view-help.ts b/lib/view-help.ts index 2f0684e..1fab8e1 100644 --- a/lib/view-help.ts +++ b/lib/view-help.ts @@ -4,199 +4,9 @@ // for someone writing or reading an analysis document, scientist or not). Shown from a "?" button // in each view's own header (ViewHead in app/lab/page.tsx), so it travels with every view kind // wherever ViewHead is used — chat, MCP, and the app all share that one component. +import { KINDS } from "./views/kinds.ts"; + export interface ViewHelp { title: string; text: string } -export const VIEW_HELP: Record = { - compare: { - title: "What is this?", - text: "Compares how often specific genes are altered — mutated, gained, or lost — between two or more groups of patients. If a gene is altered much more often in one group than another, that's a clue it might be connected to whatever makes the groups different, like a cancer subtype or how patients respond to treatment." - }, - survival: { - title: "What is this?", - text: "Shows what share of patients in each group were still alive (or still free of disease progression) at each point in time, drawn as a curve that steps downward every time a patient's outcome is recorded. Comparing the curves for two or more groups answers: did one group tend to do better than the other, and if so, is the difference too large to be chance?" - }, - cox: { - title: "What is this?", - text: "A more advanced version of the survival comparison above, which can weigh several factors at once — for example, a patient's age, a specific gene, and how far the disease had spread — to estimate how much each one changes risk on its own, holding the others equal. Each result is a \"hazard ratio\": above 1 means that factor is linked to worse outcomes, below 1 means it's linked to better ones." - }, - logistic: { - title: "What is this?", - text: "Estimates how strongly different factors are linked to a yes-or-no outcome — for example, whether a patient responded to a treatment — while accounting for several factors together. Each result is an \"odds ratio\": above 1 means that factor makes the outcome more likely, below 1 means less likely." - }, - oncoprint: { - title: "What is this?", - text: "A grid with one column per patient and one row per gene, color-coded to show what kind of genetic change (if any) each patient has in each gene. It's a fast way to see patterns across many patients and genes at once — for example, whether certain alterations tend to show up in the same patients, or never together." - }, - hotspots: { - title: "What is this?", - text: "Shows exactly where along a gene's protein its mutations land, drawn as a lollipop chart — a taller stem means more patients had a mutation at that exact spot. Mutations clustering tightly at one or two spots, rather than scattering evenly, usually points to a location that matters for how the protein works." - }, - coexpression: { - title: "What is this?", - text: "Checks whether two genes tend to be more or less active together across patients — measured by how much each gene is \"read out\" into the working molecules a cell actually uses. Genes that rise and fall together are often working in the same biological pathway, or controlled by the same switch." - }, - clinical: { - title: "What is this?", - text: "Compares things recorded about the patients themselves — age, sex, a lab result, and similar details — rather than their genetics, between two or more groups. It answers whether the groups differ in ways beyond whatever genetic difference defined them, which matters for judging whether a genetic finding is really the explanation." - }, - cooccurrence: { - title: "What is this?", - text: "Checks whether alterations in two genes tend to show up in the same patients more often than you'd expect by chance (\"co-occurring\"), or rarely appear together (\"mutually exclusive\"). Genes that are mutually exclusive often play overlapping roles — if one is already altered, changing the other adds little, so cells rarely need both." - }, - course: { - title: "What is this?", - text: "Shows what share of patients were in each stage of their care — on treatment, in follow-up, and so on — at each point in time after diagnosis or another starting event. It's a picture of the typical patient's journey through care, not any one individual's." - }, - sequence: { - title: "What is this?", - text: "Shows the order in which patients moved from one treatment to the next, drawn as flows between them. It answers a simple but useful question: after this treatment, what did doctors try next, and how often?" - }, - attrition: { - title: "What is this?", - text: "Shows how each group of patients was narrowed down, one criterion at a time: how many patients the study started with, how many were left after each rule in the group's definition, and how many each rule removed. It lets a reader see who was left out before trusting any result drawn from the patients who remain." - }, - genome: { - title: "What is this?", - text: "Walks along every chromosome and shows, at each stretch, what share of the group's tumours have extra copies of that stretch of DNA (drawn upward, in red) or have lost copies of it (drawn downward, in blue). Stretches gained or lost in many tumours often hold genes that help the cancer grow, or genes that would have held it back." - }, - pathways: { - title: "What is this?", - text: "Groups genes into ten well-known signalling pathways — chains of genes that together tell a cell when to grow, divide or die — and shows what share of each group of patients has a harmful change in at least one gene of each pathway. Two cancers can reach the same broken pathway through different genes, and this view makes that visible." - }, - diffexpr: { - title: "What is this?", - text: "Compares how active every gene is between two groups of patients. Each dot is a gene: further left or right means a bigger difference in activity between the groups, higher up means the difference is less likely to be chance. The panel beside it asks whether whole families of related genes, such as those that drive cell division, lean towards one group." - }, - paired: { - title: "What is this?", - text: "For patients who had two kinds of tumour sample tested — usually the original tumour and a later spread of it elsewhere in the body — shows, gene by gene, how often a change was found in both samples, only in the original, or only in the spread. Changes found only in the spread can point to how the cancer adapted, for example to a treatment. The two kinds can also be samples taken before and after a treatment. Without a list of genes, it checks every gene and shows the ones most often found on one side only." - }, - clonality: { - title: "What is this?", - text: "A tumour is a population of cells, and a genetic change can be in all of them (present from early on) or only in some (arriving later). For each gene, this shows how often its changes are in all the tumour's cells, and how often the cell has also lost its backup copy of that gene. Beside it: how often tumours have doubled their entire set of chromosomes." - }, - incidence: { - title: "What is this?", - text: "Shows what share of patients have experienced a particular event — such as the cancer spreading to the brain — by each point in time. It counts patients who died before the event as never having it, rather than pretending they might still, which is why its numbers can be noticeably lower, and more honest, than a simple survival-style curve would suggest." - }, - enrichment: { - title: "What is this?", - text: "Checks every gene at once for whether it is changed more often in one group of patients than another — mutated, gained, deleted or rearranged, whichever you choose. The genes at the top of the list, and furthest out on the chart, differ most clearly between the groups; the share in each group is beside every gene, so a difference that is clear but small is visible as small." - }, - assays: { - title: "What is this?", - text: "Compares two or more groups on every measurement in one kind of extra lab data the study recorded — such as which mutational signatures (like sun damage or smoking) shaped each tumour, or which chromosome arms were gained or lost. The measurements at the top differ most clearly between the groups; each one shows its average, or its mix of results, in every group, so a difference that is clear but small reads as small." - }, - composition: { - title: "What is this?", - text: "Shows what each patient's (or sample's) tumour is made of — the share of each cell type, cell state or signature — as one stacked bar per case, sorted by the part you choose, with each group's average beside it. Under the bars, every group is set against the others part by part: the colour says whether a part is higher (red) or lower (blue) there, its size how sure that is. Parts of a whole move together, so one part rising means others fall." - }, - mutations: { - title: "What is this?", - text: "Lists every mutation found in one gene in your groups, one row per mutation: which sample it was in, the exact protein change, whether OncoKB considers it cancer-causing, what kind of change it is, and how much of the tumour carries it. Click a column to sort by it; the download saves every row. It sits under the lollipop chart, which shows the same mutations as positions along the protein." - }, - table: { - title: "What is this?", - text: "Lists every sample (or every patient) in your groups with everything the study recorded about them — cancer type, age, stage, survival and the rest — one row each. Click a column's name to sort by it; the download button saves every row as a file a spreadsheet can open. Studies that only share counts, never individual patients, are named rather than listed." - }, - timeline: { - title: "What is this?", - text: "Shows one patient's medical history on a single line of time, as cBioPortal's patient page does: when samples were taken, operations, each treatment and how long it lasted, diagnoses, and lab results drawn as small charts. Time is counted from the day of diagnosis, and long stretches with nothing recorded are cut short (the dashed line). Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one. Studies that only share counts, never individual patients, are named rather than listed." - }, - alterations: { - title: "What is this?", - text: "Lists one patient's genetic changes as cBioPortal's patient page does, in three tables: mutations (the gene, the protein change, whether OncoKB considers it cancer-causing, and how much of the tumour carries it), copy-number changes (genes amplified or deleted outright) and structural variants (fusions and rearrangements). A change found in several of the patient's samples is one row, with a numbered marker for each sample. Cohort % says how common the same kind of change in that gene is across the study. Each table can be searched, filtered, sorted and downloaded on its own. Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one." - }, - correlates: { - title: "What is this?", - text: "Takes one gene and checks every other gene for whether its activity rises and falls along with it across these patients. The genes at the top go up when it goes up; those at the bottom go down when it goes up. The chart on the left shows how all genes fall: most barely move with it, and the ones far out on either side are the ones listed. Moving together is a clue, not proof that one gene controls the other." - }, - overlap: { - title: "What is this?", - text: "Shows how the groups of patients you defined overlap: for every combination of groups, how many patients belong to exactly those groups and none of the others. Each column is one combination — the dots below it say which groups — and its bar is how many patients fall there, so you can see at a glance whether two groups are mostly the same people or nearly separate." - }, - cancertypes: { - title: "What is this?", - text: "For a list of genes, shows what share of samples in each cancer type carry a change in at least one of them — mutated, gained in many copies, deleted, or rearranged — with each bar split by which kind of change it was. It answers which cancers these genes matter most in, the way cBioPortal's Cancer Types Summary does." - }, - riskmodel: { - title: "What is this?", - text: "A model that predicts survival from which genes are mutated in each patient, in the style of the OncoCast package. It is trained many times, each time on two thirds of the patients and tested on the other third, so every patient's risk score comes from a model that never saw them. Patients are then split into low, intermediate and high risk, and their survival curves are drawn. The list shows the genes the model relied on most, with a hazard ratio above 1 meaning higher risk. Tick \"validate\" to train on one half of the patients and grade the model on the other half, which it never saw, or name another group of patients under \"validate on\" to grade it there — for example patients from a different study — with the same risk-group boundaries." - }, - acquired: { - title: "What is this?", - text: "For patients who had a tumour sample tested before a treatment started and another one afterwards, shows gene by gene how many had a change only in the later sample (acquired, to the right) or only in the earlier one (lost, to the left). Genes that keep turning up only after treatment can point to how tumours resist it. It cannot say the treatment caused the change: second biopsies are usually taken when the cancer grows again, and a change can simply have been missed the first time." - }, - ctdna: { - title: "What is this?", - text: "Tumours shed DNA into the blood, and a blood test (a \"liquid biopsy\") can read it. For patients who had both a blood sample and a tumour tissue sample tested, this shows gene by gene how often a change was found in both, only in the tissue, or only in the blood — and how often the blood test caught what the tissue showed. Below, it shows how many patients had tumour DNA detected in their blood at all, and whether those patients lived shorter or longer than those without it. With the clonal-haematopoiesis filter on, changes that probably come from ageing blood cells rather than the tumour — in genes such as DNMT3A and TET2, or also found in the patient's white blood cells — are left out first, and the figure says how many were removed and what the numbers were without the filter." - }, - exclusivity: { - title: "What is this?", - text: "Asks, for each pair of genes, whether tumours carry changes in both more or less often than chance — \"mutually exclusive\" genes are rarely changed together, which can mean either change alone is enough to drive the cancer. Its sense of \"chance\" accounts for how many changes each tumour has overall: a tumour with hundreds of changes is expected to share genes with many others, so it does not make two genes look linked just because they both turn up in heavily altered tumours. Beside it is the simpler test that ignores this, to show the difference." - }, - hla: { - title: "What is this?", - text: "HLA class I molecules (HLA-A, -B and -C) show pieces of a tumour's proteins to the immune system. Everyone inherits two copies of each; a person who inherited two identical copies at a gene (homozygous) shows a narrower range of pieces, and a tumour can also lose one copy (HLA loss of heterozygosity) to hide. This shows how common each is in a cohort, and whether patients homozygous at any of the three genes, or whose tumour lost a copy, lived shorter or longer. In patients treated with immunotherapy, homozygosity has been linked to worse survival; in a cohort not limited to them the comparison describes prognosis, not response to treatment." - }, - cutpoint: { - title: "What is this?", - text: "Looks for the value of a measurement — such as tumour mutational burden — that best splits patients into a group that did better and a group that did worse. It tries every reasonable dividing line (from the lowest tenth of patients to the highest) and draws how strongly each one separates survival; the dot marks the best. Because trying many lines and keeping the best always finds something, the view also reshuffles the measurement among the patients a thousand times to see how good a 'best line' chance alone produces, and reports how surprising the real best line is once that search is accounted for. A line found this way needs checking in other patients before anyone relies on it." - }, - predictive: { - title: "What is this?", - text: "Asks, gene by gene, whether a mutation changes how patients do on a particular treatment — not just whether it marks a better or worse outlook in general. For each gene it compares patients with and without the mutation twice: among those who received the treatment (upper marker) and among those who did not (lower marker). A gene whose markers sit far apart — say, much worse with the mutation only among the treated — may predict who benefits from that treatment; the right-hand column tests that difference. Treatment here was not assigned at random, and patients had to live long enough to receive it, so a finding is a lead to test properly, not proof." - }, - rmst: { - title: "What is this?", - text: "Answers “how many months, on average, did patients in each group live within the first τ months?” — the shaded area under each survival curve up to τ. The difference between two groups is the months gained or lost in that window, which reads plainly even when the curves cross or only separate late (as with immunotherapy), where a single hazard ratio can mislead. τ is where every group still has at least ten patients followed, unless it is set. Landmark panels ask the same question again among the patients still alive and followed at 6 or 12 months, restarting the clock there. The groups were not randomised, so a difference is a clue, not proof." - }, - multistate: { - title: "What is this?", - text: "Follows each patient through three states — alive without progression, alive after their cancer was first recorded as growing, and dead — from the start of their record (in MSK-CHORD, their first tumour sequencing). The coloured bands show, month by month, what share of each group is in each state; the lines below show how fast patients move along each of the three paths (progressing, dying without progressing, dying after progressing), and the table gives each path's rate and how much faster or slower each group moves along it than the first. Splitting death into 'after progression' and 'without it' shows where a group's worse survival comes from. Progression is read from radiology reports by a model, so it is only seen in patients who were scanned; the groups were not randomised, so a difference is a clue, not proof." - }, - txendpoints: { - title: "What is this?", - text: "Starts each patient's clock on the day a treatment began — for example their first immunotherapy — and follows them to an outcome that real-world studies use in place of a trial's: when they moved on to their next treatment (time to next treatment), when they stopped this one (time on treatment), or when their cancer was recorded as growing (real-world progression-free survival). Death counts as the outcome too, and patients still followed without it are counted up to their last visit. Each group's curve shows the share not yet there at each month, with the number still followed below. The groups were not randomised to the treatment, so a gap between curves is a clue, not proof that a gene or a group changes how well the treatment works." - }, - forest: { - title: "What is this?", - text: "Asks whether one thing — a gene being mutated, a high tumour mutation burden — goes with shorter or longer survival, separately in each cancer type, and then combines those answers. Each row is one cancer type: its square is the hazard ratio (right of the dashed line at 1 means patients with the change died sooner; left means they lived longer) and its line how sure we can be. The diamonds at the bottom combine the rows into one overall estimate. When the rows disagree a lot (a high I²), the overall estimate is an average of different effects, so read the rows. This shows an association, not a cause." - }, - adjusted: { - title: "What is this?", - text: "Compares how often each commonly mutated gene is mutated in two groups of patients — say, colorectal cancers diagnosed before and after age 50 — after taking into account other things that differ between the groups and also change mutation rates, such as how many mutations a tumour carries overall. Each dot is a gene: right of centre means more often mutated in the first group, left in the second, and higher means more certain. The table shows each gene's raw difference beside the adjusted one, so you can see which differences were explained away. A difference here is a clue, not proof of a cause." - }, - instability: { - title: "What is this?", - text: "Cancer cells often gain or lose whole chromosome arms, and how much of a tumour's genome is out of balance is a measure of how unstable it is. For each group of tumours, this shows how many chromosome arms are typically gained or lost (the \"aneuploidy score\") and what share of the genome is altered, then which arms are gained (red) or lost (blue) most often — patterns that differ by cancer type, such as chromosome 20q gained and 18q lost in colorectal cancer." - }, - actionability: { - title: "What is this?", - text: "For each patient, finds the strongest evidence OncoKB lists for a drug that targets one of their tumour's genetic changes — Level 1 means an FDA-approved drug for that change, down to Level 4 for early biological evidence — and shows what share of patients reach each level, overall or per cancer type. The levels here are the best OncoKB gives the change in ANY cancer, so a change that is only actionable in another cancer still counts: read the bars as an upper bound. The table below compares, for patients sequenced twice, the level found in their primary tumour against their metastasis." - }, - germline: { - title: "What is this?", - text: "Some people are born with a faulty copy of a cancer gene such as BRCA2 in every cell. For each such gene this counts those patients (carriers) among everyone whose tumour was sequenced for it, and asks whether their tumour knocked out the remaining good copy — a second hit — by a further mutation or by deleting the gene, compared with how often the same gene is hit in patients born without a faulty copy. The commonest second hit, losing the good copy outright (loss of heterozygosity), needs data this view does not read, so the second-hit share is a minimum. Optionally the curves compare survival of carriers and non-carriers for one gene." - }, - molsubtype: { - title: "What is this?", - text: "Sorts each patient's endometrial cancer into the four molecular groups doctors now use alongside the microscope: POLE-ultramutated (a tumour with a broken DNA-copying enzyme, which does very well), mismatch-repair deficient (a tumour that cannot fix certain DNA errors, read here from its MSI test), p53-abnormal (the group that does worst), and no specific molecular profile. The groups are assigned in that order, as the WHO guideline does. Patients whose test could not tell — no MSI result, or a gene panel that missed POLE or TP53 — are counted as not classifiable rather than guessed. The bars show each group's share, overall and by the attribute you pick; the curves show survival in each group." - }, - organotropism: { - title: "What is this?", - text: "Asks, for each organ a cancer spread to — brain, liver, bone and the rest — whether patients whose tumour carries a change in a given gene spread there more or less often than patients whose tumour does not, comparing patients within the same cancer type. Red squares mean the gene's change goes with spread to that organ, blue that it goes with spreading there less; outlined squares are the ones unlikely to be chance after testing many pairs. Where the study dates each site, the curve below shows how quickly patients with and without one gene's change develop spread to one organ, counting deaths as a separate outcome. Sites are only known where scans or biopsies found them." - }, - drivernegative: { - title: "What is this?", - text: "Splits one group of patients — say, lung cancers — into those whose tumour carries one of the well-known, usually treatable cancer-driving changes (EGFR, KRAS, ALK and the rest of the list) and those whose tumour carries none, and then asks which other genes are changed more often in the second group. Those genes are candidates for what drives the tumours without a known driver. Patients whose test did not cover every gene on the list are kept apart, since we cannot tell whether they have a driver. The bar shows how big each group is; in the chart, genes to the right are more common without a known driver." - }, - labs: { - title: "What is this?", - text: "Follows a blood test — such as PSA in prostate cancer — from the moment a treatment started, as the change from each patient's own starting level. The line shows the typical patient, the shaded band the middle half of patients, and the bars on the right each patient's best result: how far their level fell, or rose, after treatment began." - }, - plot: { - title: "What is this?", - text: "Plots two measurements against each other for the same patients or samples — for example, a gene's activity level against a patient's age — so you can see whether they tend to rise and fall together, split into distinct clusters, or show no relationship at all." - } -}; +/** every kind's explanation, from its descriptor (lib/views//kind.ts) */ +export const VIEW_HELP: Record = Object.fromEntries(Object.values(KINDS).map((k) => [k.kind, k.help])); diff --git a/lib/view-svg.ts b/lib/view-svg.ts index 895ddfb..8a5ec71 100644 --- a/lib/view-svg.ts +++ b/lib/view-svg.ts @@ -3,7 +3,7 @@ // The engine produces data; a renderer is pure and knows nothing about where the picture is going. // That is what lets one figure appear identically in the web UI (drawn in the browser at its // card's width, app/lab/figure.tsx), in a chat transcript and in an MCP client: there is only -// ever one renderer per kind, in lib/figures/.ts, laid out with lib/chart/ (via +// ever one renderer per kind, its descriptor's figure (lib/views//kind.ts), laid out with lib/chart/ (via // lib/svg-kit.ts). // // Every renderer obeys three rules: @@ -19,65 +19,23 @@ // Colours come from CSS custom properties with literal fallbacks, so an SVG // inlined in the app follows the page theme while the same string rasterised // for a chat gets a fixed light figure. -import { filterHotspotGene } from "./lollipop.ts"; -import { lollipopPanelsSvg, lollipopSummaryText } from "./lollipop-svg.ts"; +import { lollipopSummaryText } from "./lollipop-svg.ts"; export { lollipopSummaryText }; -import { isDrawn, type ViewResult, type DrawnResultOf } from "./types.ts"; -import { kindFigure, kindTitle } from "./figures/index.ts"; -import { survivalSvg } from "./views/survival/figure.ts"; -import { coxSvg, logisticSvg } from "./figures/forest.ts"; -import { cooccurrenceSvg, compareSvg, coexpressionSvg, clinicalSvg } from "./figures/bars.ts"; -import { courseSvg, RISK_FLOOR } from "./views/course/figure.ts"; -import { sequenceSvg } from "./views/sequence/figure.ts"; -import { plotSvg } from "./views/plot/figure.ts"; +import { isDrawn, type ViewResult } from "./types.ts"; +import { kindOf } from "./views/kinds.ts"; +import { RISK_FLOOR } from "./views/course/figure.ts"; export { RISK_FLOOR }; import { esc, SERIES, type SvgOpts } from "./svg-kit.ts"; -import type { HotspotGene } from "./views/hotspots/types.ts"; export { esc, SERIES, type SvgOpts }; -/** The lollipop standing alone (an MCP client's image): the selection applied, the app's controls in words in the caption. */ -function hotspotsSvg(v: DrawnResultOf<"hotspots">, o: SvgOpts): string { - const hide = (v.hide || []) as string[]; - const all = (v.hotspots || []) as HotspotGene[]; - return lollipopPanelsSvg(all.map((g) => filterHotspotGene(g, hide).gene), o, - [v.note || "", lollipopSummaryText(all, hide)].filter(Boolean).join(" ")); -} - export function viewToSvg(v: ViewResult, o: SvgOpts = {}): string | null { if (!v || !isDrawn(v)) return null; - if (v.kind === "oncoprint") return v.svg || null; // already rendered by the contract - if (v.kind === "plot" && v.plot) return plotSvg(v, o) || null; - if (v.kind === "course" && v.course && v.course.length) return courseSvg(v, o); - if (v.kind === "sequence" && v.sequence && v.sequence.panels.some((z) => z.flows.length)) return sequenceSvg(v, o); - if (v.kind === "survival" && v.curves && v.curves.length) return survivalSvg(v, o); - if (v.kind === "cox" && v.cox && (v.cox.coefficients.length || (v.cox.byCohort || []).some((c) => c.coefficients.length))) return coxSvg(v, o); - if (v.kind === "logistic" && v.logistic && v.logistic.coefficients.length) return logisticSvg(v, o); - if (v.kind === "hotspots" && v.hotspots && v.hotspots.length) return hotspotsSvg(v, o); - if (v.kind === "cooccurrence" && v.pairs && v.pairs.length) return cooccurrenceSvg(v, o); - if (v.kind === "compare" && v.rows && v.rows.length) return compareSvg(v, o); - if (v.kind === "coexpression" && v.coexpression && v.coexpression.length) return coexpressionSvg(v, o); - if (v.kind === "clinical" && v.clinical && v.clinical.some((r) => !r.skipped)) return clinicalSvg(v, o); - return kindFigure(v, o); + return kindOf(v.kind)?.figure?.(v, o) ?? null; } /** A one-line title for a figure, for alt text and chat captions. */ export function viewTitle(v: ViewResult): string { const c = (v.cohorts || []).join(" vs "); - const base = v.kind === "survival" ? (v.endpoint || "OS") + " survival" + (v.anchoredTo ? " from " + v.anchoredTo : "") - : v.kind === "cox" ? "Cox regression — " + (v.endpoint || "OS") - : v.kind === "logistic" ? "Logistic regression" - : v.kind === "hotspots" ? "Recurrent protein changes" - : v.kind === "cooccurrence" ? "Co-occurrence and mutual exclusivity" - : v.kind === "coexpression" ? "Co-expression" - : v.kind === "clinical" ? "Clinical comparison" - : v.kind === "sequence" ? "Treatment sequence" + - (v.sequence ? " by " + v.sequence.labelKey : "") - : v.kind === "course" ? "Clinical course" + - (v.course && v.course.length ? " — " + [...new Set(v.course.map((z) => z.eventType))].join(", ") : "") - : v.kind === "compare" ? "Alteration frequencies" - : v.kind === "oncoprint" ? "Oncoprint" - : v.kind === "timeline" ? "Patient timeline" + (v.timeline?.patient ? " — " + v.timeline.patient.patient : "") - : v.kind === "alterations" ? "Patient alterations" + (v.alterations?.patient ? " — " + v.alterations.patient.id : "") - : kindTitle(v) || v.kind; + const base = kindOf(v.kind)?.title?.(v) || v.kind; return c ? base + " — " + c : base; } diff --git a/lib/views/acquired/kind.ts b/lib/views/acquired/kind.ts new file mode 100644 index 0000000..16ebf67 --- /dev/null +++ b/lib/views/acquired/kind.ts @@ -0,0 +1,26 @@ +// The acquired view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { acquiredSvg } from "./figure.ts"; + +export const acquiredKind: ViewKindDef<"acquired"> = { + kind: "acquired", + figure: (v, o) => v.acquired && v.acquired.panels.some((pn) => pn.patients && pn.genes.length) ? acquiredSvg(v, o) : null, + title: (v) => "Acquired under therapy" + (v.acquired ? " — samples before and after " + v.acquired.anchoredTo : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const ar = v.acquired; + out.push(`Acquired under therapy: last sample on or before the first ${ar.anchoredTo} against the first at least ${ar.minGapDays} days after (${ar.measure === "driver" ? "OncoKB oncogenic" : "any alteration"}; both samples' panels cover the gene):\n` + + ar.panels.map((pn) => ` ${pn.cohort} (${pn.anchored.toLocaleString()} started, ${pn.withPre.toLocaleString()} with a sample before, ${pn.withPost.toLocaleString()} after, ${pn.patients.toLocaleString()} with both` + + (pn.gapDays ? `; samples a median ${pn.gapDays.median} days apart` : "") + + (pn.tested != null ? `; ${pn.tested.toLocaleString()} genes tested, the ${pn.genes.length} smallest p listed` : "") + `):\n` + + pn.genes.map((g) => ` ${g.gene}: acquired ${g.acquired}, lost ${g.lost}, shared ${g.shared}, neither ${g.neither} of ${g.pairs} pairs` + + (g.acquired + g.lost ? `, McNemar p=${pFmt(g.p)} q=${pFmt(g.q)}` : "")).join("\n")).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "For patients who had a tumour sample tested before a treatment started and another one afterwards, shows gene by gene how many had a change only in the later sample (acquired, to the right) or only in the earlier one (lost, to the left). Genes that keep turning up only after treatment can point to how tumours resist it. It cannot say the treatment caused the change: second biopsies are usually taken when the cancer grows again, and a change can simply have been missed the first time." + }, + need: "local", + preview: "no method record or golden test of the before/after pairing and McNemar counts yet", + isNew: true, +}; diff --git a/lib/views/actionability/kind.ts b/lib/views/actionability/kind.ts new file mode 100644 index 0000000..d82ec6d --- /dev/null +++ b/lib/views/actionability/kind.ts @@ -0,0 +1,26 @@ +// The actionability view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { actionabilitySvg } from "./figure.ts"; + +export const actionabilityKind: ViewKindDef<"actionability"> = { + kind: "actionability", + figure: (v, o) => v.actionability.panels.some((pn) => pn.sequenced) ? actionabilitySvg(v, o) : null, + title: (v) => "Clinical actionability" + (v.actionability ? " — OncoKB " + v.actionability.pin + (v.actionability.groupBy ? " by " + v.actionability.groupBy : "") : ""), + text: (v, { out, caveatOf, noteOf }) => { + const a = v.actionability, pct = (k: number, d: number) => d ? (100 * k / d).toFixed(1) + "%" : "–"; + const line = (label: string, g: { sequenced: number; levels: number[]; resistance: number[] }) => + ` ${label} (${g.sequenced.toLocaleString()} sequenced): ` + a.levels.map((l, i) => `${l} ${g.levels[i]} (${pct(g.levels[i], g.sequenced)})`).join(", ") + + `; resistance R1 ${g.resistance[0]}, R2 ${g.resistance[1]}`; + out.push(`Clinical actionability (highest OncoKB level per patient, ${a.pin}, any cancer type${a.groupBy ? `, by ${a.groupBy}` : ""}):\n` + + a.panels.map((pn) => [line(pn.cohort, pn), ...(pn.groups || []).map((g) => line(" " + g.label, g)), + ...(pn.concordance ? [` ${a.pairBy!.a.join("/")} vs ${a.pairBy!.b.join("/")} highest level, ${pn.concordance.pairs} patients with both: ${pn.concordance.discordant} discordant (${pct(pn.concordance.discordant, pn.concordance.pairs)}); rows ${a.pairBy!.a.join("/")}, columns ${a.pairBy!.b.join("/")} (1, 2, 3A, 3B, 4, none): ` + + pn.concordance.table.map((r) => "[" + r.join(", ") + "]").join(" ")] : [])].join("\n")).join("\n") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "For each patient, finds the strongest evidence OncoKB lists for a drug that targets one of their tumour's genetic changes — Level 1 means an FDA-approved drug for that change, down to Level 4 for early biological evidence — and shows what share of patients reach each level, overall or per cancer type. The levels here are the best OncoKB gives the change in ANY cancer, so a change that is only actionable in another cancer still counts: read the bars as an upper bound. The table below compares, for patients sequenced twice, the level found in their primary tumour against their metastasis." + }, + need: "local", + preview: "no method record of the per-patient highest-level counts yet", + isNew: true, +}; diff --git a/lib/views/adjusted/kind.ts b/lib/views/adjusted/kind.ts new file mode 100644 index 0000000..3de4353 --- /dev/null +++ b/lib/views/adjusted/kind.ts @@ -0,0 +1,25 @@ +// The adjusted view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { adjustedSvg } from "./figure.ts"; + +export const adjustedKind: ViewKindDef<"adjusted"> = { + kind: "adjusted", + figure: (v, o) => v.adjusted.rows.length ? adjustedSvg(v, o) : null, + title: (v) => "Adjusted enrichment" + (v.adjusted ? " — " + v.adjusted.groups.join(" vs ") + (v.adjusted.covariates.length ? ", adjusted for " + v.adjusted.covariates.join(", ") : "") : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const a = v.adjusted, pct = (x: number, n: number) => n ? (100 * x / n).toFixed(1) + "%" : "–"; + out.push(`Adjusted enrichment: ${a.groups[0]} vs ${a.groups[1]}, mutation${a.measure === "driver" ? " (OncoKB drivers)" : ""} per gene by logistic regression` + + (a.covariates.length ? ` adjusted for ${a.covariates.join(", ")}` : " (no covariates)") + `; ${a.rows.length} genes fit, ${a.significant} at q<0.05:\n` + + a.rows.slice(0, 30).map((g) => ` ${g.gene}: ${a.groups[0]} ${g.altered[0]}/${g.profiled[0]} (${pct(g.altered[0], g.profiled[0])}), ${a.groups[1]} ${g.altered[1]}/${g.profiled[1]} (${pct(g.altered[1], g.profiled[1])}); ` + + `raw OR ${g.rawOr == null ? "undefined" : g.rawOr.toFixed(2)} (Fisher p=${pFmt(g.fisherP)}); adjusted OR ${g.or.toFixed(2)} (95% CI ${g.ciLow.toFixed(2)}-${g.ciHigh.toFixed(2)}) p=${pFmt(g.p)} q=${pFmt(g.q)}`).join("\n") + + (a.rows.length > 30 ? `\n … ${a.rows.length - 30} more genes fit` : "") + + (a.failed.length ? `\n not fit: ` + a.failed.map((f) => `${f.gene} (${f.reason})`).join("; ") : "") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Compares how often each commonly mutated gene is mutated in two groups of patients — say, colorectal cancers diagnosed before and after age 50 — after taking into account other things that differ between the groups and also change mutation rates, such as how many mutations a tumour carries overall. Each dot is a gene: right of centre means more often mutated in the first group, left in the second, and higher means more certain. The table shows each gene's raw difference beside the adjusted one, so you can see which differences were explained away. A difference here is a clue, not proof of a cause." + }, + need: "patients", + preview: "no method record or golden test of the per-gene logistic fits yet", + isNew: true, +}; diff --git a/lib/views/alterations/kind.ts b/lib/views/alterations/kind.ts new file mode 100644 index 0000000..f17c34e --- /dev/null +++ b/lib/views/alterations/kind.ts @@ -0,0 +1,24 @@ +// The alterations view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { tableText } from "../text.ts"; + +export const alterationsKind: ViewKindDef<"alterations"> = { + kind: "alterations", + title: (v) => "Patient alterations" + (v.alterations?.patient ? " — " + v.alterations.patient.id : ""), + text: (v, { out, caveatOf, noteOf }) => { + const a = v.alterations, pt = a.patient; + const head = `Patient alterations — patients to choose from: ${a.total < a.all ? `${a.total.toLocaleString()} of ${a.all.toLocaleString()} kept by the query` : `${a.total.toLocaleString()}`}; ` + tableText(a); + if (!pt) out.push(head + "\n no patient listed: the list is empty" + caveatOf(v) + noteOf(v)); + else { + const names: Record = { mutations: "Mutations", cna: "Copy Number Alterations", sv: "Structural Variants" }; + out.push(head + `\n${pt.id} (${pt.study}); samples: ${pt.samples.map((x) => x.id + (x.type ? " (" + x.type + ")" : "")).join(", ") || "none"}` + + Object.entries(a.tables).map(([k, t]) => `\n${names[k] || k}: ${t!.total < t!.all ? `${t!.total.toLocaleString()} of ${t!.all.toLocaleString()} rows kept by the query` : `${t!.total.toLocaleString()} rows`}; ` + tableText(t!)).join("") + + caveatOf(v) + noteOf(v)); + } + }, + help: { + title: "What is this?", + text: "Lists one patient's genetic changes as cBioPortal's patient page does, in three tables: mutations (the gene, the protein change, whether OncoKB considers it cancer-causing, and how much of the tumour carries it), copy-number changes (genes amplified or deleted outright) and structural variants (fusions and rearrangements). A change found in several of the patient's samples is one row, with a numbered marker for each sample. Cohort % says how common the same kind of change in that gene is across the study. Each table can be searched, filtered, sorted and downloaded on its own. Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one." + }, + need: "alterations", +}; diff --git a/lib/views/assays/kind.ts b/lib/views/assays/kind.ts new file mode 100644 index 0000000..43a0745 --- /dev/null +++ b/lib/views/assays/kind.ts @@ -0,0 +1,25 @@ +// The assays view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { assaysSvg } from "./figure.ts"; +import type { AssaysEntity } from "./types.ts"; + +export const assaysKind: ViewKindDef<"assays"> = { + kind: "assays", + figure: (v, o) => v.assays && v.assays.tested > 0 ? assaysSvg(v, o) : null, + title: (v) => "Generic assays" + (v.assays ? " — " + v.assays.profileName : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const as = v.assays; + const cell = (c: AssaysEntity["cells"][number]) => as.datatype === "numeric" + ? `${c.cohort} mean ${c.mean == null ? "—" : c.mean.toPrecision(3)} (n=${c.n})` + : `${c.cohort} ` + Object.entries(c.values || {}).map(([k, n]) => `${k} ${c.n ? (100 * n / c.n).toFixed(1) : "—"}%`).join(", ") + ` (n=${c.n})`; + out.push(`Generic-assay comparison, ${as.profileName} (${as.profileType}; ${as.datatype}, per ${as.unit.replace(/s$/, "")}; ${as.test}, BH q over ${as.tested.toLocaleString()} entities; ${as.significant} at q<0.05):\n` + + as.rows.slice(0, 40).map((g) => ` ${g.name}: ` + g.cells.map(cell).join(" | ") + + (g.diff != null ? `, difference ${g.diff.toPrecision(3)}` : "") + `, q=${pFmt(g.q)}, higher in ${g.higherIn}${g.value ? " (" + g.value + ")" : ""}`).join("\n") + + (as.rows.length > 40 ? `\n … ${as.tested - 40} more entities tested` : "") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Compares two or more groups on every measurement in one kind of extra lab data the study recorded — such as which mutational signatures (like sun damage or smoking) shaped each tumour, or which chromosome arms were gained or lost. The measurements at the top differ most clearly between the groups; each one shows its average, or its mix of results, in every group, so a difference that is clear but small reads as small." + }, + need: "local", +}; diff --git a/lib/views/attrition/kind.ts b/lib/views/attrition/kind.ts new file mode 100644 index 0000000..551e00e --- /dev/null +++ b/lib/views/attrition/kind.ts @@ -0,0 +1,20 @@ +// The attrition view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { attritionSvg } from "./figure.ts"; + +export const attritionKind: ViewKindDef<"attrition"> = { + kind: "attrition", + figure: (v, o) => v.attrition && v.attrition.length ? attritionSvg(v, o) : null, + title: () => "Cohort attrition", + text: (v, { out, caveatOf, noteOf }) => { + out.push("Cohort attrition (patients left after each clause, in order):\n" + v.attrition.map((pn) => + ` ${pn.cohort}: study base ${pn.base.toLocaleString()}\n` + + pn.steps.map((st) => ` → ${st.label}: ${st.n.toLocaleString()} (−${st.excluded.toLocaleString()})`).join("\n")).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Shows how each group of patients was narrowed down, one criterion at a time: how many patients the study started with, how many were left after each rule in the group's definition, and how many each rule removed. It lets a reader see who was left out before trusting any result drawn from the patients who remain." + }, + isNew: true, +}; diff --git a/lib/views/cancertypes/kind.ts b/lib/views/cancertypes/kind.ts new file mode 100644 index 0000000..d8784e4 --- /dev/null +++ b/lib/views/cancertypes/kind.ts @@ -0,0 +1,23 @@ +// The cancertypes view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { cancertypesSvg } from "./figure.ts"; + +export const cancertypesKind: ViewKindDef<"cancertypes"> = { + kind: "cancertypes", + figure: (v, o) => v.cancertypes && v.cancertypes.panels.some((pn) => pn.profiled) ? cancertypesSvg(v, o) : null, + title: (v) => "Cancer types summary" + (v.cancertypes ? " — " + v.cancertypes.genes.join(", ") + " by " + v.cancertypes.groupBy : ""), + text: (v, { out, caveatOf, noteOf }) => { + const ct = v.cancertypes; + const line = (g: { label: string; altered: number; profiled: number; mutation: number; amp: number; homdel: number; sv: number; multiple: number }) => + ` ${g.label}: ${(100 * g.altered / (g.profiled || 1)).toFixed(1)}% (${g.altered}/${g.profiled}; mutation ${g.mutation}, amp ${g.amp}, deep del ${g.homdel}, SV ${g.sv}, multiple ${g.multiple})`; + out.push(`Cancer types summary: ${ct.genes.join(", ")} by ${ct.groupBy} (${ct.measure === "driver" ? "drivers" : "any alteration"}; samples profiled for at least one gene):\n` + + ct.panels.map((pn) => ` ${pn.cohort}: ${pn.altered.toLocaleString()} of ${pn.profiled.toLocaleString()} profiled samples altered\n` + + [...pn.groups, ...(pn.pooled ? [pn.pooled] : [])].map(line).join("\n")).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "For a list of genes, shows what share of samples in each cancer type carry a change in at least one of them — mutated, gained in many copies, deleted, or rearranged — with each bar split by which kind of change it was. It answers which cancers these genes matter most in, the way cBioPortal's Cancer Types Summary does." + }, + need: "local", +}; diff --git a/lib/views/clinical/figure.ts b/lib/views/clinical/figure.ts new file mode 100644 index 0000000..d18485a --- /dev/null +++ b/lib/views/clinical/figure.ts @@ -0,0 +1,28 @@ +// The clinical view's figure. +import { type HRow, hbars } from "../../figures/bars.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { SvgOpts } from "../../svg-kit.ts"; +import type { ClinicalRow } from "./types.ts"; +import { OTHER, SERIES, fmtQ } from "../../svg-kit.ts"; + +/** −log₁₀ q past this runs off the axis as an arrow: q < 1e-50 is beyond any reading difference */ +const Q_CAP = 50; + +export function clinicalSvg(v: DrawnResultOf<"clinical">, o: SvgOpts): string { + const all = (v.clinical || []) as ClinicalRow[]; + const rows = all.map((r): HRow => { + if (r.skipped) return { label: r.attribute, v: null, fill: OTHER, text: "not tested: " + r.skipped, muted: true }; + const q = r.q; + return { + label: r.attribute, v: q == null ? null : q > 0 ? -Math.log10(q) : Infinity, + fill: q != null && q < 0.05 ? SERIES[0] : OTHER, + text: (r.test || "") + " · " + (q == null ? "q –" : fmtQ(q)) + }; + }); + return hbars("Clinical comparison — −log₁₀ q", rows, o, { + signed: false, axisTitle: "−log₁₀ q (Benjamini–Hochberg) — longer is stronger evidence of a difference", cap: Q_CAP, + ref: { at: -Math.log10(0.05), text: "q = 0.05" }, + legend: [{ label: "q < 0.05", color: SERIES[0] }, { label: "not significant", color: OTHER }], + note: v.note || "" + }); +} diff --git a/lib/views/clinical/kind.ts b/lib/views/clinical/kind.ts new file mode 100644 index 0000000..8e24ed1 --- /dev/null +++ b/lib/views/clinical/kind.ts @@ -0,0 +1,27 @@ +// The clinical view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { clinicalSvg } from "./figure.ts"; + +export const clinicalKind: ViewKindDef<"clinical"> = { + kind: "clinical", + figure: (v, o) => v.clinical && v.clinical.some((r) => !r.skipped) ? clinicalSvg(v, o) : null, + title: () => "Clinical comparison", + text: (v, { out, pFmt, caveatOf, noteOf }) => { + out.push("Clinical comparison across cohorts:\n" + + (v.clinical || []).slice(0, 20).map((row) => { + const cells = row.cells.map((c) => row.kind === "num" + ? `${c.cohort} mean ${c.mean != null ? c.mean.toFixed(2) : "—"}` + + (c.sd != null ? ` sd ${c.sd.toFixed(2)}` : "") + ` (n=${c.n.toLocaleString()})` + : `${c.cohort} ` + (c.top && c.top.length + ? c.top.slice(0, 3).map((t) => `${t.v} ${c.n ? Math.round(1000 * t.n / c.n) / 10 : 0}%`).join(", ") + : "—")).join(" | "); + return ` ${row.attribute} [${row.per}, ${row.kind}]: ${cells}` + + (row.p != null ? ` ${row.test || "test"} p=${pFmt(row.p)} q=${pFmt(row.q!)}` : "") + + (row.skipped ? ` SKIPPED: ${row.skipped}` : ""); + }).join("\n") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Compares things recorded about the patients themselves — age, sex, a lab result, and similar details — rather than their genetics, between two or more groups. It answers whether the groups differ in ways beyond whatever genetic difference defined them, which matters for judging whether a genetic finding is really the explanation." + }, +}; diff --git a/lib/views/clonality/kind.ts b/lib/views/clonality/kind.ts new file mode 100644 index 0000000..e382ff2 --- /dev/null +++ b/lib/views/clonality/kind.ts @@ -0,0 +1,25 @@ +// The clonality view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { clonalitySvg } from "./figure.ts"; + +export const clonalityKind: ViewKindDef<"clonality"> = { + kind: "clonality", + figure: (v, o) => v.clonality && v.clonality.panels.length ? clonalitySvg(v, o) : null, + title: () => "Clonality (FACETS)", + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const cr = v.clonality; + out.push(`Clonality (FACETS; ${cr.measure === "driver" ? "driver mutation calls" : "all mutation calls"}${cr.qc ? ", QC-passing samples" : ""}):\n` + + cr.panels.map((pn) => ` ${pn.cohort}: WGD ${pn.wgd.yes + pn.wgd.no ? (100 * pn.wgd.yes / (pn.wgd.yes + pn.wgd.no)).toFixed(1) + "%" : "—"} of ${(pn.wgd.yes + pn.wgd.no).toLocaleString()} samples\n` + + pn.genes.map((g) => ` ${g.gene}: ${g.calls} calls, ${g.clonal + g.subclonal ? Math.round(100 * g.clonal / (g.clonal + g.subclonal)) + "% clonal" : "—"}` + + ` (${g.clonal} clonal, ${g.subclonal} subclonal, ${g.indeterminate} indeterminate), LOH ${g.lohKnown ? Math.round(100 * g.loh / g.lohKnown) + "%" : "—"}` + + (g.q != null ? `, q=${pFmt(g.q)}` : "")).join("\n")).join("\n") + + (cr.wgdP != null ? `\n WGD difference: Fisher p=${pFmt(cr.wgdP)}` : "") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "A tumour is a population of cells, and a genetic change can be in all of them (present from early on) or only in some (arriving later). For each gene, this shows how often its changes are in all the tumour's cells, and how often the cell has also lost its backup copy of that gene. Beside it: how often tumours have doubled their entire set of chromosomes." + }, + need: "local", + isNew: true, +}; diff --git a/lib/views/coexpression/figure.ts b/lib/views/coexpression/figure.ts new file mode 100644 index 0000000..097ab46 --- /dev/null +++ b/lib/views/coexpression/figure.ts @@ -0,0 +1,24 @@ +// The coexpression view's figure. +import { hbars, type HRow, UP,DOWN } from "../../figures/bars.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { SvgOpts } from "../../svg-kit.ts"; +import type { CoexprRow } from "./types.ts"; +import { OTHER, fmtNum, fmtInt, fmtQ } from "../../svg-kit.ts"; + +export function coexpressionSvg(v: DrawnResultOf<"coexpression">, o: SvgOpts): string { + const rows = (v.coexpression || []) as CoexprRow[]; + return hbars("Co-expression — Pearson r", rows.map((r): HRow => { + const rr = r.rMeta ?? r.r; + const sig = r.q < 0.05 && rr != null && rr !== 0; + return { + label: r.a + " / " + r.b, v: rr, fill: sig ? (rr! > 0 ? UP : DOWN) : OTHER, + text: (rr == null ? "r –" : "r = " + fmtNum(rr, 2)) + " · n = " + fmtInt(r.n) + " · " + fmtQ(r.q), + tip: r.a + " / " + r.b + (r.rMeta != null ? ": r combined across " + (r.sites ?? 0) + " sites" : "") + + (r.perStudy && r.perStudy.length > 1 ? "\n" + r.perStudy.map((s) => s.study + ": r " + (s.r == null ? "–" : fmtNum(s.r, 2)) + ", n " + fmtInt(s.n)).join("\n") : "") + }; + }), o, { + signed: true, axisTitle: "Pearson r — left: anti-correlated, right: correlated", + legend: [{ label: "positive, q < 0.05", color: UP }, { label: "negative, q < 0.05", color: DOWN }, { label: "not significant", color: OTHER }], + note: v.note || "" + }); +} diff --git a/lib/views/coexpression/kind.ts b/lib/views/coexpression/kind.ts new file mode 100644 index 0000000..49de2ad --- /dev/null +++ b/lib/views/coexpression/kind.ts @@ -0,0 +1,20 @@ +// The coexpression view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { coexpressionSvg } from "./figure.ts"; + +export const coexpressionKind: ViewKindDef<"coexpression"> = { + kind: "coexpression", + figure: (v, o) => v.coexpression && v.coexpression.length ? coexpressionSvg(v, o) : null, + title: () => "Co-expression", + text: (v, { out, pFmt, caveatOf, noteOf }) => { + out.push("Co-expression (Pearson r, computed from six per-site sums so the pooled value is exact):\n" + + (v.coexpression || []).slice(0, 20).map((x) => + ` ${x.a}/${x.b}: r=${x.r != null ? x.r.toFixed(3) : "—"} (n=${x.n.toLocaleString()}) p=${pFmt(x.p)} q=${pFmt(x.q)}` + + (x.rMeta != null ? ` meta-r=${x.rMeta.toFixed(3)} across ${x.sites} sites` : "")).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Checks whether two genes tend to be more or less active together across patients — measured by how much each gene is \"read out\" into the working molecules a cell actually uses. Genes that rise and fall together are often working in the same biological pathway, or controlled by the same switch." + }, +}; diff --git a/lib/views/compare/figure.ts b/lib/views/compare/figure.ts new file mode 100644 index 0000000..e8b8454 --- /dev/null +++ b/lib/views/compare/figure.ts @@ -0,0 +1,80 @@ +// The compare view's figure. +import { PAD, GUT, titleLine, r1, sentence } from "../../figures/bars.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { SvgOpts, LegendItem } from "../../svg-kit.ts"; +import { palette, seriesStyle, legend, FS, fmtQ, textWidth, linear, SW, label, fmtPct, fmtInt, esc, axisBottom, fmtNum, bottomAxisHeight, caption, frame } from "../../svg-kit.ts"; +import type { CompareRow } from "./types.ts"; + +/** below this a column of groups is too narrow to read; more width adds columns */ +const COL_MIN = 340; + +/** + * Altered %, grouped by gene: one bar per cohort in each gene's group, the cohorts keyed once in + * a legend. Genes flow down columns as the width allows, every column on one shared scale. + */ +export function compareSvg(v: DrawnResultOf<"compare">, o: SvgOpts): string { + const p = palette(o.theme); + const rows = (v.rows || []) as CompareRow[]; + const names = v.cohorts && v.cohorts.length ? v.cohorts : (rows[0]?.cells || []).map((c) => c.cohort); + const k = Math.max(1, ...rows.map((r) => r.cells.length)); + let W = Math.max(360, o.width || 720); + const grown = W < COL_MIN + 2 * PAD; + if (grown) W = COL_MIN + 2 * PAD; + const title = "Altered %, by cohort"; + + const out: string[] = []; + const t = titleLine(title, W, p); + out.push(t.svg); + let y = t.y; + const items: LegendItem[] = names.map((n, i) => ({ label: n, color: seriesStyle(i).color, opacity: seriesStyle(i).tint })); + const lg = legend(items, PAD, y, W - 2 * PAD, p, { grid: names.length > 4 }); + out.push(lg.svg); + y += lg.height + 8; + + // one bar per cohort, a gap between genes; the gene's name and q head its group on the left + const BAR = k > 6 ? 10 : 12, GAP = 10; + const groupH = Math.max(k * BAR, FS.body + FS.small + 6) + GAP; + const cols = Math.max(1, Math.min(Math.ceil(rows.length / 4), Math.floor((W - 2 * PAD + GUT * 2) / (COL_MIN + GUT * 2)))); + const perCol = Math.ceil(rows.length / cols); + const colW = (W - 2 * PAD - (cols - 1) * GUT * 2) / cols; + const qText = (r: CompareRow) => r.qAny != null ? fmtQ(r.qAny) : ""; + const geneW = Math.max(...rows.map((r) => Math.max(textWidth(r.gene, FS.body, 600), textWidth(qText(r), FS.small))), 30); + const valW = textWidth("100.0%", FS.small) + 4; + const top = y, plotH = perCol * groupH; + const maxPct = Math.max(1, ...rows.flatMap((r) => r.cells.map((c) => c.anyPct ?? 0))); + let notProfiled = 0; + + for (let c = 0; c < cols; c++) { + const cx = PAD + c * (colW + GUT * 2); + const L = cx + geneW + GUT, R = cx + colW - valW; + const xs = linear(0, Math.min(100, maxPct), L, R); + for (const tk of xs.ticks) out.push(``); + rows.slice(c * perCol, (c + 1) * perCol).forEach((r, gi) => { + const gy = top + gi * groupH; + out.push(label(cx, gy + FS.body, r.gene, { size: FS.body, weight: 600, fill: p.ink, maxW: geneW })); + const q = qText(r); + if (q) out.push(label(cx, gy + FS.body + FS.small + 3, q, { size: FS.small, fill: r.qAny! < 0.05 ? p.ink : p.muted })); + r.cells.forEach((cell, ci) => { + const st = seriesStyle(ci), by = gy + ci * BAR; + const pct = cell.anyPct; + const tip = (names[ci] || cell.cohort) + " — " + r.gene + ": " + (pct == null ? "not profiled" : fmtPct(pct) + " (" + fmtInt(cell.any) + " of " + fmtInt(cell.profiled) + " profiled)"); + if (pct == null) { + notProfiled++; + out.push(label(L + 2, by + BAR / 2 + FS.small * 0.35, "not profiled", { size: FS.small, fill: p.muted })); + return; + } + const w = Math.max(pct > 0 ? 1 : 0, xs(pct) - L); + out.push(`${esc(tip)}`); + out.push(label(L + w + 3, by + BAR / 2 + FS.small * 0.35, fmtPct(pct), { size: FS.small, fill: p.muted })); + }); + }); + out.push(``); + out.push(axisBottom(xs, top + plotH, p, { title: "altered, % of profiled", minX: L - GUT, maxX: cx + colW, fmt: (x) => fmtNum(x) + "%" })); + } + y = top + plotH + bottomAxisHeight("altered") + 8; + const note = sentence(v.note || "") + (notProfiled ? " " + fmtInt(notProfiled) + " cohort–gene pair" + (notProfiled === 1 ? " was" : "s were") + " not profiled and " + (notProfiled === 1 ? "has" : "have") + " no bar." : "") + + (rows.some((r) => r.qAny != null) ? " The q under each gene compares its altered share across the cohorts." : ""); + const cap = caption(note, W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, grown, title + " — " + names.join(", ")); +} diff --git a/lib/views/compare/kind.ts b/lib/views/compare/kind.ts new file mode 100644 index 0000000..9668887 --- /dev/null +++ b/lib/views/compare/kind.ts @@ -0,0 +1,20 @@ +// The compare view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { compareSvg } from "./figure.ts"; + +export const compareKind: ViewKindDef<"compare"> = { + kind: "compare", + figure: (v, o) => v.rows && v.rows.length ? compareSvg(v, o) : null, + title: () => "Alteration frequencies", + text: (v, { out, noteOf }) => { + const rows = v.rows.slice(0, 20).map((row) => + ` ${row.gene}: ` + row.cells.map((c) => `${c.cohort} ${c.driverPct}%/${c.anyPct}%`).join(" | ") + + (row.pAny != null ? ` p=${row.pAny < 1e-4 ? row.pAny.toExponential(1) : row.pAny.toFixed(4)}` : "")); + out.push(`Compare (driver%/any%, panel-aware denominators; annotation ${v.annotation}):\n` + rows.join("\n") + + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Compares how often specific genes are altered — mutated, gained, or lost — between two or more groups of patients. If a gene is altered much more often in one group than another, that's a clue it might be connected to whatever makes the groups different, like a cancer subtype or how patients respond to treatment." + }, +}; diff --git a/lib/views/composition/kind.ts b/lib/views/composition/kind.ts new file mode 100644 index 0000000..937433a --- /dev/null +++ b/lib/views/composition/kind.ts @@ -0,0 +1,25 @@ +// The composition view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { compositionSvg } from "./figure.ts"; + +export const compositionKind: ViewKindDef<"composition"> = { + kind: "composition", + figure: (v, o) => v.composition && v.composition.groups.some((g) => g.n > 0) ? compositionSvg(v, o) : null, + title: (v) => "Composition" + (v.composition ? " — " + v.composition.sourceName : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const cm = v.composition; + const fmt = (x: number) => cm.normalize ? (100 * x).toFixed(1) + "%" : x.toPrecision(3); + out.push(`Composition of ${cm.sourceName} (${cm.parts.length} parts${cm.normalize ? ", shares of each " + cm.unit.replace(/s$/, "") + "'s total" : ""}; ` + + `each cohort against the others by Welch's t, BH q over ${cm.tested} comparisons; ${cm.significant} at q<0.05):\n` + + cm.groups.map((g) => ` ${g.cohort} (n=${g.n}${g.entropy ? ", entropy " + g.entropy.mean.toFixed(2) : ""}): ` + + cm.parts.map((pt) => { const c = cm.cells.find((x) => x.cohort === g.cohort && x.part === pt.id)!; + return `${pt.name} ${fmt(c.mean)}` + (c.q != null ? ` (log2 ${c.log2!.toFixed(2)}, q=${pFmt(c.q)})` : ""); }).join(", ")).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Shows what each patient's (or sample's) tumour is made of — the share of each cell type, cell state or signature — as one stacked bar per case, sorted by the part you choose, with each group's average beside it. Under the bars, every group is set against the others part by part: the colour says whether a part is higher (red) or lower (blue) there, its size how sure that is. Parts of a whole move together, so one part rising means others fall." + }, + need: "local", + isNew: true, +}; diff --git a/lib/views/cooccurrence/figure.ts b/lib/views/cooccurrence/figure.ts new file mode 100644 index 0000000..6e82b15 --- /dev/null +++ b/lib/views/cooccurrence/figure.ts @@ -0,0 +1,28 @@ +// The cooccurrence view's figure. +import { type HRow, UP,DOWN, hbars, sentence } from "../../figures/bars.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { SvgOpts } from "../../svg-kit.ts"; +import type { CooccurRow } from "./types.ts"; +import { OTHER, fmtNum, fmtQ, fmtInt } from "../../svg-kit.ts"; + +export function cooccurrenceSvg(v: DrawnResultOf<"cooccurrence">, o: SvgOpts): string { + const pairs = (v.pairs || []) as CooccurRow[]; + let anyInf = false; + const rows = pairs.map((r): HRow => { + const lor = r.oddsRatioMH != null ? Math.log2(r.oddsRatioMH) : r.log2Odds; + if (lor != null && !Number.isFinite(lor)) anyInf = true; + const p = r.pStratified ?? r.p; + const sig = r.q < 0.05 && lor != null && lor !== 0; + return { + label: r.a + " / " + r.b, v: lor, fill: sig ? (lor! > 0 ? UP : DOWN) : OTHER, + text: (lor == null ? "–" : fmtNum(lor)) + " · " + fmtQ(r.q), + tip: r.a + " / " + r.b + ": both " + fmtInt(r.both) + ", " + r.a + " only " + fmtInt(r.onlyA) + ", " + r.b + " only " + fmtInt(r.onlyB) + + ", neither " + fmtInt(r.neither) + " · " + (r.pStratified != null ? "stratified " : "") + "p " + fmtQ(p).slice(2) + }; + }); + return hbars("Co-occurrence — log₂ odds ratio", rows, o, { + signed: true, axisTitle: "log₂ odds ratio — left: mutually exclusive, right: co-occurring", + legend: [{ label: "co-occur, q < 0.05", color: UP }, { label: "mutually exclusive, q < 0.05", color: DOWN }, { label: "not significant", color: OTHER }], + note: sentence(v.note || "") + (anyInf ? " An odds ratio of 0 or ∞ (a pair never or always seen together) is drawn to the edge of the axis." : "") + }); +} diff --git a/lib/views/cooccurrence/kind.ts b/lib/views/cooccurrence/kind.ts new file mode 100644 index 0000000..d9926ba --- /dev/null +++ b/lib/views/cooccurrence/kind.ts @@ -0,0 +1,23 @@ +// The cooccurrence view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { cooccurrenceSvg } from "./figure.ts"; + +export const cooccurrenceKind: ViewKindDef<"cooccurrence"> = { + kind: "cooccurrence", + figure: (v, o) => v.pairs && v.pairs.length ? cooccurrenceSvg(v, o) : null, + title: () => "Co-occurrence and mutual exclusivity", + text: (v, { out, pFmt, caveatOf, noteOf }) => { + out.push("Co-occurrence (2x2 per pair, among patients profiled for BOTH genes):\n" + + (v.pairs || []).slice(0, 20).map((x) => + ` ${x.a}/${x.b}: both ${x.both.toLocaleString()}, ${x.a} only ${x.onlyA.toLocaleString()}, ` + + `${x.b} only ${x.onlyB.toLocaleString()}, neither ${x.neither.toLocaleString()} (of ${x.profiled.toLocaleString()} profiled)` + + (x.log2Odds != null ? ` log2OR=${x.log2Odds.toFixed(2)} ${x.log2Odds > 0 ? "(co-occur)" : "(mutually exclusive)"}` : "") + + ` p=${pFmt(x.p)} q=${pFmt(x.q)}` + + (x.pStratified != null ? ` stratified p=${pFmt(x.pStratified)}` : "")).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Checks whether alterations in two genes tend to show up in the same patients more often than you'd expect by chance (\"co-occurring\"), or rarely appear together (\"mutually exclusive\"). Genes that are mutually exclusive often play overlapping roles — if one is already altered, changing the other adds little, so cells rarely need both." + }, +}; diff --git a/lib/views/correlates/figure.ts b/lib/views/correlates/figure.ts index b313238..de41076 100644 --- a/lib/views/correlates/figure.ts +++ b/lib/views/correlates/figure.ts @@ -2,6 +2,7 @@ // most positive and most negative genes listed with their ρ as a bar. import type { ViewResultOf, DrawnResultOf } from "../../types.ts"; import { + palette, frame, caption, label, textWidth, wrapWidth, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, fmtInt, pDigits, tickFormat, esc, FS, SW, SERIES, type SvgOpts } from "../../svg-kit.ts"; diff --git a/lib/views/correlates/kind.ts b/lib/views/correlates/kind.ts new file mode 100644 index 0000000..f4ce5a9 --- /dev/null +++ b/lib/views/correlates/kind.ts @@ -0,0 +1,23 @@ +// The correlates view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { correlatesSvg } from "./figure.ts"; +import type { CorrelatesGene } from "./types.ts"; + +export const correlatesKind: ViewKindDef<"correlates"> = { + kind: "correlates", + figure: (v, o) => v.correlates && v.correlates.tested > 0 ? correlatesSvg(v, o) : null, + title: (v) => "Co-expression" + (v.correlates ? " — " + v.correlates.gene + " against every gene" : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const co = v.correlates; + const line = (g: CorrelatesGene) => ` ${g.gene}: ρ ${g.rho} (q=${pFmt(g.q)}), r ${g.r}, n=${g.n}`; + out.push(`Genes co-expressed with ${co.gene} (${co.profileType}${co.otherProfile !== co.profileType ? " against " + co.otherProfile : ""}) in ${co.cohort}; ` + + `Spearman ρ${co.rhoFrom === "meta" ? " combined across studies by Fisher z" : ""}, BH q over ${co.tested.toLocaleString()} genes; ${co.significant.toLocaleString()} at q<0.05:\n` + + ` most positively correlated:\n` + co.positive.map(line).join("\n") + + `\n most negatively correlated:\n` + co.negative.map(line).join("\n") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Takes one gene and checks every other gene for whether its activity rises and falls along with it across these patients. The genes at the top go up when it goes up; those at the bottom go down when it goes up. The chart on the left shows how all genes fall: most barely move with it, and the ones far out on either side are the ones listed. Moving together is a clue, not proof that one gene controls the other." + }, + need: "local", +}; diff --git a/lib/views/course/kind.ts b/lib/views/course/kind.ts new file mode 100644 index 0000000..4e72cc6 --- /dev/null +++ b/lib/views/course/kind.ts @@ -0,0 +1,25 @@ +// The course view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { courseSvg } from "./figure.ts"; + +export const courseKind: ViewKindDef<"course"> = { + kind: "course", + figure: (v, o) => v.course && v.course.length ? courseSvg(v, o) : null, + title: (v) => "Clinical course" + (v.course && v.course.length ? " — " + [...new Set(v.course.map((z) => z.eventType))].join(", ") : ""), + text: (v, { out, caveatOf, noteOf }) => { + const axis = v.courseAxis; + out.push(`Clinical course (${axis ? axis.binMonths + "-month bins, " + axis.from + " to +" + axis.to + "mo" : "binned"} ` + + `relative to the anchor; DENOMINATOR IS THE PATIENTS STILL OBSERVABLE IN EACH BIN, not the cohort):\n` + + (v.course || []).map((s2) => { + const pts = s2.points.filter((q) => q.pct != null && q.atRisk > 0); + const at = (t: number) => { const q = pts.find((z) => z.t === t); return q ? `${q.pct}% (n=${q.atRisk.toLocaleString()})` : "—"; }; + return ` ${s2.cohort} / ${s2.eventType} [${s2.isInterval ? "a state patients are IN" : "an event that HAPPENED"}]: ` + + `0mo ${at(0)}, 12mo ${at(12)}, 24mo ${at(24)}, 60mo ${at(60)}`; + }).join("\n") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Shows what share of patients were in each stage of their care — on treatment, in follow-up, and so on — at each point in time after diagnosis or another starting event. It's a picture of the typical patient's journey through care, not any one individual's." + }, + isNew: true, +}; diff --git a/lib/views/cox/figure.ts b/lib/views/cox/figure.ts new file mode 100644 index 0000000..a314dc1 --- /dev/null +++ b/lib/views/cox/figure.ts @@ -0,0 +1,15 @@ +// The cox view's figure. +import { type Panel, forestSvg } from "../../figures/forest.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { SvgOpts } from "../../svg-kit.ts"; + +/** Cox: hazard ratios; with stratifyByCohort, one panel per cohort that fit */ +export function coxSvg(v: DrawnResultOf<"cox">, o: SvgOpts): string { + const byCohort = v.cox?.byCohort; + const panels: Panel[] = byCohort + ? byCohort.filter((c) => c.coefficients.length).map((c) => ({ + heading: c.cohort + " — n = " + c.n.toLocaleString("en-US") + ", " + c.events.toLocaleString("en-US") + " events", + coefs: c.coefficients.map((cf) => ({ ...cf, ratio: cf.hr })) })) + : [{ heading: null, coefs: (v.cox?.coefficients || []).map((cf) => ({ ...cf, ratio: cf.hr })) }]; + return forestSvg(panels, Math.max(520, o.width || 760), o.theme, "HR", v.caveat || "", "Cox regression — hazard ratios"); +} diff --git a/lib/views/cox/kind.ts b/lib/views/cox/kind.ts new file mode 100644 index 0000000..8caa53c --- /dev/null +++ b/lib/views/cox/kind.ts @@ -0,0 +1,32 @@ +// The cox view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { coxSvg } from "./figure.ts"; + +export const coxKind: ViewKindDef<"cox"> = { + kind: "cox", + figure: (v, o) => v.cox && (v.cox.coefficients.length || (v.cox.byCohort || []).some((c) => c.coefficients.length)) ? coxSvg(v, o) : null, + title: (v) => "Cox regression — " + (v.endpoint || "OS"), + text: (v, { out, pFmt, caveatOf }) => { + // stratifyByCohort: no pooled coefficients — one independent fit per cohort instead. + out.push(v.cox.byCohort + ? `Cox regression (${v.endpoint || "OS"}, stratified by cohort):\n` + + v.cox.byCohort.map((c) => c.error + ? ` ${c.cohort}: ERROR — ${c.error}` + : ` ${c.cohort} (n=${c.n.toLocaleString()}, ${c.events.toLocaleString()} events):\n` + + c.coefficients.map((cf) => + ` ${cf.label}${cf.reference ? " vs " + cf.reference : ""}: HR ${cf.hr.toFixed(2)} ` + + `(95% CI ${cf.ciLow.toFixed(2)}-${cf.ciHigh.toFixed(2)}) p=${pFmt(cf.p)}`).join("\n") + ).join("\n") + caveatOf(v) + : `Cox regression (${v.endpoint || "OS"}, n=${v.cox.n.toLocaleString()}, ${v.cox.events.toLocaleString()} events):\n` + + v.cox.coefficients.map((c) => + ` ${c.label}${c.reference ? " vs " + c.reference : ""}: HR ${c.hr.toFixed(2)} ` + + `(95% CI ${c.ciLow.toFixed(2)}-${c.ciHigh.toFixed(2)}) p=${pFmt(c.p)}`).join("\n") + + caveatOf(v)); + }, + help: { + title: "What is this?", + text: "A more advanced version of the survival comparison above, which can weigh several factors at once — for example, a patient's age, a specific gene, and how far the disease had spread — to estimate how much each one changes risk on its own, holding the others equal. Each result is a \"hazard ratio\": above 1 means that factor is linked to worse outcomes, below 1 means it's linked to better ones." + }, + need: "patients", + isNew: true, +}; diff --git a/lib/views/ctdna/kind.ts b/lib/views/ctdna/kind.ts new file mode 100644 index 0000000..b3d0dfa --- /dev/null +++ b/lib/views/ctdna/kind.ts @@ -0,0 +1,33 @@ +// The ctdna view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { ctdnaSvg } from "./figure.ts"; + +export const ctdnaKind: ViewKindDef<"ctdna"> = { + kind: "ctdna", + figure: (v, o) => v.ctdna && v.ctdna.panels.some((pn) => pn.patients) ? ctdnaSvg(v, o) : null, + title: () => "ctDNA vs tissue", + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const cr = v.ctdna, pc = (x: number | null) => x == null ? "—" : (100 * x).toFixed(0) + "%"; + out.push(`ctDNA vs tissue (plasma ${cr.attribute} = ${cr.plasma.join("/")}${cr.plasmaPanels.length ? " or panel " + cr.plasmaPanels.join("/") : ""}, tissue ${cr.tissue.join("/")}; ` + + `${cr.measure === "driver" ? "OncoKB oncogenic" : "any alteration"}${cr.withinDays != null ? `; samples within ${cr.withinDays} days` : ""}; both panels cover the gene):\n` + + cr.panels.map((pn) => ` ${pn.cohort} (${pn.patients.toLocaleString()} patients with both; ctDNA detected in ${pn.detected.toLocaleString()} of ${pn.plasmaPatients.toLocaleString()} with plasma, ` + + `${pc(pn.plasmaPatients ? pn.detected / pn.plasmaPatients : null)} [95% CI ${pc(pn.detectedLow)}–${pc(pn.detectedHigh)}]):\n` + + pn.genes.map((g) => ` ${g.gene}: both ${g.both}, tissue-only ${g.tissueOnly}, plasma-only ${g.plasmaOnly}, neither ${g.neither} of ${g.pairs}; ` + + `PPA ${pc(g.ppa)}${g.ppa != null ? ` [${pc(g.ppaLow)}–${pc(g.ppaHigh)}]` : ""}, concordance ${pc(g.concordance)}`).join("\n") + + (pn.survival ? `\n OS: ` + pn.survival.curves.map((c) => `${c.label} n=${c.n}, ${c.events} deaths, median ${c.median == null ? "not reached" : c.median.toFixed(1) + "mo"}`).join("; ") + + (pn.survival.logrank ? `; log-rank p=${pFmt(pn.survival.logrank.p)}` : "") : pn.survivalWhy ? `\n OS by ctDNA not drawn: ${pn.survivalWhy}` : "") + + (pn.ch ? `\n CH filter: removed ${pn.ch.removed} of ${pn.ch.calls} plasma mutation calls in ${pn.ch.patients} patients (${pn.ch.byGene} by gene, ${pn.ch.byNormal} by the matched normal)` + + (pn.ch.genes.length ? ": " + pn.ch.genes.map((g) => `${g.gene} ${g.calls}`).join(", ") : "") + + `; unfiltered: ctDNA detected ${pn.ch.before.detected.toLocaleString()} (${pc(pn.plasmaPatients ? pn.ch.before.detected / pn.plasmaPatients : null)})` + + (pn.ch.before.survival && pn.ch.before.survival.p != null ? `, OS log-rank p=${pFmt(pn.ch.before.survival.p)}` : "") + + `; plasma-only unfiltered ` + pn.ch.before.genes.map((g) => `${g.gene} ${g.plasmaOnly}`).join(", ") : "")).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Tumours shed DNA into the blood, and a blood test (a \"liquid biopsy\") can read it. For patients who had both a blood sample and a tumour tissue sample tested, this shows gene by gene how often a change was found in both, only in the tissue, or only in the blood — and how often the blood test caught what the tissue showed. Below, it shows how many patients had tumour DNA detected in their blood at all, and whether those patients lived shorter or longer than those without it. With the clonal-haematopoiesis filter on, changes that probably come from ageing blood cells rather than the tumour — in genes such as DNMT3A and TET2, or also found in the patient's white blood cells — are left out first, and the figure says how many were removed and what the numbers were without the filter." + }, + need: "local", + preview: "no method record or golden test of the agreement counts, Wilson intervals and survival split, or of the clonal-haematopoiesis filter (chFilter) yet", + isNew: true, +}; diff --git a/lib/views/cutpoint/kind.ts b/lib/views/cutpoint/kind.ts new file mode 100644 index 0000000..55ed459 --- /dev/null +++ b/lib/views/cutpoint/kind.ts @@ -0,0 +1,26 @@ +// The cutpoint view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { cutpointSvg } from "./figure.ts"; + +export const cutpointKind: ViewKindDef<"cutpoint"> = { + kind: "cutpoint", + figure: (v, o) => v.cutpoint.candidates.length ? cutpointSvg(v, o) : null, + title: (v) => "Cut-point" + (v.cutpoint ? " — " + v.cutpoint.attribute + " on " + v.cutpoint.endpoint : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const m = v.cutpoint; + const side = (s: { n: number; events: number; median: number | null }) => `n=${s.n}, ${s.events} events, median ${s.median == null ? "not reached" : s.median.toFixed(1) + "mo"}`; + out.push(`Cut-point of ${m.attribute} on ${m.endpoint} (maximally selected log-rank, ${m.candidates.length} candidate cuts between the 10th and 90th percentiles; ${m.n.toLocaleString()} patients, ${m.events.toLocaleString()} events):\n` + + ` best cut ${m.attribute} > ${m.cut}: high ${side(m.high)}; low ${side(m.low)}` + (m.hr ? `; HR ${m.hr.hr.toFixed(2)} (95% CI ${m.hr.ciLow.toFixed(2)}-${m.hr.ciHigh.toFixed(2)})` : "") + "\n" + + ` corrected p=${pFmt(m.permP)} (${m.permutations} permutations, seed ${m.seed}); naive log-rank p=${pFmt(m.naiveP)}, which ignores the search` + + (m.strata ? "\n per " + m.strata.attribute + ":\n" + m.strata.rows.map((s) => ` ${s.stratum}: n=${s.n}, cut ${s.cut}, high ${s.high.n}, ` + + (s.hr ? `HR ${s.hr.hr.toFixed(2)}, ` : "") + `corrected p=${pFmt(s.permP)}, naive p=${pFmt(s.naiveP)}`).join("\n") : "") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Looks for the value of a measurement — such as tumour mutational burden — that best splits patients into a group that did better and a group that did worse. It tries every reasonable dividing line (from the lowest tenth of patients to the highest) and draws how strongly each one separates survival; the dot marks the best. Because trying many lines and keeping the best always finds something, the view also reshuffles the measurement among the patients a thousand times to see how good a 'best line' chance alone produces, and reports how surprising the real best line is once that search is accounted for. A line found this way needs checking in other patients before anyone relies on it." + }, + need: "patients", + preview: "no method record or golden test of the maximally selected log-rank scan against R maxstat yet", + isNew: true, +}; diff --git a/lib/views/diffexpr/kind.ts b/lib/views/diffexpr/kind.ts new file mode 100644 index 0000000..af821fe --- /dev/null +++ b/lib/views/diffexpr/kind.ts @@ -0,0 +1,26 @@ +// The diffexpr view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { diffexprSvg } from "./figure.ts"; + +export const diffexprKind: ViewKindDef<"diffexpr"> = { + kind: "diffexpr", + figure: (v, o) => v.diffexpr && v.diffexpr.tested ? diffexprSvg(v, o) : null, + title: (v) => "Differential expression" + (v.diffexpr ? " — " + v.diffexpr.profileType : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const d = v.diffexpr; + const top = (sign: 1 | -1) => d.points.filter((g) => g.q < 0.05 && Math.sign(g.effect) === sign).slice(0, 12) + .map((g) => `${g.gene} ${g.effect > 0 ? "+" : ""}${g.effect.toFixed(2)} (q=${pFmt(g.q)})`).join(", "); + out.push(`Differential expression, ${d.groups[0]} vs ${d.groups[1]} on ${d.profileType}${d.log2 ? " (log2)" : ""}: ` + + `${d.tested.toLocaleString()} genes tested, ${d.up.toLocaleString()} higher in ${d.groups[0]} and ${d.down.toLocaleString()} higher in ${d.groups[1]} at q<0.05 (${d.effectLabel}).\n` + + ` most significant, higher in ${d.groups[0]}: ${top(1) || "none"}\n` + + ` most significant, higher in ${d.groups[1]}: ${top(-1) || "none"}` + + (d.gsea ? `\n GSEA (${d.gsea.ranking}; ${d.gsea.permutations} gene-set permutations):\n` + + d.gsea.sets.slice(0, 12).map((x) => ` ${x.name}: NES ${x.nes.toFixed(2)}, q=${pFmt(x.q)}, ${x.size} genes, leading edge ${x.leadingEdge.slice(0, 8).join(" ")}${x.leadingEdge.length > 8 ? " …" : ""}`).join("\n") : "") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Compares how active every gene is between two groups of patients. Each dot is a gene: further left or right means a bigger difference in activity between the groups, higher up means the difference is less likely to be chance. The panel beside it asks whether whole families of related genes, such as those that drive cell division, lean towards one group." + }, + need: "local", +}; diff --git a/lib/views/drivernegative/kind.ts b/lib/views/drivernegative/kind.ts new file mode 100644 index 0000000..700190a --- /dev/null +++ b/lib/views/drivernegative/kind.ts @@ -0,0 +1,25 @@ +// The drivernegative view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { drivernegativeSvg } from "./figure.ts"; + +export const drivernegativeKind: ViewKindDef<"drivernegative"> = { + kind: "drivernegative", + figure: (v, o) => v.drivernegative.sequenced ? drivernegativeSvg(v, o) : null, + title: (v) => "Driver-negative analysis" + (v.drivernegative ? " — " + v.drivernegative.alteration + ", driver-negative vs driver-positive" : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const d = v.drivernegative, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; + out.push(`Driver-negative analysis (drivers: ${d.drivers.join(", ")}): of ${d.sequenced.toLocaleString()} patients sequenced, ` + + `${d.positive.toLocaleString()} driver-positive (${pct(d.positive, d.sequenced)}), ${d.negative.toLocaleString()} driver-negative (${pct(d.negative, d.sequenced)}), ` + + `${d.unprofiled.toLocaleString()} not fully profiled (in neither group).\n` + + ` ${d.alteration} frequency, driver-negative vs driver-positive, Fisher + BH over ${d.tested} genes, ${d.significant} at q<0.05:\n` + + d.rows.slice(0, 30).map((g) => ` ${g.gene}: ${g.altered[0]}/${g.profiled[0]} (${pct(g.altered[0], g.profiled[0])}) vs ${g.altered[1]}/${g.profiled[1]} (${pct(g.altered[1], g.profiled[1])}), ` + + `log2 ratio ${g.log2Ratio == null ? "undefined" : g.log2Ratio.toFixed(2)}, p=${pFmt(g.p)} q=${pFmt(g.q)}`).join("\n") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Splits one group of patients — say, lung cancers — into those whose tumour carries one of the well-known, usually treatable cancer-driving changes (EGFR, KRAS, ALK and the rest of the list) and those whose tumour carries none, and then asks which other genes are changed more often in the second group. Those genes are candidates for what drives the tumours without a known driver. Patients whose test did not cover every gene on the list are kept apart, since we cannot tell whether they have a driver. The bar shows how big each group is; in the chart, genes to the right are more common without a known driver." + }, + need: "local", + preview: "no method record or golden test of the group split and the per-gene Fisher tests yet", + isNew: true, +}; diff --git a/lib/views/enrichment/figure.ts b/lib/views/enrichment/figure.ts index 9c50ce7..15618b1 100644 --- a/lib/views/enrichment/figure.ts +++ b/lib/views/enrichment/figure.ts @@ -3,6 +3,7 @@ import type { ViewResultOf, DrawnResultOf } from "../../types.ts"; import { volcano, volcanoPlotH } from "../../figures/volcano.ts"; import { + palette, frame, caption, label, textWidth, wrapWidth, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, bandLabels, legend, seriesStyle, textOn, fmtInt, fmtPct, pDigits, tickFormat, esc, FS, SW, type SvgOpts diff --git a/lib/views/enrichment/kind.ts b/lib/views/enrichment/kind.ts new file mode 100644 index 0000000..c96ab88 --- /dev/null +++ b/lib/views/enrichment/kind.ts @@ -0,0 +1,21 @@ +// The enrichment view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { enrichmentSvg } from "./figure.ts"; + +export const enrichmentKind: ViewKindDef<"enrichment"> = { + kind: "enrichment", + figure: (v, o) => v.enrichment && v.enrichment.tested > 0 ? enrichmentSvg(v, o) : null, + title: (v) => "Alteration enrichment" + (v.enrichment ? " — " + v.enrichment.alteration : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const en = v.enrichment; + out.push(`Alteration enrichment (${en.alteration}${en.measure === "driver" ? ", drivers only" : ""}; ${en.test}, BH q over ${en.tested.toLocaleString()} genes; ${en.significant} at q<0.05):\n` + + en.rows.slice(0, 40).map((g) => ` ${g.gene}: ` + g.cells.map((c) => `${c.cohort} ${c.pct == null ? "—" : c.pct + "%"} (${c.altered}/${c.profiled})`).join(" | ") + + (g.log2Ratio != null && g.log2Ratio !== undefined ? `, log2 ratio ${g.log2Ratio.toFixed(2)}` : "") + `, q=${pFmt(g.q)}, enriched in ${g.enrichedIn}`).join("\n") + + (en.rows.length > 40 ? `\n … ${en.tested - 40} more genes tested` : "") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Checks every gene at once for whether it is changed more often in one group of patients than another — mutated, gained, deleted or rearranged, whichever you choose. The genes at the top of the list, and furthest out on the chart, differ most clearly between the groups; the share in each group is beside every gene, so a difference that is clear but small is visible as small." + }, + need: "local", +}; diff --git a/lib/views/exclusivity/kind.ts b/lib/views/exclusivity/kind.ts new file mode 100644 index 0000000..80750d5 --- /dev/null +++ b/lib/views/exclusivity/kind.ts @@ -0,0 +1,25 @@ +// The exclusivity view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { exclusivitySvg } from "./figure.ts"; + +export const exclusivityKind: ViewKindDef<"exclusivity"> = { + kind: "exclusivity", + figure: (v, o) => v.exclusivity && v.exclusivity.pairs.length ? exclusivitySvg(v, o) : null, + title: (v) => "Mutual exclusivity (DISCOVER)" + (v.exclusivity ? " — " + v.exclusivity.cohort : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const ex = v.exclusivity; + out.push(`Mutual exclusivity (DISCOVER background over ${ex.samples.toLocaleString()} samples and ${ex.matrixGenes} genes${ex.stratifyBy ? `, within ${ex.strata.length} ${ex.stratifyBy} strata` : ""}; ` + + `${ex.measure === "driver" ? "OncoKB oncogenic" : "any alteration"}; fit ${ex.converged ? "converged" : "NOT converged"} in ${ex.iterations} rounds) in ${ex.cohort}:\n` + + ex.pairs.map((x) => ` ${x.a}/${x.b}: both ${x.both} of ${x.profiled} profiled, ${x.expected} expected; DISCOVER exclusivity q=${pFmt(x.qExclusive)}, co-occurrence q=${pFmt(x.qCooccur)}` + + `${x.exact ? "" : " (normal approx.)"}; Fisher q=${pFmt(x.fisherQ)}${x.log2Odds != null ? `, log2 OR ${x.log2Odds.toFixed(2)}` : ""}`).join("\n") + + (ex.unassessable.length ? `\n not assessable: ${ex.unassessable.join(", ")}` : "") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Asks, for each pair of genes, whether tumours carry changes in both more or less often than chance — \"mutually exclusive\" genes are rarely changed together, which can mean either change alone is enough to drive the cancer. Its sense of \"chance\" accounts for how many changes each tumour has overall: a tumour with hundreds of changes is expected to share genes with many others, so it does not make two genes look linked just because they both turn up in heavily altered tumours. Beside it is the simpler test that ignores this, to show the difference." + }, + need: "local", + preview: "no method record or golden test of the DISCOVER background fit against the R package yet", + isNew: true, +}; diff --git a/lib/views/forest/figure.ts b/lib/views/forest/figure.ts new file mode 100644 index 0000000..4b412aa --- /dev/null +++ b/lib/views/forest/figure.ts @@ -0,0 +1,82 @@ +import { r1, ROW, HEAD } from "../../figures/forest.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { SvgOpts } from "../../svg-kit.ts"; +import { palette, fmtRatio, textWidth, FS, pDigits, log, label, SERIES, SW, axisBottom, bottomAxisHeight, caption, frame } from "../../svg-kit.ts"; +import type { ForestPooled } from "./types.ts"; + +/** + * The forest view: one row per stratum (n, events, HR [CI], its random-effects weight, the square + * sized by that weight), then the fixed- and random-effects pools as diamonds. + */ +export function metaForestSvg(v: DrawnResultOf<"forest">, o: SvgOpts): string { + const f = v.forest, W = Math.max(520, o.width || 760), p = palette(o.theme); + const pools: [string, ForestPooled][] = []; + if (f.random) pools.push(["Random effects (DerSimonian–Laird)", f.random]); + if (f.fixed) pools.push(["Fixed effect", f.fixed]); + const value = (x: { hr: number; ciLow: number; ciHigh: number }) => fmtRatio(x.hr) + " [" + fmtRatio(x.ciLow) + "–" + fmtRatio(x.ciHigh) + "]"; + const count = (r: { n: number; events: number }) => r.n.toLocaleString("en-US") + " (" + r.events.toLocaleString("en-US") + ")"; + const pct = (w: number) => (100 * w).toFixed(1) + "%"; + const labW = Math.min(W * 0.3, Math.max(textWidth(f.strata, FS.small), ...f.rows.map((r) => textWidth(r.label, FS.body)), + ...pools.map(([l]) => textWidth(l, FS.body, 600) * 0.75))); + const cntW = Math.max(textWidth("n (events)", FS.small), ...f.rows.map((r) => textWidth(count(r), FS.body))); + const valW = Math.max(textWidth("HR [95% CI]", FS.small), ...f.rows.map((x) => textWidth(value(x), FS.body)), ...pools.map(([, x]) => textWidth(value(x), FS.body, 600))); + const wW = pools.length ? Math.max(textWidth("weight / p", FS.small), textWidth("100.0%", FS.body), ...pools.map(([, x]) => textWidth(pDigits(x.p), FS.body))) : 0; + const cntR = 8 + labW + 12 + cntW, L = cntR + 16; + const wR = W - 8, valL = wR - (wW ? wW + 14 : 0) - valW, R = valL - 14; + const all = [...f.rows, ...pools.map(([, x]) => x)]; + const est = all.map((c) => c.hr).filter((r) => Number.isFinite(r) && r > 0); + const eLo = Math.min(1, ...est), eHi = Math.max(1, ...est); + const ci = all.flatMap((c) => [c.ciLow, c.ciHigh]).filter((r) => Number.isFinite(r) && r > 0).map((r) => Math.min(eHi * 4, Math.max(eLo / 4, r))); + const xs = log(Math.min(eLo, ...ci) * 0.9, Math.max(eHi, ...ci) * 1.1, L, R, { nice: false }); + const [d0, d1] = xs.domain; + const at = (r: number) => r1(xs(Math.min(d1, Math.max(d0, r)))); + let offScale = 0; + const out: string[] = []; + out.push(label(8, 14, f.strata, { size: FS.small, fill: p.muted })); + out.push(label(cntR, 14, "n (events)", { size: FS.small, fill: p.muted, anchor: "end" })); + out.push(label(valL, 14, "HR [95% CI]", { size: FS.small, fill: p.muted })); + if (wW) out.push(label(wR, 14, "weight / p", { size: FS.small, fill: p.muted, anchor: "end" })); + let y = HEAD; + const top = y, maxW = Math.max(...f.rows.map((r) => r.weight), 1e-9); + const interval = (lo: number, hi: number, cy: number) => { + const a = Math.max(lo, d0), b = Math.min(hi, d1); + if (b > a) out.push(``); + for (const [past, x, dir] of [[lo < d0, xs(d0), -1], [hi > d1, xs(d1), 1]] as [boolean, number, number][]) { + if (!past) continue; + offScale++; + out.push(``); + } + }; + f.rows.forEach((r, i) => { + const cy = y + ROW / 2, ty = cy + FS.body * 0.35; + if (i % 2 === 0) out.push(``); + out.push(label(8, ty, r.label, { size: FS.body, fill: p.ink, maxW: labW })); + out.push(label(cntR, ty, count(r), { size: FS.body, fill: p.ink, anchor: "end" })); + interval(r.ciLow, r.ciHigh, cy); + const s = pools.length ? 4 + 5 * Math.sqrt(r.weight / maxW) : 8; + out.push(``); + out.push(label(valL, ty, value(r), { size: FS.body, fill: p.ink })); + if (wW) out.push(label(wR, ty, pct(r.weight), { size: FS.body, fill: p.muted, anchor: "end" })); + y += ROW; + }); + if (pools.length) { + out.push(``); + y += 8; + for (const [name, x] of pools) { + const cy = y + ROW / 2, ty = cy + FS.body * 0.35; + out.push(label(8, ty, name, { size: FS.body, fill: p.ink, weight: 600, maxW: cntR - 8 })); + const a = at(x.ciLow), b = at(x.ciHigh), m = at(x.hr); + out.push(``); + out.push(label(valL, ty, value(x), { size: FS.body, fill: p.ink, weight: 600 })); + out.push(label(wR, ty, pDigits(x.p), { size: FS.body, fill: x.p < 0.05 ? p.ink : p.muted, anchor: "end" })); + y += ROW; + } + } + out.push(``); + const axTitle = "HR for " + f.exposedLabel + " (log scale) — right of 1 is worse " + f.endpoint; + out.push(axisBottom(xs, y + 4, p, { title: axTitle, minX: L - 20, maxX: R + 20 })); + y += 4 + bottomAxisHeight(axTitle) + 6; + const cap = caption((pools.length ? "Rows' last column is their weight in the random-effects pool; the pooled rows' is their p. " : "") + (v.caveat || "") + (offScale ? " " + offScale + " interval end" + (offScale === 1 ? " runs" : "s run") + " past the axis and " + + (offScale === 1 ? "is" : "are") + " drawn as an arrow; the numbers beside it are exact." : ""), W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, false, "Forest plot — " + f.exposedLabel + " by " + f.strata); +} diff --git a/lib/views/forest/kind.ts b/lib/views/forest/kind.ts new file mode 100644 index 0000000..d5aef3a --- /dev/null +++ b/lib/views/forest/kind.ts @@ -0,0 +1,26 @@ +// The forest view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { metaForestSvg } from "./figure.ts"; + +export const forestKind: ViewKindDef<"forest"> = { + kind: "forest", + figure: (v, o) => v.forest.rows.length ? metaForestSvg(v, o) : null, + title: (v) => "Forest plot" + (v.forest ? " — " + v.forest.exposedLabel + " by " + v.forest.strata + ", " + v.forest.endpoint : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const f = v.forest, hr = (x: { hr: number; ciLow: number; ciHigh: number; p: number }) => + `HR ${x.hr.toFixed(2)} (95% CI ${x.ciLow.toFixed(2)}-${x.ciHigh.toFixed(2)}) p=${pFmt(x.p)}`; + out.push(`Forest plot: ${f.endpoint} hazard ratio for ${f.exposedLabel} by ${f.strata}` + (f.covariates.length ? `, adjusted for ${f.covariates.join(", ")}` : "") + + ` (n=${f.n.toLocaleString()}, ${f.events.toLocaleString()} events):\n` + + f.rows.map((r) => ` ${r.label} (n=${r.n.toLocaleString()}, ${r.events} events, ${r.exposed} exposed): ${hr(r)}${f.random ? `, weight ${(100 * r.weight).toFixed(1)}%` : ""}`).join("\n") + + (f.random && f.fixed ? `\n pooled, random effects (DerSimonian–Laird): ${hr(f.random)}\n pooled, fixed effect: ${hr(f.fixed)}` + + `\n heterogeneity: tau²=${f.tau2.toFixed(3)}, I²=${(100 * f.i2).toFixed(0)}%, Q=${f.q.toFixed(1)} on ${f.qDf} df, p=${pFmt(f.qP)}` : "") + + (f.left.length ? `\n not estimated: ` + f.left.map((l) => `${l.label} (${l.reason})`).join("; ") : "") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Asks whether one thing — a gene being mutated, a high tumour mutation burden — goes with shorter or longer survival, separately in each cancer type, and then combines those answers. Each row is one cancer type: its square is the hazard ratio (right of the dashed line at 1 means patients with the change died sooner; left means they lived longer) and its line how sure we can be. The diamonds at the bottom combine the rows into one overall estimate. When the rows disagree a lot (a high I²), the overall estimate is an average of different effects, so read the rows. This shows an association, not a cause." + }, + need: "patients", + preview: "no method record or golden test of the per-stratum Cox fits and the DerSimonian–Laird pooling yet", + isNew: true, +}; diff --git a/lib/views/genome/kind.ts b/lib/views/genome/kind.ts new file mode 100644 index 0000000..fe9a02c --- /dev/null +++ b/lib/views/genome/kind.ts @@ -0,0 +1,35 @@ +// The genome view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { genomeSvg } from "./figure.ts"; + +export const genomeKind: ViewKindDef<"genome"> = { + kind: "genome", + figure: (v, o) => v.genome && v.genome.series.length ? genomeSvg(v, o) : null, + title: () => "Genome-wide copy number", + text: (v, { out, caveatOf, noteOf }) => { + const g = v.genome; + // per chromosome, the highest gained and lost share of each cohort — enough to name the + // regions without reciting every bin + const perChrom = (s: typeof g.series[number]) => { + const best = new Map(); + g.bins.forEach((b, i) => { + if (!s.profiled[i]) return; + const cur = best.get(b.chrom) || { gain: 0, loss: 0 }; + cur.gain = Math.max(cur.gain, 100 * s.gain[i] / s.profiled[i]); + cur.loss = Math.max(cur.loss, 100 * s.loss[i] / s.profiled[i]); + best.set(b.chrom, cur); + }); + return [...best].map(([c, x]) => `chr${c} +${x.gain.toFixed(0)}/−${x.loss.toFixed(0)}`).join(", "); + }; + out.push(`Genome-wide copy number (${g.build}, ${g.binMb} Mb bins, |log2| ≥ ${g.threshold}; peak % gained/lost per chromosome):\n` + + g.series.map((s) => ` ${s.cohort} (${s.segmented.toLocaleString()} segmented samples): ${perChrom(s)}`).join("\n") + + (g.test ? `\n bins differing at q<0.05: ${g.test.significantGain} for gains, ${g.test.significantLoss} for losses, of ${g.bins.length}` : "") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Walks along every chromosome and shows, at each stretch, what share of the group's tumours have extra copies of that stretch of DNA (drawn upward, in red) or have lost copies of it (drawn downward, in blue). Stretches gained or lost in many tumours often hold genes that help the cancer grow, or genes that would have held it back." + }, + need: "local", + isNew: true, +}; diff --git a/lib/views/germline/kind.ts b/lib/views/germline/kind.ts new file mode 100644 index 0000000..56b541b --- /dev/null +++ b/lib/views/germline/kind.ts @@ -0,0 +1,26 @@ +// The germline view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { germlineSvg } from "./figure.ts"; + +export const germlineKind: ViewKindDef<"germline"> = { + kind: "germline", + figure: (v, o) => v.germline.panels.some((pn) => pn.genes.some((g) => g.sequenced)) ? germlineSvg(v, o) : null, + title: (v) => "Germline carriers and second hits" + (v.germline ? " — " + v.germline.genes.length + " genes" : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const gm = v.germline, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; + out.push(`Germline carriers and somatic second hits (${gm.chosen || gm.genes.length + " genes"}; LOH ${gm.loh}):\n` + + gm.panels.map((pn) => ` ${pn.cohort} (${pn.patients.toLocaleString()} patients):\n` + pn.genes.map((g) => + ` ${g.gene}: ${g.carriers}/${g.sequenced} sequenced carry a germline call (${pct(g.carriers, g.sequenced)})${g.outsidePanel ? `, ${g.outsidePanel} more off their panel` : ""}; ` + + `second hit in carriers ${g.carrierEither}/${g.carriers} (${pct(g.carrierEither, g.carriers)}: ${g.carrierSomatic} somatic mutation, ${g.carrierDeletion}/${g.carrierCnaProfiled} deep deletion) vs non-carriers ${g.noncarrierEither}/${g.noncarriers} (${pct(g.noncarrierEither, g.noncarriers)})` + + (g.p != null ? `, Fisher p=${pFmt(g.p)} q=${pFmt(g.q!)}` : "")).join("\n")).join("\n") + + (gm.survival || []).map((s) => `\n OS by ${s.gene} carrier status, ${s.cohort}: ` + s.curves.map((c) => `${c.group} n=${c.n}, ${c.events} deaths, median ${c.median == null ? "not reached" : c.median.toFixed(1) + " mo"}`).join("; ") + + (s.logrank ? `; log-rank p=${pFmt(s.logrank.p)}` : s.noTest ? "; " + s.noTest : "")).join("") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Some people are born with a faulty copy of a cancer gene such as BRCA2 in every cell. For each such gene this counts those patients (carriers) among everyone whose tumour was sequenced for it, and asks whether their tumour knocked out the remaining good copy — a second hit — by a further mutation or by deleting the gene, compared with how often the same gene is hit in patients born without a faulty copy. The commonest second hit, losing the good copy outright (loss of heterozygosity), needs data this view does not read, so the second-hit share is a minimum. Optionally the curves compare survival of carriers and non-carriers for one gene." + }, + need: "local", + preview: "no method record or golden test of the carrier and second-hit counts or the Fisher tests yet", + isNew: true, +}; diff --git a/lib/views/hla/kind.ts b/lib/views/hla/kind.ts new file mode 100644 index 0000000..280ebee --- /dev/null +++ b/lib/views/hla/kind.ts @@ -0,0 +1,30 @@ +// The hla view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { hlaSvg } from "./figure.ts"; + +export const hlaKind: ViewKindDef<"hla"> = { + kind: "hla", + figure: (v, o) => v.hla.panels.some((pn) => pn.typed || pn.lohEvaluated) ? hlaSvg(v, o) : null, + title: (v) => "HLA class I genotype and " + (v.hla ? v.hla.endpoint : "OS"), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const m = v.hla, pc = (a: number, b: number) => b ? (100 * a / b).toFixed(1) + "%" : "—"; + const hr = (h: { hr: number; ciLow: number; ciHigh: number; p: number; n: number } | null) => h ? `HR ${h.hr.toFixed(2)} (95% CI ${h.ciLow.toFixed(2)}-${h.ciHigh.toFixed(2)}, p=${pFmt(h.p)}, n=${h.n})` : "n/a"; + const split = (name: string, s: (typeof m.panels)[number]["byLoh"], why?: string) => s + ? `\n ${m.endpoint} by ${name}: ` + s.curves.map((c) => `${c.label} n=${c.n}, ${c.events} events, median ${c.median == null ? "not reached" : c.median.toFixed(1) + "mo"}`).join("; ") + + (s.logrank ? `; log-rank p=${pFmt(s.logrank.p)}` : "") + `; ${hr(s.hr)}` + (m.covariates.length ? `; adjusted for ${m.covariates.join(", ")} ${hr(s.adjusted)}` : "") + : `\n ${m.endpoint} by ${name} not drawn: ${why || "no data"}`; + out.push(`HLA class I genotype (alleles compared at ${m.resolution === "full" ? "full" : m.resolution + "-field"} resolution) and HLA LOH:\n` + + m.panels.map((pn) => ` ${pn.cohort} (${pn.typed.toLocaleString()} of ${pn.patients.toLocaleString()} patients typed at A, B and C): homozygous at 0 loci ${pc(pn.homozygous[0], pn.typed)}, ` + + `1 ${pc(pn.homozygous[1], pn.typed)}, 2+ ${pc(pn.homozygous[2] + pn.homozygous[3], pn.typed)}; ` + pn.loci.map((l) => `HLA-${l.locus} ${pc(l.homozygous, l.typed)}`).join(", ") + + `; HLA LOH ${pn.loh.toLocaleString()} of ${pn.lohEvaluated.toLocaleString()} with a call (${pc(pn.loh, pn.lohEvaluated)})` + + split("homozygosity (≥1 locus vs none)", pn.byHomozygosity, pn.byHomozygosityWhy) + split("HLA LOH", pn.byLoh, pn.byLohWhy)).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "HLA class I molecules (HLA-A, -B and -C) show pieces of a tumour's proteins to the immune system. Everyone inherits two copies of each; a person who inherited two identical copies at a gene (homozygous) shows a narrower range of pieces, and a tumour can also lose one copy (HLA loss of heterozygosity) to hide. This shows how common each is in a cohort, and whether patients homozygous at any of the three genes, or whose tumour lost a copy, lived shorter or longer. In patients treated with immunotherapy, homozygosity has been linked to worse survival; in a cohort not limited to them the comparison describes prognosis, not response to treatment." + }, + need: "patients", + preview: "no method record or golden test of the homozygosity and LOH counts or the survival splits yet", + isNew: true, +}; diff --git a/lib/views/hotspots/figure.ts b/lib/views/hotspots/figure.ts new file mode 100644 index 0000000..1e60778 --- /dev/null +++ b/lib/views/hotspots/figure.ts @@ -0,0 +1,14 @@ +// The hotspots view's figure: the lollipop of each gene, its selection applied. +import { filterHotspotGene } from "../../lollipop.ts"; +import { lollipopPanelsSvg, lollipopSummaryText } from "../../lollipop-svg.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { HotspotGene } from "./types.ts"; +import type { SvgOpts } from "../../svg-kit.ts"; + +/** The lollipop standing alone (an MCP client's image): the selection applied, the app's controls in words in the caption. */ +export function hotspotsSvg(v: DrawnResultOf<"hotspots">, o: SvgOpts): string { + const hide = (v.hide || []) as string[]; + const all = (v.hotspots || []) as HotspotGene[]; + return lollipopPanelsSvg(all.map((g) => filterHotspotGene(g, hide).gene), o, + [v.note || "", lollipopSummaryText(all, hide)].filter(Boolean).join(" ")); +} diff --git a/lib/views/hotspots/kind.ts b/lib/views/hotspots/kind.ts new file mode 100644 index 0000000..cf67253 --- /dev/null +++ b/lib/views/hotspots/kind.ts @@ -0,0 +1,24 @@ +// The hotspots view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { lollipopSummaryText } from "../../lollipop-svg.ts"; +import { hotspotsSvg } from "./figure.ts"; + +export const hotspotsKind: ViewKindDef<"hotspots"> = { + kind: "hotspots", + figure: (v, o) => v.hotspots && v.hotspots.length ? hotspotsSvg(v, o) : null, + title: () => "Recurrent protein changes", + text: (v, { out, caveatOf, noteOf }) => { + out.push("Mutations by protein position (commonest changes; share of patients mutated in THAT gene):\n" + + (v.hotspots || []).map((g) => + ` ${g.gene}: ${g.mutated.toLocaleString()} mutated, ${g.mutations.toLocaleString()} mutations on ${g.positions.length.toLocaleString()} codons — ` + + (g.topChanges.slice(0, 6).map((x) => `${x.change} ${x.n.toLocaleString()} (${x.pct}%)`).join(", ") || "no change above the floor") + + (g.unpositioned ? ` [${g.unpositioned.toLocaleString()} with no protein position, not drawn]` : "") + + (g.withheld ? ` [${g.withheld.toLocaleString()} below the suppression floor]` : "")).join("\n") + + "\n " + lollipopSummaryText(v.hotspots || [], v.hide || []) + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Shows exactly where along a gene's protein its mutations land, drawn as a lollipop chart — a taller stem means more patients had a mutation at that exact spot. Mutations clustering tightly at one or two spots, rather than scattering evenly, usually points to a location that matters for how the protein works." + }, +}; diff --git a/lib/views/incidence/kind.ts b/lib/views/incidence/kind.ts new file mode 100644 index 0000000..9f19198 --- /dev/null +++ b/lib/views/incidence/kind.ts @@ -0,0 +1,24 @@ +// The incidence view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { incidenceSvg } from "./figure.ts"; + +export const incidenceKind: ViewKindDef<"incidence"> = { + kind: "incidence", + figure: (v, o) => v.incidence && v.incidence.curves.some((c) => c.n) ? incidenceSvg(v, o) : null, + title: (v) => "Cumulative incidence" + (v.incidence ? " — " + v.incidence.eventLabel + (v.incidence.anchoredTo ? " from " + v.incidence.anchoredTo : "") : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const inc = v.incidence; + out.push(`Cumulative incidence of ${inc.eventLabel}${inc.anchoredTo ? " from " + inc.anchoredTo : ""}, death competing (Aalen-Johansen):\n` + + inc.curves.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()}, ${c.events} events, ${c.competing} died first, ${c.censored} censored; ` + + `not at risk: ${c.excluded.prior} already had it, ${c.excluded.unobserved} before observation, ${c.excluded.noAnchor} no anchor, ${c.excluded.noFollowup} no follow-up`).join("\n") + "\n" + + inc.horizons.map((h) => ` at ${h.months} months: ` + h.cells.map((c) => `${c.cohort} ${(100 * c.f1).toFixed(1)}% (95% CI ${(100 * c.lo).toFixed(1)}-${(100 * c.hi).toFixed(1)}; 1-KM would say ${(100 * c.naive).toFixed(1)}%)`).join(" | ")).join("\n") + + (inc.gray ? `\n Gray's test: statistic ${inc.gray.statistic.toFixed(2)}, permutation p=${pFmt(inc.gray.p)} (${inc.gray.permutations} shuffles)` : "") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Shows what share of patients have experienced a particular event — such as the cancer spreading to the brain — by each point in time. It counts patients who died before the event as never having it, rather than pretending they might still, which is why its numbers can be noticeably lower, and more honest, than a simple survival-style curve would suggest." + }, + need: "local", + isNew: true, +}; diff --git a/lib/views/instability/kind.ts b/lib/views/instability/kind.ts new file mode 100644 index 0000000..57ea043 --- /dev/null +++ b/lib/views/instability/kind.ts @@ -0,0 +1,25 @@ +// The instability view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { instabilitySvg } from "./figure.ts"; + +export const instabilityKind: ViewKindDef<"instability"> = { + kind: "instability", + figure: (v, o) => v.instability && v.instability.panels.some((pn) => pn.score.some((n) => n > 0)) ? instabilitySvg(v, o) : null, + title: () => "Genomic instability — aneuploidy score and FGA", + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const ir = v.instability, f = (s: { median: number | null; q1: number | null; q3: number | null }) => s.median == null ? "—" : `${s.median} [${s.q1}–${s.q3}]`; + out.push(`Genomic instability (hg19 segments, threshold ±${ir.threshold}, arm called past ${Math.round(ir.armShare * 100)}% of its length; ${ir.arms.length} arms):\n` + + ir.panels.map((pn) => ` ${pn.cohort}: ${pn.score.reduce((s, x) => s + x, 0).toLocaleString()} samples scored; aneuploidy score median [IQR] ${f(pn.scoreSpread)}, FGA ${f(pn.fgaSpread)}%\n` + + ` most often gained: ${[...pn.arms].filter((a) => a.measured).sort((a, b) => b.gain / b.measured - a.gain / a.measured).slice(0, 5).map((a) => `${a.arm} ${Math.round(100 * a.gain / a.measured)}%`).join(", ")}\n` + + ` most often lost: ${[...pn.arms].filter((a) => a.measured).sort((a, b) => b.loss / b.measured - a.loss / a.measured).slice(0, 5).map((a) => `${a.arm} ${Math.round(100 * a.loss / a.measured)}%`).join(", ")}`).join("\n") + + (ir.test ? `\n ${ir.test.name}: aneuploidy score p=${pFmt(ir.test.scoreP)}, FGA p=${pFmt(ir.test.fgaP)}` : "") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Cancer cells often gain or lose whole chromosome arms, and how much of a tumour's genome is out of balance is a measure of how unstable it is. For each group of tumours, this shows how many chromosome arms are typically gained or lost (the \"aneuploidy score\") and what share of the genome is altered, then which arms are gained (red) or lost (blue) most often — patterns that differ by cancer type, such as chromosome 20q gained and 18q lost in colorectal cancer." + }, + need: "local", + preview: "no method record, and no check of the arm calls and aneuploidy scores against Taylor et al.'s published values yet", + isNew: true, +}; diff --git a/lib/views/kind-def.ts b/lib/views/kind-def.ts new file mode 100644 index 0000000..cac1af5 --- /dev/null +++ b/lib/views/kind-def.ts @@ -0,0 +1,41 @@ +// What a view kind is to every page and agent, apart from how it runs (run.ts, server-only): its +// figure, its title, its part of the results text, its help, what federation needs of it, and its +// marks. Each kind's lib/views//kind.ts is one of these; lib/views/kinds.ts lists them. +// Browser-safe. +import type { ViewKind, ViewResult, ViewResultOf, DrawnResultOf } from "../types.ts"; +import type { SvgOpts } from "../svg-kit.ts"; +import type { ViewHelp } from "../view-help.ts"; +import type { Need } from "../view-coverage.ts"; + +/** What a kind's text has to hand (lib/result-text.ts builds one per analysis). */ +export interface TextContext { + /** the text so far: a kind pushes its own paragraph */ + out: string[]; + pFmt: (x: number) => string; + /** the engine's caveat, as the model is to pass it on */ + caveatOf: (v: { caveat?: string }) => string; + /** the author's note and the engine's own */ + noteOf: (v: { annotationNote?: string; note?: string }) => string; + /** what a plot's point is, from the view as authored */ + plotUnit: (v: ViewResult) => string; + /** the steps its method record took, for a kind that says them inside its own paragraph */ + methodOf: (v: ViewResult) => string; +} + +export interface ViewKindDef { + kind: K; + /** its figure from a drawn result, or null when there is nothing to draw (lib/view-svg.ts) */ + figure?: (v: DrawnResultOf, o: SvgOpts) => string | null; + /** a one-line title for its figure, for alt text and chat captions; the kind's name without one */ + title?: (v: ViewResultOf) => string; + /** its paragraph of the results text; false when it has none for this result (lib/result-text.ts) */ + text?: (v: DrawnResultOf, t: TextContext) => void | false; + /** the plain-language "What is this?" in its header */ + help: ViewHelp; + /** what it needs of every study it draws (lib/view-coverage.ts); counts only without one */ + need?: Need; + /** what a preview kind still lacks before it is held to the full checks (lib/preview-kinds.ts) */ + preview?: string; + /** cBioPortal has no counterpart for it (lib/new-in-lab.ts) */ + isNew?: boolean; +} diff --git a/lib/views/kinds.ts b/lib/views/kinds.ts new file mode 100644 index 0000000..df28e4c --- /dev/null +++ b/lib/views/kinds.ts @@ -0,0 +1,123 @@ +// Every view kind's descriptor (lib/views//kind.ts), by kind: what the pages, the figures, the +// results text, the help and the federation rules read about a kind. A new kind is one entry here +// and one in lib/views/index.ts (its runner). Browser-safe. +import type { ViewKind, ViewResult } from "../types.ts"; +import type { SvgOpts } from "../svg-kit.ts"; +import type { ViewHelp } from "../view-help.ts"; +import type { Need } from "../view-coverage.ts"; +import type { TextContext, ViewKindDef } from "./kind-def.ts"; +import { acquiredKind } from "./acquired/kind.ts"; +import { actionabilityKind } from "./actionability/kind.ts"; +import { adjustedKind } from "./adjusted/kind.ts"; +import { alterationsKind } from "./alterations/kind.ts"; +import { assaysKind } from "./assays/kind.ts"; +import { attritionKind } from "./attrition/kind.ts"; +import { cancertypesKind } from "./cancertypes/kind.ts"; +import { clinicalKind } from "./clinical/kind.ts"; +import { clonalityKind } from "./clonality/kind.ts"; +import { coexpressionKind } from "./coexpression/kind.ts"; +import { compareKind } from "./compare/kind.ts"; +import { compositionKind } from "./composition/kind.ts"; +import { cooccurrenceKind } from "./cooccurrence/kind.ts"; +import { correlatesKind } from "./correlates/kind.ts"; +import { courseKind } from "./course/kind.ts"; +import { coxKind } from "./cox/kind.ts"; +import { ctdnaKind } from "./ctdna/kind.ts"; +import { cutpointKind } from "./cutpoint/kind.ts"; +import { diffexprKind } from "./diffexpr/kind.ts"; +import { drivernegativeKind } from "./drivernegative/kind.ts"; +import { enrichmentKind } from "./enrichment/kind.ts"; +import { exclusivityKind } from "./exclusivity/kind.ts"; +import { forestKind } from "./forest/kind.ts"; +import { genomeKind } from "./genome/kind.ts"; +import { germlineKind } from "./germline/kind.ts"; +import { hlaKind } from "./hla/kind.ts"; +import { hotspotsKind } from "./hotspots/kind.ts"; +import { incidenceKind } from "./incidence/kind.ts"; +import { instabilityKind } from "./instability/kind.ts"; +import { labsKind } from "./labs/kind.ts"; +import { logisticKind } from "./logistic/kind.ts"; +import { molsubtypeKind } from "./molsubtype/kind.ts"; +import { multistateKind } from "./multistate/kind.ts"; +import { mutationsKind } from "./mutations/kind.ts"; +import { oncoprintKind } from "./oncoprint/kind.ts"; +import { organotropismKind } from "./organotropism/kind.ts"; +import { overlapKind } from "./overlap/kind.ts"; +import { pairedKind } from "./paired/kind.ts"; +import { pathwaysKind } from "./pathways/kind.ts"; +import { plotKind } from "./plot/kind.ts"; +import { predictiveKind } from "./predictive/kind.ts"; +import { riskmodelKind } from "./riskmodel/kind.ts"; +import { rmstKind } from "./rmst/kind.ts"; +import { sequenceKind } from "./sequence/kind.ts"; +import { survivalKind } from "./survival/kind.ts"; +import { tableKind } from "./table/kind.ts"; +import { timelineKind } from "./timeline/kind.ts"; +import { txendpointsKind } from "./txendpoints/kind.ts"; + +/** one entry per kind: a kind in the View union without one is a type error here */ +export const KINDS: { [K in ViewKind]: ViewKindDef } = { + acquired: acquiredKind, + actionability: actionabilityKind, + adjusted: adjustedKind, + alterations: alterationsKind, + assays: assaysKind, + attrition: attritionKind, + cancertypes: cancertypesKind, + clinical: clinicalKind, + clonality: clonalityKind, + coexpression: coexpressionKind, + compare: compareKind, + composition: compositionKind, + cooccurrence: cooccurrenceKind, + correlates: correlatesKind, + course: courseKind, + cox: coxKind, + ctdna: ctdnaKind, + cutpoint: cutpointKind, + diffexpr: diffexprKind, + drivernegative: drivernegativeKind, + enrichment: enrichmentKind, + exclusivity: exclusivityKind, + forest: forestKind, + genome: genomeKind, + germline: germlineKind, + hla: hlaKind, + hotspots: hotspotsKind, + incidence: incidenceKind, + instability: instabilityKind, + labs: labsKind, + logistic: logisticKind, + molsubtype: molsubtypeKind, + multistate: multistateKind, + mutations: mutationsKind, + oncoprint: oncoprintKind, + organotropism: organotropismKind, + overlap: overlapKind, + paired: pairedKind, + pathways: pathwaysKind, + plot: plotKind, + predictive: predictiveKind, + riskmodel: riskmodelKind, + rmst: rmstKind, + sequence: sequenceKind, + survival: survivalKind, + table: tableKind, + timeline: timelineKind, + txendpoints: txendpointsKind, +}; + +/** A descriptor as code that holds a result of any kind calls it: each entry takes only its own kind's result, which its key guarantees. */ +export interface AnyKindDef { + kind: ViewKind; + figure?: (v: ViewResult, o: SvgOpts) => string | null; + title?: (v: ViewResult) => string; + text?: (v: ViewResult, t: TextContext) => void | false; + help: ViewHelp; need?: Need; preview?: string; isNew?: boolean; +} + +/** the descriptor for a view kind, or undefined for a kind the document language does not have */ +export function kindOf(kind: unknown): AnyKindDef | undefined { + const k = String(kind); + return Object.hasOwn(KINDS, k) ? KINDS[k as ViewKind] as unknown as AnyKindDef : undefined; +} diff --git a/lib/views/labs/kind.ts b/lib/views/labs/kind.ts new file mode 100644 index 0000000..ccf8921 --- /dev/null +++ b/lib/views/labs/kind.ts @@ -0,0 +1,24 @@ +// The labs view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { labsSvg } from "./figure.ts"; + +export const labsKind: ViewKindDef<"labs"> = { + kind: "labs", + figure: (v, o) => v.labs && v.labs.panels.some((pn) => pn.evaluable) ? labsSvg(v, o) : null, + title: (v) => "Lab course" + (v.labs ? " — " + v.labs.test + " from " + v.labs.anchoredTo : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const lb = v.labs; + out.push(`Lab course: ${lb.test} % change from baseline, weeks from ${lb.anchoredTo}:\n` + + lb.panels.map((pn) => ` ${pn.cohort}: ${pn.anchored.toLocaleString()} started, ${pn.baseline.toLocaleString()} with a baseline, ${pn.evaluable.toLocaleString()} measured after; ` + + `${pn.evaluable ? (100 * pn.responders / pn.evaluable).toFixed(1) : "—"}% fell ≥${lb.responseDrop}% (${pn.responders}/${pn.evaluable})\n` + + ` median [IQR] by week: ` + pn.course.filter((c) => c.n >= 5).map((c) => `${c.week}w ${c.median}% [${c.q1}, ${c.q3}]`).join(", ")).join("\n") + + (lb.responseP != null ? `\n response rates differ: Fisher p=${pFmt(lb.responseP)}` : "") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Follows a blood test — such as PSA in prostate cancer — from the moment a treatment started, as the change from each patient's own starting level. The line shows the typical patient, the shaded band the middle half of patients, and the bars on the right each patient's best result: how far their level fell, or rose, after treatment began." + }, + need: "local", + isNew: true, +}; diff --git a/lib/views/logistic/figure.ts b/lib/views/logistic/figure.ts new file mode 100644 index 0000000..c6d9d44 --- /dev/null +++ b/lib/views/logistic/figure.ts @@ -0,0 +1,10 @@ +// The logistic view's figure. +import { type Panel, forestSvg } from "../../figures/forest.ts"; +import type { DrawnResultOf } from "../../types.ts"; +import type { SvgOpts } from "../../svg-kit.ts"; + +/** logistic: odds ratios, one panel */ +export function logisticSvg(v: DrawnResultOf<"logistic">, o: SvgOpts): string { + const panels: Panel[] = [{ heading: null, coefs: (v.logistic?.coefficients || []).map((cf) => ({ ...cf, ratio: cf.or })) }]; + return forestSvg(panels, Math.max(520, o.width || 760), o.theme, "OR", v.caveat || "", "Logistic regression — odds ratios"); +} diff --git a/lib/views/logistic/kind.ts b/lib/views/logistic/kind.ts new file mode 100644 index 0000000..3b9af0c --- /dev/null +++ b/lib/views/logistic/kind.ts @@ -0,0 +1,22 @@ +// The logistic view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { logisticSvg } from "./figure.ts"; + +export const logisticKind: ViewKindDef<"logistic"> = { + kind: "logistic", + figure: (v, o) => v.logistic && v.logistic.coefficients.length ? logisticSvg(v, o) : null, + title: () => "Logistic regression", + text: (v, { out, pFmt, caveatOf }) => { + out.push(`Logistic regression (n=${v.logistic.n.toLocaleString()}, ${v.logistic.events.toLocaleString()} with the outcome):\n` + + v.logistic.coefficients.map((c) => + ` ${c.label}${c.reference ? " vs " + c.reference : ""}: OR ${c.or.toFixed(2)} ` + + `(95% CI ${c.ciLow.toFixed(2)}-${c.ciHigh.toFixed(2)}) p=${pFmt(c.p)}`).join("\n") + + caveatOf(v)); + }, + help: { + title: "What is this?", + text: "Estimates how strongly different factors are linked to a yes-or-no outcome — for example, whether a patient responded to a treatment — while accounting for several factors together. Each result is an \"odds ratio\": above 1 means that factor makes the outcome more likely, below 1 means less likely." + }, + need: "patients", + isNew: true, +}; diff --git a/lib/views/molsubtype/kind.ts b/lib/views/molsubtype/kind.ts new file mode 100644 index 0000000..78de583 --- /dev/null +++ b/lib/views/molsubtype/kind.ts @@ -0,0 +1,27 @@ +// The molsubtype view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { molsubtypeSvg } from "./figure.ts"; + +export const molsubtypeKind: ViewKindDef<"molsubtype"> = { + kind: "molsubtype", + figure: (v, o) => v.molsubtype.panels.some((pn) => pn.classified) ? molsubtypeSvg(v, o) : null, + title: (v) => "Molecular subtypes" + (v.molsubtype ? " — " + v.molsubtype.classifier + (v.molsubtype.by ? " by " + v.molsubtype.by : "") : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const m = v.molsubtype, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; + const line = (label: string, c: { counts: number[]; classified: number; notClassifiable: number }) => + ` ${label} (${c.classified.toLocaleString()} classified, ${c.notClassifiable.toLocaleString()} not classifiable): ` + m.subtypes.map((s, i) => `${s} ${c.counts[i]} (${pct(c.counts[i], c.classified)})`).join(", "); + out.push(`Molecular subtypes — ${m.classifierLabel}; MSI by ${m.msi}; p53 abnormal = ${m.p53}:\n` + + m.panels.map((pn) => [line(pn.cohort, pn), ...(pn.groups || []).map((g) => line(" " + g.label, g)), + ...(pn.byTest ? [` by ${m.by}: chi-squared ${pn.byTest.chi2.toFixed(1)} (${pn.byTest.df} df), p=${pFmt(pn.byTest.p)}`] : [])].join("\n")).join("\n") + + (m.cohortTest ? `\n Between cohorts: chi-squared ${m.cohortTest.chi2.toFixed(1)} (${m.cohortTest.df} df), p=${pFmt(m.cohortTest.p)}` : "") + + (m.survival || []).map((s) => `\n OS by subtype, ${s.cohort}: ` + s.curves.map((c) => `${c.subtype} n=${c.n}, ${c.events} deaths, median ${c.median == null ? "not reached" : c.median.toFixed(1) + " mo"}`).join("; ") + + (s.logrank ? `; log-rank chi-squared ${s.logrank.chi2.toFixed(1)} (${s.logrank.df} df), p=${pFmt(s.logrank.p)}` : s.noTest ? "; " + s.noTest : "")).join("") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Sorts each patient's endometrial cancer into the four molecular groups doctors now use alongside the microscope: POLE-ultramutated (a tumour with a broken DNA-copying enzyme, which does very well), mismatch-repair deficient (a tumour that cannot fix certain DNA errors, read here from its MSI test), p53-abnormal (the group that does worst), and no specific molecular profile. The groups are assigned in that order, as the WHO guideline does. Patients whose test could not tell — no MSI result, or a gene panel that missed POLE or TP53 — are counted as not classifiable rather than guessed. The bars show each group's share, overall and by the attribute you pick; the curves show survival in each group." + }, + need: "local", + preview: "no method record or golden test of the per-patient classification, the chi-squared tests or the survival split yet", + isNew: true, +}; diff --git a/lib/views/multistate/kind.ts b/lib/views/multistate/kind.ts new file mode 100644 index 0000000..b04fad7 --- /dev/null +++ b/lib/views/multistate/kind.ts @@ -0,0 +1,27 @@ +// The multistate view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { MS_TRANSITIONS, msRateText, multistateSvg } from "./figure.ts"; + +export const multistateKind: ViewKindDef<"multistate"> = { + kind: "multistate", + figure: (v, o) => v.multistate.cohorts.some((c) => c.n) ? multistateSvg(v, o) : null, + title: () => "Multistate model — progression and death", + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const m = v.multistate; + out.push(`Multistate (illness-death) model, months from ${m.origin}:\n` + + m.cohorts.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()} (${c.prevalent.toLocaleString()} progressed by day 0); ` + + c.rates.map((r) => `${MS_TRANSITIONS[r.key]} ${r.events} events, ${msRateText(r)} per 100 person-months`).join("; ") + + `\n in each state (not progressed / progressed / dead): ` + + c.atHorizon.map((h) => `${h.months} mo ` + (h.p0 == null ? "n/a" : `${(100 * h.p0).toFixed(0)}% / ${(100 * h.p1!).toFixed(0)}% / ${(100 * h.p2!).toFixed(0)}%`)).join(", ")).join("\n") + + m.cox.map((x) => `\n ${MS_TRANSITIONS[x.key]}: ` + (x.noFit ? `no hazard ratio (${x.noFit})` : + x.rows.map((r) => `${r.cohort} vs ${m.reference} HR ${r.hr.toFixed(2)} (95% CI ${r.lo.toFixed(2)}-${r.hi.toFixed(2)}, ${pFmt(r.p)})`).join("; "))).join("") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Follows each patient through three states — alive without progression, alive after their cancer was first recorded as growing, and dead — from the start of their record (in MSK-CHORD, their first tumour sequencing). The coloured bands show, month by month, what share of each group is in each state; the lines below show how fast patients move along each of the three paths (progressing, dying without progressing, dying after progressing), and the table gives each path's rate and how much faster or slower each group moves along it than the first. Splitting death into 'after progression' and 'without it' shows where a group's worse survival comes from. Progression is read from radiology reports by a model, so it is only seen in patients who were scanned; the groups were not randomised, so a difference is a clue, not proof." + }, + need: "local", + preview: "no method record or golden test of the Aalen–Johansen occupation, Nelson–Aalen hazards or the delayed-entry Cox fits yet (no interval on the occupation)", + isNew: true, +}; diff --git a/lib/views/mutations/kind.ts b/lib/views/mutations/kind.ts new file mode 100644 index 0000000..eb87e65 --- /dev/null +++ b/lib/views/mutations/kind.ts @@ -0,0 +1,18 @@ +// The mutations view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { tableText } from "../text.ts"; + +export const mutationsKind: ViewKindDef<"mutations"> = { + kind: "mutations", + text: (v, { out, caveatOf, noteOf }) => { + const t = v.mutations; + const title = `${v.mutations.gene} mutations`; + out.push(`${title}: ${t.total < t.all ? `${t.total.toLocaleString()} of ${t.all.toLocaleString()} rows kept by the query` : `${t.total.toLocaleString()} rows`}; ` + + tableText(t) + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Lists every mutation found in one gene in your groups, one row per mutation: which sample it was in, the exact protein change, whether OncoKB considers it cancer-causing, what kind of change it is, and how much of the tumour carries it. Click a column to sort by it; the download saves every row. It sits under the lollipop chart, which shows the same mutations as positions along the protein." + }, + need: "patients", +}; diff --git a/lib/views/oncoprint/kind.ts b/lib/views/oncoprint/kind.ts new file mode 100644 index 0000000..7796dec --- /dev/null +++ b/lib/views/oncoprint/kind.ts @@ -0,0 +1,25 @@ +// The oncoprint view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; + +export const oncoprintKind: ViewKindDef<"oncoprint"> = { + kind: "oncoprint", + figure: (v) => v.svg || null, + title: () => "Oncoprint", + text: (v, { out, noteOf }) => { + if (!v.svg && v.spec) { + // the web page draws its oncoprints itself, so its results carry the spec, not the figure + const sp = v.spec as { genes?: unknown[] }; + out.push(`Oncoprint of ${(sp.genes || []).length} gene track(s) across ${(v.cohorts || []).join(", ")} — drawn in the page from its spec; ` + + `per-gene frequencies are in the figure's header, not in this summary.` + noteOf(v)); + } else if (v.svg) { + out.push(`Oncoprint: ${v.nColumns!.toLocaleString()} patients — ` + + v.genes!.map((g) => `${g.gene} ${g.driverPct}%/${g.anyPct}%`).join(", ") + + ` — annotation ${v.annotation}.` + noteOf(v)); + } else return false; + }, + help: { + title: "What is this?", + text: "A grid with one column per patient and one row per gene, color-coded to show what kind of genetic change (if any) each patient has in each gene. It's a fast way to see patterns across many patients and genes at once — for example, whether certain alterations tend to show up in the same patients, or never together." + }, + need: "patients", +}; diff --git a/lib/views/organotropism/kind.ts b/lib/views/organotropism/kind.ts new file mode 100644 index 0000000..bdb96a8 --- /dev/null +++ b/lib/views/organotropism/kind.ts @@ -0,0 +1,29 @@ +// The organotropism view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { organotropismSvg } from "./figure.ts"; + +export const organotropismKind: ViewKindDef<"organotropism"> = { + kind: "organotropism", + figure: (v, o) => v.organotropism.sites.length && v.organotropism.genes.length ? organotropismSvg(v, o) : null, + title: (v) => "Organotropism" + (v.organotropism ? " — " + v.organotropism.genes.length + " genes × " + v.organotropism.sites.length + " sites, " + v.organotropism.source : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const o = v.organotropism, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; + const lab = new Map(o.sites.map((s) => [s.site, s.label])); + const sig = o.cells.filter((c) => c.q != null && c.q < 0.05).sort((a, b) => a.p! - b.p!); + out.push(`Metastatic organotropism (${o.patients.toLocaleString()} patients; sites from ${o.sourceLabel}; ${o.measure === "driver" ? "OncoKB-driver " : ""}${o.alteration}s; ` + + `${o.stratifyBy ? "CMH stratified by " + o.stratifyBy : "unstratified"}): ${o.genes.length} genes × ${o.sites.length} sites, ${o.tested} cells tested, ${o.significant} at q<0.05.\n` + + ` Sites (patients with a metastasis there / evaluable): ` + o.sites.map((s) => `${s.label} ${s.withSite}/${s.evaluable}`).join(", ") + "\n" + + sig.slice(0, 30).map((c) => ` ${lab.get(c.site) || c.site} × ${c.gene}: altered ${c.withAltered}/${c.withSite} (${pct(c.withAltered, c.withSite)}) with the site vs ${c.withoutAltered}/${c.without} (${pct(c.withoutAltered, c.without)}) without, ` + + `MH OR ${c.or == null ? "undefined" : c.or.toFixed(2)}, p=${pFmt(c.p!)} q=${pFmt(c.q!)}`).join("\n") + + (o.incidence ? `\n Cumulative incidence of ${o.incidence.site} by ${o.incidence.gene} (death competing): ` + + o.incidence.horizons.map((h) => `${h.months} mo ` + h.cells.map((c) => `${c.cohort} ${(100 * c.f1).toFixed(1)}% [${(100 * c.lo).toFixed(1)}–${(100 * c.hi).toFixed(1)}]`).join(" vs ")).join("; ") + + (o.incidence.gray ? `; Gray's test p=${pFmt(o.incidence.gray.p)}` : "") : o.noIncidence ? `\n No incidence panel: ${o.noIncidence}` : "") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Asks, for each organ a cancer spread to — brain, liver, bone and the rest — whether patients whose tumour carries a change in a given gene spread there more or less often than patients whose tumour does not, comparing patients within the same cancer type. Red squares mean the gene's change goes with spread to that organ, blue that it goes with spreading there less; outlined squares are the ones unlikely to be chance after testing many pairs. Where the study dates each site, the curve below shows how quickly patients with and without one gene's change develop spread to one organ, counting deaths as a separate outcome. Sites are only known where scans or biopsies found them." + }, + need: "local", + preview: "no method record or golden test of the per-cell Cochran–Mantel–Haenszel tests or the site incidence yet", + isNew: true, +}; diff --git a/lib/views/overlap/kind.ts b/lib/views/overlap/kind.ts new file mode 100644 index 0000000..c5e2d12 --- /dev/null +++ b/lib/views/overlap/kind.ts @@ -0,0 +1,21 @@ +// The overlap view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { overlapSvg } from "./figure.ts"; + +export const overlapKind: ViewKindDef<"overlap"> = { + kind: "overlap", + figure: (v, o) => v.overlap && v.overlap.union > 0 ? overlapSvg(v, o) : null, + title: () => "Cohort overlap", + text: (v, { out, caveatOf, noteOf }) => { + const ov = v.overlap; + out.push(`Cohort overlap (patients in exactly these cohorts and no other):\n` + + ` cohort sizes: ` + ov.sets.map((x) => `${x.name} ${x.size.toLocaleString()}`).join(", ") + `\n` + + ov.intersections.map((x) => ` ${x.members.join(" ∩ ")}${x.members.length < ov.sets.length ? " only" : ""}: ${x.n.toLocaleString()}`).join("\n") + + `\n ${ov.union.toLocaleString()} patients in at least one cohort` + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Shows how the groups of patients you defined overlap: for every combination of groups, how many patients belong to exactly those groups and none of the others. Each column is one combination — the dots below it say which groups — and its bar is how many patients fall there, so you can see at a glance whether two groups are mostly the same people or nearly separate." + }, + need: "local", +}; diff --git a/lib/views/paired/kind.ts b/lib/views/paired/kind.ts new file mode 100644 index 0000000..421101b --- /dev/null +++ b/lib/views/paired/kind.ts @@ -0,0 +1,27 @@ +// The paired view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { pairedSvg } from "./figure.ts"; + +export const pairedKind: ViewKindDef<"paired"> = { + kind: "paired", + figure: (v, o) => v.paired && v.paired.panels.some((pn) => pn.patients) ? pairedSvg(v, o) : null, + title: (v) => "Paired samples" + (v.paired ? " — " + v.paired.labels.a + " vs " + v.paired.labels.b : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const pr = v.paired, aL = pr.labels.a, bL = pr.labels.b; + const sides = pr.perStudy ? Object.entries(pr.perStudy).map(([st, sp]) => `${st}: ${sp.attribute} ${sp.a.join("/")} vs ${sp.b.join("/")}`).join("; ") + : `${pr.pairBy.attribute} ${pr.pairBy.a.join("/")} vs ${pr.pairBy.b.join("/")}`; + out.push(`Paired samples, ${aL} vs ${bL} (${sides}; ${pr.measure === "driver" ? "drivers" : "any alteration"}; profiled on both sides):\n` + + pr.panels.map((pn) => ` ${pn.cohort} (${pn.patients.toLocaleString()} patients with both` + + (pn.tested != null ? `; scanned ${pn.tested.toLocaleString()} genes with a discordant patient, the ${pn.genes.length} most one-sided listed` : "") + `):\n` + + pn.genes.map((g) => ` ${g.gene}: both ${g.both}, ${aL}-only ${g.aOnly}, ${bL}-only ${g.bOnly} of ${g.pairs} pairs` + + (g.concordance != null ? `, ${Math.round(100 * g.concordance)}% concordant` : "") + + (g.aOnly + g.bOnly ? `, McNemar q=${pFmt(g.q)}` : "")).join("\n")).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "For patients who had two kinds of tumour sample tested — usually the original tumour and a later spread of it elsewhere in the body — shows, gene by gene, how often a change was found in both samples, only in the original, or only in the spread. Changes found only in the spread can point to how the cancer adapted, for example to a treatment. The two kinds can also be samples taken before and after a treatment. Without a list of genes, it checks every gene and shows the ones most often found on one side only." + }, + need: "local", + isNew: true, +}; diff --git a/lib/views/pathways/kind.ts b/lib/views/pathways/kind.ts new file mode 100644 index 0000000..9493f00 --- /dev/null +++ b/lib/views/pathways/kind.ts @@ -0,0 +1,24 @@ +// The pathways view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { pathwaysSvg } from "./figure.ts"; + +export const pathwaysKind: ViewKindDef<"pathways"> = { + kind: "pathways", + figure: (v, o) => v.pathways && v.pathways.rows.length ? pathwaysSvg(v, o) : null, + title: () => "Oncogenic signalling pathways", + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const pw = v.pathways; + out.push(`Oncogenic signalling pathways (% of patients with ≥1 member ${pw.measure === "driver" ? "driver" : "altered"}; union-of-panels denominator):\n` + + pw.rows.map((r) => ` ${r.name} (${r.profiledGenes}/${r.genes} genes profiled): ` + + r.cells.map((c) => `${c.cohort} ${c.pct == null ? "not profiled" : c.pct + "%"}`).join(" | ") + + (r.q != null ? ` q=${pFmt(r.q)}` : "") + + (r.top.length ? `; top: ` + r.top.map((t) => `${t.gene} ${t.cells.map((c) => c.pct == null ? "–" : c.pct + "%").join("/")}`).join(", ") : "") + + (r.otherAltered ? `, +${r.otherAltered} more altered` : "")).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Groups genes into ten well-known signalling pathways — chains of genes that together tell a cell when to grow, divide or die — and shows what share of each group of patients has a harmful change in at least one gene of each pathway. Two cancers can reach the same broken pathway through different genes, and this view makes that visible." + }, + need: "local", +}; diff --git a/lib/views/plot/figure.ts b/lib/views/plot/figure.ts index 285df81..5dc4413 100644 --- a/lib/views/plot/figure.ts +++ b/lib/views/plot/figure.ts @@ -9,6 +9,7 @@ import type { HighlightPoint, PlotData, PlotLayer } from "../../plot.ts"; import { isAbsent, jitterFor } from "../../plot-draw.ts"; import { LAYER_BASE } from "../../plot-layers.ts"; import { + esc, palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, bandLabels, legend, label, textWidth, fmtInt, fmtNum, fmtP, FS, SW, SERIES, OTHER, type Scale, type SvgOpts, type LegendItem } from "../../svg-kit.ts"; diff --git a/lib/views/plot/kind.ts b/lib/views/plot/kind.ts new file mode 100644 index 0000000..08b2e4b --- /dev/null +++ b/lib/views/plot/kind.ts @@ -0,0 +1,46 @@ +// The plot view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { plotSvg } from "./figure.ts"; + +export const plotKind: ViewKindDef<"plot"> = { + kind: "plot", + figure: (v, o) => v.plot ? plotSvg(v, o) || null : null, + text: (v, { out, pFmt, caveatOf, noteOf, plotUnit }) => { + const pd = v.plot; + if (!pd) { out.push("Plot: no data"); } + else if (pd.shape === "scatter") { + const st = pd.stats; + out.push(`Plot (scatter): ${pd.x.label} vs ${pd.y.label} — ${pd.n.toLocaleString()} ${plotUnit(v)}` + + (pd.grid ? `, binned onto a ${pd.grid[0]}x${pd.grid[1]} grid (the payload is bins, never points)` : "") + "." + + (st ? `\n Pearson r=${st.pearson != null ? st.pearson.toFixed(3) : "—"}` + + (st.pPearson != null ? ` p=${pFmt(st.pPearson)}` : "") + + `; Spearman rho=${st.spearman != null ? st.spearman.toFixed(3) : "—"}` + + (st.pSpearman != null ? ` p=${pFmt(st.pSpearman)}` : "") + + (st.slope != null ? `; least-squares slope ${st.slope.toFixed(3)}` : "") : "") + + caveatOf(v) + noteOf(v)); + } else if (pd.shape === "box") { + out.push(`Plot (box): ${pd.y.label} by ${pd.x.label} — ${pd.n.toLocaleString()} ${plotUnit(v)}.\n` + + (pd.groups || []).slice(0, 12).map((g) => + ` ${g.g}: median ${g.median.toFixed(2)} (IQR ${g.q1.toFixed(2)}–${g.q3.toFixed(2)}, ` + + `whiskers ${g.loWhisker.toFixed(2)}–${g.hiWhisker.toFixed(2)}, n=${g.n.toLocaleString()})`).join("\n") + + // WHICH overlay was drawn is a disclosure, not decoration: points + // mean the reader is seeing cases, a density means they are not. + (pd.overlay === "points" ? `\n Values are drawn as individual points.` + : pd.overlay === "violin" ? `\n Values are drawn as a DENSITY, not points — either above the point cap or from a participant that discloses no cases.` + : "") + + // a cohort layer drawn as boxes is a second set of quartiles the figure shows and the note does not + (pd.layers || []).filter((h) => h.groups && h.groups.length).map((h) => `\n Point layer “${h.name}”:\n` + + h.groups!.slice(0, 12).map((g) => ` ${g.g}: median ${g.median.toFixed(2)} (IQR ${g.q1.toFixed(2)}–${g.q3.toFixed(2)}, n=${g.n.toLocaleString()})`).join("\n")).join("") + + caveatOf(v) + noteOf(v)); + } else { + out.push(`Plot (table): ${pd.x.label} vs ${pd.y.label} — ${pd.n.toLocaleString()} ${plotUnit(v)}.\n` + + (pd.cells || []).slice(0, 24).map((c) => ` ${c.cx} x ${c.cy}: ${c.n.toLocaleString()}`).join("\n") + + caveatOf(v) + noteOf(v)); + } + }, + help: { + title: "What is this?", + text: "Plots two measurements against each other for the same patients or samples — for example, a gene's activity level against a patient's age — so you can see whether they tend to rise and fall together, split into distinct clusters, or show no relationship at all." + }, + need: "local", +}; diff --git a/lib/views/predictive/kind.ts b/lib/views/predictive/kind.ts new file mode 100644 index 0000000..ac8dabd --- /dev/null +++ b/lib/views/predictive/kind.ts @@ -0,0 +1,25 @@ +// The predictive view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { predictiveSvg } from "./figure.ts"; + +export const predictiveKind: ViewKindDef<"predictive"> = { + kind: "predictive", + figure: (v, o) => v.predictive.genes.length ? predictiveSvg(v, o) : null, + title: (v) => "Predictive biomarkers" + (v.predictive ? " — gene × " + v.predictive.exposure : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const m = v.predictive; + const eff = (e: { hr: number; ciLow: number; ciHigh: number; p: number } | null) => e ? `HR ${e.hr.toFixed(2)} (95% CI ${e.ciLow.toFixed(2)}-${e.ciHigh.toFixed(2)}, ${pFmt(e.p)})` : "n/a"; + out.push(`Predictive biomarkers: gene x ${m.exposure} on ${m.endpoint} (Cox per gene; ${m.n.toLocaleString()} patients, ${m.exposed.toLocaleString()} exposed, ${m.events.toLocaleString()} events` + + (m.covariates.length ? `; adjusted for ${m.covariates.join(", ")}` : "") + `):\n` + + m.genes.map((g) => ` ${g.gene}: mutated ${g.mutatedExposed}/${g.exposed} exposed, ${g.mutatedUnexposed}/${g.unexposed} unexposed; ` + + (g.interaction ? `among exposed ${eff(g.inExposed)}; among unexposed ${eff(g.inUnexposed)}; interaction ${eff(g.interaction)}, q=${pFmt(g.q!)}` : `not fitted: ${g.skipped}`)).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Asks, gene by gene, whether a mutation changes how patients do on a particular treatment — not just whether it marks a better or worse outlook in general. For each gene it compares patients with and without the mutation twice: among those who received the treatment (upper marker) and among those who did not (lower marker). A gene whose markers sit far apart — say, much worse with the mutation only among the treated — may predict who benefits from that treatment; the right-hand column tests that difference. Treatment here was not assigned at random, and patients had to live long enough to receive it, so a finding is a lead to test properly, not proof." + }, + need: "patients", + preview: "no method record or golden test of the per-gene interaction fits yet", + isNew: true, +}; diff --git a/lib/views/riskmodel/kind.ts b/lib/views/riskmodel/kind.ts new file mode 100644 index 0000000..7b15c8a --- /dev/null +++ b/lib/views/riskmodel/kind.ts @@ -0,0 +1,38 @@ +// The riskmodel view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { riskSpan, riskmodelSvg } from "./figure.ts"; + +export const riskmodelKind: ViewKindDef<"riskmodel"> = { + kind: "riskmodel", + figure: (v, o) => v.riskmodel ? riskmodelSvg(v, o) : null, + title: (v) => "Risk model" + (v.riskmodel ? " — elastic-net Cox, " + v.riskmodel.runs + " runs" : ""), + text: (v, { out, pFmt, caveatOf, noteOf, methodOf }) => { + const m = v.riskmodel; + const pc = (x: number | null) => x == null ? "n/a" : Math.round(x * 100) + "%"; + out.push(`Risk model (elastic-net Cox, ${m.runs} held-out splits, alpha ${m.alpha}, seed ${m.seed}; ${m.n.toLocaleString()} patients, ${m.events.toLocaleString()} events, ` + + `${m.candidates} candidate genes${m.strata ? ", stratified by " + m.strata : ""}${m.anchoredTo ? ", clock from " + m.anchoredTo : ""}):\n` + + ` held-out C-index ${m.cIndex.median.toFixed(3)} (IQR ${m.cIndex.q1.toFixed(3)}–${m.cIndex.q3.toFixed(3)})` + + (m.validation ? `; trained on the discovery half only (${m.validation.discovery} patients), C-index on the validation half ${m.validation.cIndex.toFixed(3)} (${m.validation.validation} patients)` : "") + "\n" + + (m.covariates.length ? " adjusted for: " + m.covariates.map((c) => `${c.name} HR ${c.hr.toFixed(3)}`).join(", ") + "\n" : "") + + ` risk score on OncoCast's 0–10 scale (0 and 10 the lowest and highest held-out score${m.validation ? " in the discovery half" : ""}; raw ${m.scale.min.toFixed(3)} to ${m.scale.max.toFixed(3)})\n` + + ` risk groups (${m.reportedOn}${m.cutsChosen ? `; cut at the view's chosen quantiles ${m.cutsChosen.join(", ")}` : ", equal quantiles"}):\n` + + m.groups.map((g) => ` ${g.name} (score ${riskSpan(m, g)}): n=${g.curve.n}, ${g.curve.events} events, median ${g.curve.median == null ? "not reached" : g.curve.median.toFixed(1) + "mo"}, ` + + `12-month ${pc(g.at12)}, 36-month ${pc(g.at36)}` + (g.hr != null ? `, HR vs lowest ${g.hr.toFixed(2)} (95% CI ${g.ciLow!.toFixed(2)}–${g.ciHigh!.toFixed(2)}, ${pFmt(g.p!)})` : "")).join("\n") + + (m.logrank ? `\n log-rank p=${pFmt(m.logrank.p)}` : "") + + (m.refit ? `\n risk score alone: HR ${m.refit.hr.toFixed(2)} per SD (95% CI ${m.refit.ciLow.toFixed(2)}–${m.refit.ciHigh.toFixed(2)}, ${pFmt(m.refit.p)})` : "") + + "\n genes kept most often: " + m.features.slice(0, 15).map((f) => `${f.gene} ${Math.round(f.selected * 100)}% HR ${f.hr.toFixed(2)}`).join(", ") + + (m.neverHeldOut ? `\n ${m.neverHeldOut} patients were never held out, so have no score` : "") + + (m.external ? `\n external validation on ${m.external.cohort} (${m.external.study}; ${m.external.n} patients scored, ${m.external.notCovered} not covered by the model's genes): ` + + `C-index ${m.external.cIndex.toFixed(3)} (95% bootstrap CI ${m.external.cIndexLo.toFixed(3)}–${m.external.cIndexHi.toFixed(3)})` + + (m.external.logrank ? `, log-rank p=${pFmt(m.external.logrank.p)}` : "") + "\n" + + m.external.groups.map((g) => ` ${g.name}: n=${g.curve.n}, ${g.curve.events} events, median ${g.curve.median == null ? "not reached" : g.curve.median.toFixed(1) + "mo"}` + + (g.hr != null ? `, HR vs lowest ${g.hr.toFixed(2)} (95% CI ${g.ciLow!.toFixed(2)}–${g.ciHigh!.toFixed(2)})` : "")).join("\n") : "") + + caveatOf(v) + methodOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "A model that predicts survival from which genes are mutated in each patient, in the style of the OncoCast package. It is trained many times, each time on two thirds of the patients and tested on the other third, so every patient's risk score comes from a model that never saw them. Patients are then split into low, intermediate and high risk, and their survival curves are drawn. The list shows the genes the model relied on most, with a hazard ratio above 1 meaning higher risk. Tick \"validate\" to train on one half of the patients and grade the model on the other half, which it never saw, or name another group of patients under \"validate on\" to grade it there — for example patients from a different study — with the same risk-group boundaries." + }, + need: "patients", + isNew: true, +}; diff --git a/lib/views/rmst/kind.ts b/lib/views/rmst/kind.ts new file mode 100644 index 0000000..39c9259 --- /dev/null +++ b/lib/views/rmst/kind.ts @@ -0,0 +1,24 @@ +// The rmst view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { rmstDiffText, rmstRatioText, rmstSvg, rmstText } from "./figure.ts"; + +export const rmstKind: ViewKindDef<"rmst"> = { + kind: "rmst", + figure: (v, o) => v.rmst.panels.some((pn) => pn.curves.some((c) => c.n)) ? rmstSvg(v, o) : null, + title: (v) => "Restricted mean survival time" + (v.rmst ? " — " + v.rmst.endpointLabel : ""), + text: (v, { out, caveatOf, noteOf }) => { + const r = v.rmst; + out.push(`Restricted mean survival time, ${r.endpointLabel}:\n` + + r.panels.map((pn) => ` ${pn.landmark ? `from the ${pn.landmark}-month landmark` : "from the start"}, tau=${pn.tau} months${pn.tauSet ? "" : " (default: every cohort >= 10 at risk)"}:\n` + + pn.curves.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()}, RMST ${rmstText(c)}` + + ((x) => x ? `; vs ${x.vs} difference ${rmstDiffText(x)}, ratio ${rmstRatioText(x)}` : "")(pn.comparisons.find((x) => x.cohort === c.cohort))).join("\n")).join("\n") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Answers “how many months, on average, did patients in each group live within the first τ months?” — the shaded area under each survival curve up to τ. The difference between two groups is the months gained or lost in that window, which reads plainly even when the curves cross or only separate late (as with immunotherapy), where a single hazard ratio can mislead. τ is where every group still has at least ten patients followed, unless it is set. Landmark panels ask the same question again among the patients still alive and followed at 6 or 12 months, restarting the clock there. The groups were not randomised, so a difference is a clue, not proof." + }, + need: "local", + preview: "no method record or golden test of the restricted means, their variances and the landmark sets against R survRM2 yet", + isNew: true, +}; diff --git a/lib/views/run.ts b/lib/views/run.ts index a3af0fa..1fc204f 100644 --- a/lib/views/run.ts +++ b/lib/views/run.ts @@ -3,6 +3,7 @@ import type { ProfileInfo } from "../profile-catalog.ts"; import type { AnalysisDoc, ViewResult, ViewParticipation } from "../types.ts"; import type { Resolver } from "../federation.ts"; +// eslint-disable-next-line local/kind-boundary -- riskFit is how the server hands the one model-fitting kind its fitter (a worker); types only import type { RiskFit, RiskFitArgs } from "./riskmodel/run.ts"; /** one view of a document, as authored */ diff --git a/lib/views/sequence/kind.ts b/lib/views/sequence/kind.ts new file mode 100644 index 0000000..52ffed6 --- /dev/null +++ b/lib/views/sequence/kind.ts @@ -0,0 +1,29 @@ +// The sequence view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { sequenceSvg } from "./figure.ts"; + +export const sequenceKind: ViewKindDef<"sequence"> = { + kind: "sequence", + figure: (v, o) => v.sequence && v.sequence.panels.some((z) => z.flows.length) ? sequenceSvg(v, o) : null, + title: (v) => "Treatment sequence" + (v.sequence ? " by " + v.sequence.labelKey : ""), + text: (v, { out, caveatOf, noteOf }) => { + const sq2 = v.sequence; + out.push(`Treatment sequence (lines of therapy by ${sq2 ? sq2.labelKey : "?"}, ` + + `${sq2 ? sq2.lineGap : 90}-day line gap):\n` + + (sq2?.panels || []).map((pn) => { + const first = pn.nodes.filter((n) => n.column === 1 && n.kind === "regimen") + .sort((a, b) => b.n - a.n).slice(0, 5) + .map((n) => `${n.label} ${n.n.toLocaleString()}`).join(", "); + const stopped = pn.nodes.filter((n) => n.kind === "stopped").reduce((t, n) => t + n.n, 0); + const cens = pn.censored; + return ` ${pn.cohort}: ${pn.started.toLocaleString()} with a first line — ${first}\n` + + ` of all paths, ${stopped.toLocaleString()} were WATCHED and began no further line; ` + + `${cens.toLocaleString()} (${pn.started ? Math.round(100 * cens / pn.started) : 0}%) end because the RECORD stops — nothing can be concluded about those`; + }).join("\n") + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Shows the order in which patients moved from one treatment to the next, drawn as flows between them. It answers a simple but useful question: after this treatment, what did doctors try next, and how often?" + }, + isNew: true, +}; diff --git a/lib/views/survival/figure.ts b/lib/views/survival/figure.ts index eece777..c7c1e88 100644 --- a/lib/views/survival/figure.ts +++ b/lib/views/survival/figure.ts @@ -6,6 +6,7 @@ import { survivalAxis } from "../../stats.ts"; import { stepPath } from "../../chart/marks.ts"; import { plotHeight } from "../../figures/common.ts"; import { + palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, legend, seriesStyle, fmtInt, fmtMonths, fmtP, fmtNum, label, textWidth, esc, FS, SW, type SvgOpts } from "../../svg-kit.ts"; diff --git a/lib/views/survival/kind.ts b/lib/views/survival/kind.ts new file mode 100644 index 0000000..8b2eb1a --- /dev/null +++ b/lib/views/survival/kind.ts @@ -0,0 +1,20 @@ +// The survival view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { survivalSvg } from "./figure.ts"; + +export const survivalKind: ViewKindDef<"survival"> = { + kind: "survival", + figure: (v, o) => v.curves && v.curves.length ? survivalSvg(v, o) : null, + title: (v) => (v.endpoint || "OS") + " survival" + (v.anchoredTo ? " from " + v.anchoredTo : ""), + text: (v, { out, pFmt }) => { + out.push(`Survival (KM, ${v.endpoint || "OS"}${v.anchoredTo ? " from " + v.anchoredTo : ""}):\n` + v.curves.map((c) => + ` ${c.cohort}: median ${c.median ? c.median.toFixed(1) + "mo" : "not reached"} (n=${c.n}, ${c.events} events${c.missing ? ", " + c.missing + " missing OS" : ""}${c.unobserved ? ", " + c.unobserved + " with no follow-up after entry left out" : ""})`).join("\n") + + (v.logrank ? `\n log-rank p=${pFmt(v.logrank.p)}${v.logrank.strata > 1 ? " (stratified by study)" : ""}` + : v.logrankK ? `\n k-group log-rank p=${pFmt(v.logrankK.p)}` : "") + + "\nCAVEAT (tell the user): " + v.caveat); + }, + help: { + title: "What is this?", + text: "Shows what share of patients in each group were still alive (or still free of disease progression) at each point in time, drawn as a curve that steps downward every time a patient's outcome is recorded. Comparing the curves for two or more groups answers: did one group tend to do better than the other, and if so, is the difference too large to be chance?" + }, +}; diff --git a/lib/views/table/kind.ts b/lib/views/table/kind.ts new file mode 100644 index 0000000..07360fd --- /dev/null +++ b/lib/views/table/kind.ts @@ -0,0 +1,18 @@ +// The table view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { tableText } from "../text.ts"; + +export const tableKind: ViewKindDef<"table"> = { + kind: "table", + text: (v, { out, caveatOf, noteOf }) => { + const t = v.table; + const title = `Clinical data table (one row per ${v.table.unit})`; + out.push(`${title}: ${t.total < t.all ? `${t.total.toLocaleString()} of ${t.all.toLocaleString()} rows kept by the query` : `${t.total.toLocaleString()} rows`}; ` + + tableText(t) + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Lists every sample (or every patient) in your groups with everything the study recorded about them — cancer type, age, stage, survival and the rest — one row each. Click a column's name to sort by it; the download button saves every row as a file a spreadsheet can open. Studies that only share counts, never individual patients, are named rather than listed." + }, + need: "patients", +}; diff --git a/lib/views/text.ts b/lib/views/text.ts new file mode 100644 index 0000000..fb8e24d --- /dev/null +++ b/lib/views/text.ts @@ -0,0 +1,14 @@ +// What several kinds' results text shares (lib/views//kind.ts): how a table view lists its rows. + +/** a timeline's events listed in text (MCP); the figure draws every one */ +export const TIMELINE_TEXT_EVENTS = 400; + +/** A table view's first rows, shown columns only, tab-separated — the web app's view pages and downloads every row. */ +export function tableText(t: { columns: { id: string }[]; shown: string[]; rows: { cohort: string; values: unknown[] }[]; total: number }): string { + const idx = t.shown.map((id) => t.columns.findIndex((c) => c.id === id)).filter((i) => i >= 0); + const shown = t.rows.slice(0, 50); + return `the first ${shown.length}, tab-separated (the web app's view pages and downloads every row, with every column):\n` + + [["cohort", ...idx.map((i) => t.columns[i].id)], ...shown.map((r) => [r.cohort, ...idx.map((i) => r.values[i])])] + .map((xs) => " " + xs.map((x) => String(x ?? "").replace(/[\t\n]+/g, " ")).join("\t")).join("\n") + + (t.total > shown.length ? `\n … ${(t.total - shown.length).toLocaleString()} more rows` : ""); +} diff --git a/lib/views/timeline/kind.ts b/lib/views/timeline/kind.ts new file mode 100644 index 0000000..b9b0c07 --- /dev/null +++ b/lib/views/timeline/kind.ts @@ -0,0 +1,28 @@ +// The timeline view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { TIMELINE_TEXT_EVENTS, tableText } from "../text.ts"; + +export const timelineKind: ViewKindDef<"timeline"> = { + kind: "timeline", + title: (v) => "Patient timeline" + (v.timeline?.patient ? " — " + v.timeline.patient.patient : ""), + text: (v, { out, caveatOf, noteOf }) => { + const t = v.timeline, pt = t.patient; + const head = `Patient timeline — patients to choose from: ${t.total < t.all ? `${t.total.toLocaleString()} of ${t.all.toLocaleString()} kept by the query` : `${t.total.toLocaleString()}`}; ` + tableText(t); + if (!pt) out.push(head + "\n no patient drawn: the list is empty" + caveatOf(v) + noteOf(v)); + else { + const shown = pt.events.slice(0, TIMELINE_TEXT_EVENTS); + const line = (e: (typeof pt.events)[number]) => ` day ${e.start}${e.end != null && e.end > e.start ? `–${e.end}` : ""}: ${e.type}` + + (e.attrs.length ? " — " + e.attrs.filter(([k]) => !/^STYLE_/i.test(k)).map(([k, x]) => `${k}=${x}`).join(", ") : ""); + out.push(head + `\n${pt.patient} (${pt.study}${pt.source.remote ? ", from " + pt.source.label + " over MCP" : ""}): ${pt.events.length} events, days from diagnosis; ` + + `samples: ${pt.samples.map((x) => x.id + (x.type ? " (" + x.type + ")" : "")).join(", ") || "none"}\n` + + shown.map(line).join("\n") + + (pt.events.length > shown.length ? `\n … ${pt.events.length - shown.length} later events not listed here (the figure draws all of them)` : "") + + caveatOf(v) + noteOf(v)); + } + }, + help: { + title: "What is this?", + text: "Shows one patient's medical history on a single line of time, as cBioPortal's patient page does: when samples were taken, operations, each treatment and how long it lasted, diagnoses, and lab results drawn as small charts. Time is counted from the day of diagnosis, and long stretches with nothing recorded are cut short (the dashed line). Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one. Studies that only share counts, never individual patients, are named rather than listed." + }, + need: "timeline", +}; diff --git a/lib/views/txendpoints/kind.ts b/lib/views/txendpoints/kind.ts new file mode 100644 index 0000000..05a8f5e --- /dev/null +++ b/lib/views/txendpoints/kind.ts @@ -0,0 +1,25 @@ +// The txendpoints view as every page and agent sees it: its figure, title, results text, help and marks. +import type { ViewKindDef } from "../kind-def.ts"; +import { txMedianText, txendpointsSvg } from "./figure.ts"; + +export const txendpointsKind: ViewKindDef<"txendpoints"> = { + kind: "txendpoints", + figure: (v, o) => v.txendpoints.curves.some((c) => c.n) ? txendpointsSvg(v, o) : null, + title: (v) => "Treatment endpoints" + (v.txendpoints ? " — " + v.txendpoints.endpointLabel + " from " + v.txendpoints.anchoredTo : ""), + text: (v, { out, pFmt, caveatOf, noteOf }) => { + const t = v.txendpoints; + out.push(`Treatment endpoint: ${t.endpointLabel} from ${t.anchoredTo} (Kaplan-Meier):\n` + + t.curves.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()}, ${c.events} events, ${txMedianText(c)}; ` + + `${c.anchored} of ${c.cohortPatients} anchored; left out ${c.excluded.noAnchor} without the anchor, ${c.excluded.noFollowup} without follow-up` + + (t.endpoint === "tot" ? `, ${c.excluded.noStop} without a stop date` : "")).join("\n") + + (t.logrank ? `\n log-rank chi2=${t.logrank.chi2.toFixed(2)} (${t.logrank.df} df), p=${pFmt(t.logrank.p)}` : t.noTest ? `\n ${t.noTest}` : "") + + caveatOf(v) + noteOf(v)); + }, + help: { + title: "What is this?", + text: "Starts each patient's clock on the day a treatment began — for example their first immunotherapy — and follows them to an outcome that real-world studies use in place of a trial's: when they moved on to their next treatment (time to next treatment), when they stopped this one (time on treatment), or when their cancer was recorded as growing (real-world progression-free survival). Death counts as the outcome too, and patients still followed without it are counted up to their last visit. Each group's curve shows the share not yet there at each month, with the number still followed below. The groups were not randomised to the treatment, so a gap between curves is a clue, not proof that a gene or a group changes how well the treatment works." + }, + need: "local", + preview: "no method record or golden test of the endpoint derivation (lines, stops, progression) or the median intervals yet", + isNew: true, +}; diff --git a/test/mcp-views.test.ts b/test/mcp-views.test.ts index 9d5307d..b940e4b 100644 --- a/test/mcp-views.test.ts +++ b/test/mcp-views.test.ts @@ -1,8 +1,7 @@ -// Source tripwire: every view kind the engine can produce must be described by -// the MCP run_analysis tool. +// Every view kind the engine can produce must be described by the MCP run_analysis tool. // -// The failure this exists to prevent is silent. run_analysis formats results in -// an if/else chain over `v.kind`; a kind with no branch used to append NOTHING, +// The failure this exists to prevent is silent. run_analysis formats results with each kind's +// descriptor text (lib/views//kind.ts); a kind without one used to append NOTHING, // so the view ran, the numbers existed, and the model was simply never told. // That is worse than an error — a model writes about figures it cannot see, and // nothing in the response signals the omission. Six of nine kinds were in that @@ -12,6 +11,8 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { viewKinds } from "./support/view-kinds.ts"; +import { KINDS } from "../lib/views/kinds.ts"; +import { analysisText } from "../lib/result-text.ts"; const tools = readFileSync(new URL("../mcp/tools.ts", import.meta.url), "utf8"); // the per-view text lives in lib/result-text.ts, shared by run_analysis and the WebMCP tools @@ -22,14 +23,9 @@ const reference = readFileSync(new URL("../lib/dsl-reference.ts", import.meta.ur const engineViewKinds = viewKinds; /** the kinds run_analysis has a formatter branch for */ -function viewLoop(): string { - const start = renderer.indexOf("for (const v of r.views)"); - assert.ok(start > 0, "could not find the view loop in lib/result-text.ts"); - return renderer.slice(start, renderer.indexOf("return out;", start)); -} +/** the kinds whose descriptor (lib/views//kind.ts) gives the results text a paragraph */ function describedKinds(): string[] { - const body = viewLoop(); - return [...new Set([...body.matchAll(/v\.kind === "(\w+)"/g)].map((x) => x[1]))].sort(); + return Object.values(KINDS).filter((k) => typeof k.text === "function").map((k) => k.kind).sort(); } describe("MCP run_analysis describes every view the engine can produce", () => { @@ -42,8 +38,8 @@ describe("MCP run_analysis describes every view the engine can produce", () => { assert.ok(engine.length >= 9, "expected the full View union; found " + engine.join(", ")); const missing = engine.filter((k) => !described.includes(k)); assert.deepEqual(missing, [], - "these view kinds run but say nothing back to the model — add a branch in " + - "lib/result-text.ts: " + missing.join(", ")); + "these view kinds run but say nothing back to the model — give their descriptor a text " + + "(lib/views//kind.ts): " + missing.join(", ")); }); test("the tool description and describe_dsl's views table name every kind", () => { @@ -60,13 +56,12 @@ describe("MCP run_analysis describes every view the engine can produce", () => { } }); - test("and an unknown kind still says so rather than vanishing", () => { - // Belt and braces: even if someone adds a view and forgets the branch, the - // response must admit it rather than quietly drop the figure. - const body = viewLoop(); - assert.match(body, /\}\s*else\s*\{/, - "the if/else chain needs a final else, or an unhandled kind appends nothing"); - assert.match(body, /has no text rendering for it yet/); + test("and a kind with no text still says so rather than vanishing", () => { + // Belt and braces: even if a view has no text for its result, the response must admit it + // rather than quietly drop the figure. + const r = { involvedStudies: ["s"], oncokbPin: null, stats: { ms: 1 }, cohorts: {}, views: [{ kind: "not-a-kind", cohorts: [] }] }; + const out = analysisText({ study: "s", cohorts: {}, views: [] } as never, r as never); + assert.match(out.join("\n"), /View "not-a-kind" ran but this tool has no text rendering for it yet/); }); test("the author's note is read from annotationNote, not the engine's note", () => { @@ -81,11 +76,10 @@ describe("MCP run_analysis describes every view the engine can produce", () => { // Methodology the engine attaches (left truncation, panel-aware // denominators, non-harmonised vocabularies) is what stops a model // overstating a result, so it must not be dropped in formatting. - const body = viewLoop(); - const branches = (body.match(/v\.kind === "\w+"/g) || []).length; - const caveats = (body.match(/caveatOf\(v\)|v\.caveat/g) || []).length; - assert.ok(caveats >= branches - 2, - `${branches} view branches but only ${caveats} surface a caveat`); + const texts = Object.values(KINDS).flatMap((k) => k.text ? [String(k.text)] : []); + const caveats = texts.filter((t) => /caveatOf\(v\)|v\.caveat/.test(t)).length; + assert.ok(caveats >= texts.length - 2, + `${texts.length} view kinds describe themselves but only ${caveats} surface a caveat`); }); }); From cc62c12b5b608c8081aa97b4dbd3824b6957a22b Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Fri, 23 Oct 2026 22:06:12 +0000 Subject: [PATCH 4/7] kinds.json also pins the add menu's and the card registry's order; e2e comments name the moved files Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CuBjiBEjYispDSEYDZDWM1 --- e2e.cjs | 4 +- test/snapshots/kinds.json | 6468 +++++++++++++++++++------------------ test/view-kinds.test.ts | 2 +- 3 files changed, 3282 insertions(+), 3192 deletions(-) diff --git a/e2e.cjs b/e2e.cjs index 28ed6f4..ccb0be8 100644 --- a/e2e.cjs +++ b/e2e.cjs @@ -1206,7 +1206,7 @@ async function main() { await ctx.close(); } - // The risk model (lib/views/riskmodel.ts): the volcano's label threshold is applied in the browser + // The risk model (lib/views/riskmodel/run.ts): the volcano's label threshold is applied in the browser // (no request), and a cut is one request answered from the cached fit (lib/next-cache.ts // cachedRiskFit), not a refit; the caveat then says the cuts were chosen. { @@ -1281,7 +1281,7 @@ async function main() { await ctx.close(); } - // The risk model's Table 1 (app/lab/risk-table1-dialog.tsx on lib/views/riskmodel-table1.ts): the toolbar's + // The risk model's Table 1 (app/lab/risk-table1-dialog.tsx on lib/views/riskmodel/table1.ts): the toolbar's // button opens every group side by side, a header click marks a group's column; the model's own genes are marked and get // no q; a gene added in the dialog re-runs only the dialog's request, which covers the table while its // controls stay usable, and is counted over the patients whose panels cover it; "Add to document" writes diff --git a/test/snapshots/kinds.json b/test/snapshots/kinds.json index 3174325..5481cf5 100644 --- a/test/snapshots/kinds.json +++ b/test/snapshots/kinds.json @@ -1,1139 +1,1167 @@ { - "compare": { - "menu": { - "label": "Compare genes", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "compare", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF", - "ERBB2" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "compare", - "genes": [], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "compare", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF", - "ERBB2" - ], - "cohorts": [] - }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Compare genes", - "headTitle": "Compare", - "drawsOwnHead": { - "error": false, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "Compares how often specific genes are altered — mutated, gained, or lost — between two or more groups of patients. If a gene is altered much more often in one group than another, that's a clue it might be connected to whatever makes the groups different, like a cancer subtype or how patients respond to treatment." - }, - "need": null, - "preview": null, - "isNew": false, - "figureTitle": "Alteration frequencies — A vs B" - }, - "survival": { - "menu": { - "label": "Survival", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "survival", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "survival", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "survival", - "cohorts": [] - }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Survival", - "headTitle": "Survival", - "drawsOwnHead": { - "error": false, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "Shows what share of patients in each group were still alive (or still free of disease progression) at each point in time, drawn as a curve that steps downward every time a patient's outcome is recorded. Comparing the curves for two or more groups answers: did one group tend to do better than the other, and if so, is the difference too large to be chance?" - }, - "need": null, - "preview": null, - "isNew": false, - "figureTitle": "OS survival — A vs B" - }, - "cox": { - "menu": { - "label": "Cox regression (forest plot)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "cox", - "covariates": [ - { + "menuOrder": [ + "compare", + "survival", + "cox", + "logistic", + "oncoprint", + "hotspots", + "plot", + "cooccurrence", + "clinical", + "coexpression", + "course", + "sequence", + "attrition", + "genome", + "pathways", + "enrichment", + "assays", + "composition", + "table", + "mutations", + "timeline", + "alterations", + "correlates", + "overlap", + "cancertypes", + "labs", + "incidence", + "hla", + "cutpoint", + "predictive", + "rmst", + "multistate", + "txendpoints", + "clonality", + "paired", + "acquired", + "instability", + "exclusivity", + "ctdna", + "riskmodel", + "drivernegative", + "organotropism", + "germline", + "molsubtype", + "actionability", + "adjusted", + "forest", + "diffexpr" + ], + "cardOrder": [ + "attrition", + "genome", + "pathways", + "enrichment", + "assays", + "composition", + "table", + "mutations", + "timeline", + "alterations", + "correlates", + "overlap", + "cancertypes", + "labs", + "incidence", + "hla", + "cutpoint", + "predictive", + "rmst", + "multistate", + "txendpoints", + "clonality", + "paired", + "acquired", + "instability", + "exclusivity", + "ctdna", + "riskmodel", + "drivernegative", + "organotropism", + "germline", + "molsubtype", + "actionability", + "adjusted", + "forest", + "diffexpr" + ], + "kinds": { + "compare": { + "menu": { + "label": "Compare genes", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "compare", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "compare", + "genes": [], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "compare", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ], + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Compare genes", + "headTitle": "Compare", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Compares how often specific genes are altered — mutated, gained, or lost — between two or more groups of patients. If a gene is altered much more often in one group than another, that's a clue it might be connected to whatever makes the groups different, like a cancer subtype or how patients respond to treatment." + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "Alteration frequencies — A vs B" + }, + "survival": { + "menu": { + "label": "Survival", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "survival", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "survival", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "survival", + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Survival", + "headTitle": "Survival", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Shows what share of patients in each group were still alive (or still free of disease progression) at each point in time, drawn as a curve that steps downward every time a patient's outcome is recorded. Comparing the curves for two or more groups answers: did one group tend to do better than the other, and if so, is the difference too large to be chance?" + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "OS survival — A vs B" + }, + "cox": { + "menu": { + "label": "Cox regression (forest plot)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "cox", + "covariates": [ + { + "source": "alteration", + "gene": "TP53", + "by": "mutated" + } + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "cox", + "covariates": [ + { + "source": "alteration", + "gene": "TP53", + "by": "mutated" + } + ], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "cox", + "covariates": [ + { + "source": "alteration", + "gene": "TP53", + "by": "mutated" + } + ], + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Cox regression (forest plot)", + "headTitle": "Cox regression", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "A more advanced version of the survival comparison above, which can weigh several factors at once — for example, a patient's age, a specific gene, and how far the disease had spread — to estimate how much each one changes risk on its own, holding the others equal. Each result is a \"hazard ratio\": above 1 means that factor is linked to worse outcomes, below 1 means it's linked to better ones." + }, + "need": "patients", + "preview": null, + "isNew": true, + "figureTitle": "Cox regression — OS — A vs B" + }, + "logistic": { + "menu": { + "label": "Logistic regression (odds ratios)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "logistic", + "outcome": { "source": "alteration", "gene": "TP53", "by": "mutated" - } - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "cox", - "covariates": [ - { + }, + "outcomeEvent": "Mutated", + "covariates": [ + { + "source": "clinical", + "attribute": "TMB_NONSYNONYMOUS" + } + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "logistic", + "outcome": { "source": "alteration", "gene": "TP53", "by": "mutated" - } - ], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "cox", - "covariates": [ - { + }, + "outcomeEvent": "Mutated", + "covariates": [ + { + "source": "clinical", + "attribute": "SEX" + } + ], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "logistic", + "outcome": { "source": "alteration", "gene": "TP53", "by": "mutated" - } - ], - "cohorts": [] - }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Cox regression (forest plot)", - "headTitle": "Cox regression", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "A more advanced version of the survival comparison above, which can weigh several factors at once — for example, a patient's age, a specific gene, and how far the disease had spread — to estimate how much each one changes risk on its own, holding the others equal. Each result is a \"hazard ratio\": above 1 means that factor is linked to worse outcomes, below 1 means it's linked to better ones." - }, - "need": "patients", - "preview": null, - "isNew": true, - "figureTitle": "Cox regression — OS — A vs B" - }, - "logistic": { - "menu": { - "label": "Logistic regression (odds ratios)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "logistic", - "outcome": { - "source": "alteration", - "gene": "TP53", - "by": "mutated" + }, + "outcomeEvent": "Mutated", + "covariates": [ + { + "source": "clinical", + "attribute": "TMB_NONSYNONYMOUS" + } + ], + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Logistic regression (odds ratios)", + "headTitle": "Logistic regression", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Estimates how strongly different factors are linked to a yes-or-no outcome — for example, whether a patient responded to a treatment — while accounting for several factors together. Each result is an \"odds ratio\": above 1 means that factor makes the outcome more likely, below 1 means less likely." + }, + "need": "patients", + "preview": null, + "isNew": true, + "figureTitle": "Logistic regression — A vs B" + }, + "oncoprint": { + "menu": { + "label": "Oncoprint", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "oncoprint", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "oncoprint", + "genes": [], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "oncoprint", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "cohorts": [] }, - "outcomeEvent": "Mutated", - "covariates": [ - { + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Oncoprint", + "headTitle": "Oncoprint", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "A grid with one column per patient and one row per gene, color-coded to show what kind of genetic change (if any) each patient has in each gene. It's a fast way to see patterns across many patients and genes at once — for example, whether certain alterations tend to show up in the same patients, or never together." + }, + "need": "patients", + "preview": null, + "isNew": false, + "figureTitle": "Oncoprint — A vs B" + }, + "hotspots": { + "menu": { + "label": "Lollipop (hotspots)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "hotspots", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11" + ], + "minCount": 1, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "hotspots", + "genes": [], + "minCount": 1, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "hotspots", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11" + ], + "minCount": 1, + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Lollipop (hotspots)", + "headTitle": "Mutations", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Shows exactly where along a gene's protein its mutations land, drawn as a lollipop chart — a taller stem means more patients had a mutation at that exact spot. Mutations clustering tightly at one or two spots, rather than scattering evenly, usually points to a location that matters for how the protein works." + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "Recurrent protein changes — A vs B" + }, + "plot": { + "menu": { + "label": "Plot", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study declares no clinical attributes to plot", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "plot", + "x": { "source": "clinical", "attribute": "TMB_NONSYNONYMOUS" - } - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "logistic", - "outcome": { - "source": "alteration", - "gene": "TP53", - "by": "mutated" - }, - "outcomeEvent": "Mutated", - "covariates": [ - { + }, + "y": { "source": "clinical", - "attribute": "SEX" - } - ], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "logistic", - "outcome": { - "source": "alteration", - "gene": "TP53", - "by": "mutated" + "attribute": "AGE" + }, + "unit": "sample", + "cohorts": [ + "A" + ] }, - "outcomeEvent": "Mutated", - "covariates": [ - { + "addedBareStudy": { + "kind": "plot", + "unit": "sample", + "x": { + "source": "alteration", + "gene": "TP53", + "by": "mutationType" + }, + "y": { + "source": "alteration", + "gene": "KRAS", + "by": "mutated" + }, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "plot", + "x": { "source": "clinical", "attribute": "TMB_NONSYNONYMOUS" - } - ], - "cohorts": [] - }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Logistic regression (odds ratios)", - "headTitle": "Logistic regression", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "Estimates how strongly different factors are linked to a yes-or-no outcome — for example, whether a patient responded to a treatment — while accounting for several factors together. Each result is an \"odds ratio\": above 1 means that factor makes the outcome more likely, below 1 means less likely." - }, - "need": "patients", - "preview": null, - "isNew": true, - "figureTitle": "Logistic regression — A vs B" - }, - "oncoprint": { - "menu": { - "label": "Oncoprint", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "oncoprint", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "oncoprint", - "genes": [], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "oncoprint", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1" - ], - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Oncoprint", - "headTitle": "Oncoprint", - "drawsOwnHead": { - "error": false, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "A grid with one column per patient and one row per gene, color-coded to show what kind of genetic change (if any) each patient has in each gene. It's a fast way to see patterns across many patients and genes at once — for example, whether certain alterations tend to show up in the same patients, or never together." - }, - "need": "patients", - "preview": null, - "isNew": false, - "figureTitle": "Oncoprint — A vs B" - }, - "hotspots": { - "menu": { - "label": "Lollipop (hotspots)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "hotspots", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11" - ], - "minCount": 1, - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "hotspots", - "genes": [], - "minCount": 1, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "hotspots", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11" - ], - "minCount": 1, - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Lollipop (hotspots)", - "headTitle": "Mutations", - "drawsOwnHead": { - "error": false, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "Shows exactly where along a gene's protein its mutations land, drawn as a lollipop chart — a taller stem means more patients had a mutation at that exact spot. Mutations clustering tightly at one or two spots, rather than scattering evenly, usually points to a location that matters for how the protein works." - }, - "need": null, - "preview": null, - "isNew": false, - "figureTitle": "Recurrent protein changes — A vs B" - }, - "plot": { - "menu": { - "label": "Plot", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study declares no clinical attributes to plot", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "plot", - "x": { - "source": "clinical", - "attribute": "TMB_NONSYNONYMOUS" - }, - "y": { - "source": "clinical", - "attribute": "AGE" - }, - "unit": "sample", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "plot", - "unit": "sample", - "x": { - "source": "alteration", - "gene": "TP53", - "by": "mutationType" + }, + "y": { + "source": "clinical", + "attribute": "AGE" + }, + "unit": "sample", + "cohorts": [] }, - "y": { - "source": "alteration", - "gene": "KRAS", - "by": "mutated" + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Plot", + "headTitle": "Plot", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Plots two measurements against each other for the same patients or samples — for example, a gene's activity level against a patient's age — so you can see whether they tend to rise and fall together, split into distinct clusters, or show no relationship at all." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "plot — A vs B" + }, + "cooccurrence": { + "menu": { + "label": "Co-occurrence", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "cooccurrence", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "cooccurrence", + "genes": [], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "cooccurrence", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA" + ], + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Co-occurrence", + "headTitle": "Co-occurrence", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Checks whether alterations in two genes tend to show up in the same patients more often than you'd expect by chance (\"co-occurring\"), or rarely appear together (\"mutually exclusive\"). Genes that are mutually exclusive often play overlapping roles — if one is already altered, changing the other adds little, so cells rarely need both." + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "Co-occurrence and mutual exclusivity — A vs B" + }, + "clinical": { + "menu": { + "label": "Clinical comparison", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study declares no clinical attributes to compare", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "clinical", + "attributes": [ + "TMB_NONSYNONYMOUS", + "AGE", + "OS_MONTHS" + ], + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "clinical", + "attributes": [], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "clinical", + "attributes": [ + "TMB_NONSYNONYMOUS", + "AGE", + "OS_MONTHS" + ], + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Clinical comparison", + "headTitle": "Clinical comparison", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Compares things recorded about the patients themselves — age, sex, a lab result, and similar details — rather than their genetics, between two or more groups. It answers whether the groups differ in ways beyond whatever genetic difference defined them, which matters for judging whether a genetic finding is really the explanation." + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "Clinical comparison — A vs B" + }, + "coexpression": { + "menu": { + "label": "Co-expression", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no continuous molecular profile (mRNA z-scores) to correlate", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "coexpression", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "profileType": "rna_seq_v2_mrna", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "coexpression", + "genes": [], + "profileType": null, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "coexpression", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "profileType": "rna_seq_v2_mrna", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Co-expression", + "headTitle": "Co-expression", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Checks whether two genes tend to be more or less active together across patients — measured by how much each gene is \"read out\" into the working molecules a cell actually uses. Genes that rise and fall together are often working in the same biological pathway, or controlled by the same switch." + }, + "need": null, + "preview": null, + "isNew": false, + "figureTitle": "Co-expression — A vs B" + }, + "course": { + "menu": { + "label": "Clinical course", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study records no timeline events (only 131 of 545 studies have any)", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "course", + "eventTypes": [ + "treatment", + "diagnosis" + ], + "anchor": { + "eventType": "Treatment", + "occurrence": "FIRST" + }, + "binMonths": 3, + "fromMonths": -12, + "toMonths": 60, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "course", + "eventTypes": [], + "binMonths": 3, + "fromMonths": -12, + "toMonths": 60, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "course", + "eventTypes": [ + "treatment", + "diagnosis" + ], + "anchor": { + "eventType": "Treatment", + "occurrence": "FIRST" + }, + "binMonths": 3, + "fromMonths": -12, + "toMonths": 60, + "cohorts": [] + }, + "maxCohorts": null, + "emptyHint": "Add one cohort, or several to compare them.", + "title": "Clinical course", + "headTitle": "Clinical course", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Shows what share of patients were in each stage of their care — on treatment, in follow-up, and so on — at each point in time after diagnosis or another starting event. It's a picture of the typical patient's journey through care, not any one individual's." + }, + "need": null, + "preview": null, + "isNew": true, + "figureTitle": "Clinical course — A vs B" + }, + "sequence": { + "menu": { + "label": "Treatment sequence", + "why": "this study records no treatment events, so there are no lines of therapy to sequence", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no treatment events, so there are no lines of therapy to sequence", + "loading": false + }, + "menuAggregateOnly": { + "why": "this study records no treatment events, so there are no lines of therapy to sequence" + }, + "added": { + "kind": "sequence", + "eventType": "treatment", + "labelKey": "SUBTYPE", + "lineGap": 90, + "maxSteps": 3, + "topN": 8, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "sequence", + "labelKey": "SUBTYPE", + "lineGap": 90, + "maxSteps": 3, + "topN": 8, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "sequence", + "eventType": "treatment", + "labelKey": "SUBTYPE", + "lineGap": 90, + "maxSteps": 3, + "topN": 8, + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Treatment sequence", + "headTitle": "Treatment sequence", + "drawsOwnHead": { + "error": false, + "withData": true + }, + "card": null, + "drawsOwnWaiting": false, + "sub": null, + "help": { + "title": "What is this?", + "text": "Shows the order in which patients moved from one treatment to the next, drawn as flows between them. It answers a simple but useful question: after this treatment, what did doctors try next, and how often?" + }, + "need": null, + "preview": null, + "isNew": true, + "figureTitle": "Treatment sequence — A vs B" + }, + "attrition": { + "menu": { + "label": "Cohort attrition (CONSORT)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "attrition", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "attrition", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "attrition", + "cohorts": [] }, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "plot", - "x": { - "source": "clinical", - "attribute": "TMB_NONSYNONYMOUS" - }, - "y": { - "source": "clinical", - "attribute": "AGE" - }, - "unit": "sample", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Plot", - "headTitle": "Plot", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "Plots two measurements against each other for the same patients or samples — for example, a gene's activity level against a patient's age — so you can see whether they tend to rise and fall together, split into distinct clusters, or show no relationship at all." - }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "plot — A vs B" - }, - "cooccurrence": { - "menu": { - "label": "Co-occurrence", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "cooccurrence", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "cooccurrence", - "genes": [], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "cooccurrence", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA" - ], - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Co-occurrence", - "headTitle": "Co-occurrence", - "drawsOwnHead": { - "error": false, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "Checks whether alterations in two genes tend to show up in the same patients more often than you'd expect by chance (\"co-occurring\"), or rarely appear together (\"mutually exclusive\"). Genes that are mutually exclusive often play overlapping roles — if one is already altered, changing the other adds little, so cells rarely need both." - }, - "need": null, - "preview": null, - "isNew": false, - "figureTitle": "Co-occurrence and mutual exclusivity — A vs B" - }, - "clinical": { - "menu": { - "label": "Clinical comparison", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study declares no clinical attributes to compare", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "clinical", - "attributes": [ - "TMB_NONSYNONYMOUS", - "AGE", - "OS_MONTHS" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "clinical", - "attributes": [], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "clinical", - "attributes": [ - "TMB_NONSYNONYMOUS", - "AGE", - "OS_MONTHS" - ], - "cohorts": [] - }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Clinical comparison", - "headTitle": "Clinical comparison", - "drawsOwnHead": { - "error": false, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "Compares things recorded about the patients themselves — age, sex, a lab result, and similar details — rather than their genetics, between two or more groups. It answers whether the groups differ in ways beyond whatever genetic difference defined them, which matters for judging whether a genetic finding is really the explanation." - }, - "need": null, - "preview": null, - "isNew": false, - "figureTitle": "Clinical comparison — A vs B" - }, - "coexpression": { - "menu": { - "label": "Co-expression", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no continuous molecular profile (mRNA z-scores) to correlate", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "coexpression", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1" - ], - "profileType": "rna_seq_v2_mrna", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "coexpression", - "genes": [], - "profileType": null, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "coexpression", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1" - ], - "profileType": "rna_seq_v2_mrna", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Co-expression", - "headTitle": "Co-expression", - "drawsOwnHead": { - "error": false, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "Checks whether two genes tend to be more or less active together across patients — measured by how much each gene is \"read out\" into the working molecules a cell actually uses. Genes that rise and fall together are often working in the same biological pathway, or controlled by the same switch." - }, - "need": null, - "preview": null, - "isNew": false, - "figureTitle": "Co-expression — A vs B" - }, - "course": { - "menu": { - "label": "Clinical course", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study records no timeline events (only 131 of 545 studies have any)", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "course", - "eventTypes": [ - "treatment", - "diagnosis" - ], - "anchor": { - "eventType": "Treatment", - "occurrence": "FIRST" - }, - "binMonths": 3, - "fromMonths": -12, - "toMonths": 60, - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "course", - "eventTypes": [], - "binMonths": 3, - "fromMonths": -12, - "toMonths": 60, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "course", - "eventTypes": [ - "treatment", - "diagnosis" - ], - "anchor": { - "eventType": "Treatment", - "occurrence": "FIRST" - }, - "binMonths": 3, - "fromMonths": -12, - "toMonths": 60, - "cohorts": [] - }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Clinical course", - "headTitle": "Clinical course", - "drawsOwnHead": { - "error": false, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "Shows what share of patients were in each stage of their care — on treatment, in follow-up, and so on — at each point in time after diagnosis or another starting event. It's a picture of the typical patient's journey through care, not any one individual's." - }, - "need": null, - "preview": null, - "isNew": true, - "figureTitle": "Clinical course — A vs B" - }, - "sequence": { - "menu": { - "label": "Treatment sequence", - "why": "this study records no treatment events, so there are no lines of therapy to sequence", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no treatment events, so there are no lines of therapy to sequence", - "loading": false - }, - "menuAggregateOnly": { - "why": "this study records no treatment events, so there are no lines of therapy to sequence" - }, - "added": { - "kind": "sequence", - "eventType": "treatment", - "labelKey": "SUBTYPE", - "lineGap": 90, - "maxSteps": 3, - "topN": 8, - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "sequence", - "labelKey": "SUBTYPE", - "lineGap": 90, - "maxSteps": 3, - "topN": 8, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "sequence", - "eventType": "treatment", - "labelKey": "SUBTYPE", - "lineGap": 90, - "maxSteps": 3, - "topN": 8, - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Treatment sequence", - "headTitle": "Treatment sequence", - "drawsOwnHead": { - "error": false, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "Shows the order in which patients moved from one treatment to the next, drawn as flows between them. It answers a simple but useful question: after this treatment, what did doctors try next, and how often?" - }, - "need": null, - "preview": null, - "isNew": true, - "figureTitle": "Treatment sequence — A vs B" - }, - "attrition": { - "menu": { - "label": "Cohort attrition (CONSORT)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "attrition", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "attrition", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "attrition", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Cohort attrition (CONSORT)", - "headTitle": "Cohort attrition", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Cohort attrition (CONSORT)", - "title": "Cohort attrition", "maxCohorts": 6, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "attrition" - }, - "defaultsBareStudy": { - "kind": "attrition" - } - }, - "drawsOwnWaiting": false, - "sub": "patients left after each clause, in order", - "help": { - "title": "What is this?", - "text": "Shows how each group of patients was narrowed down, one criterion at a time: how many patients the study started with, how many were left after each rule in the group's definition, and how many each rule removed. It lets a reader see who was left out before trusting any result drawn from the patients who remain." - }, - "need": null, - "preview": null, - "isNew": true, - "figureTitle": "Cohort attrition — A vs B" - }, - "genome": { - "menu": { - "label": "Genome-wide copy number", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "genome", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "genome", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "genome", - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Genome-wide copy number", - "headTitle": "Genome-wide copy number", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Genome-wide copy number", - "title": "Genome-wide copy number", + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Cohort attrition (CONSORT)", + "headTitle": "Cohort attrition", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Cohort attrition (CONSORT)", + "title": "Cohort attrition", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "attrition" + }, + "defaultsBareStudy": { + "kind": "attrition" + } + }, + "drawsOwnWaiting": false, + "sub": "patients left after each clause, in order", + "help": { + "title": "What is this?", + "text": "Shows how each group of patients was narrowed down, one criterion at a time: how many patients the study started with, how many were left after each rule in the group's definition, and how many each rule removed. It lets a reader see who was left out before trusting any result drawn from the patients who remain." + }, + "need": null, + "preview": null, + "isNew": true, + "figureTitle": "Cohort attrition — A vs B" + }, + "genome": { + "menu": { + "label": "Genome-wide copy number", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "genome", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "genome", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "genome", + "cohorts": [] + }, "maxCohorts": 4, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "genome" - }, - "defaultsBareStudy": { - "kind": "genome" - } - }, - "drawsOwnWaiting": false, - "sub": "from copy-number segments", - "help": { - "title": "What is this?", - "text": "Walks along every chromosome and shows, at each stretch, what share of the group's tumours have extra copies of that stretch of DNA (drawn upward, in red) or have lost copies of it (drawn downward, in blue). Stretches gained or lost in many tumours often hold genes that help the cancer grow, or genes that would have held it back." - }, - "need": "local", - "preview": null, - "isNew": true, - "figureTitle": "Genome-wide copy number — A vs B" - }, - "pathways": { - "menu": { - "label": "Oncogenic pathways", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "pathways", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "pathways", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "pathways", - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Oncogenic pathways", - "headTitle": "Oncogenic signalling pathways", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Oncogenic pathways", - "title": "Oncogenic signalling pathways", + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Genome-wide copy number", + "headTitle": "Genome-wide copy number", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Genome-wide copy number", + "title": "Genome-wide copy number", + "maxCohorts": 4, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "genome" + }, + "defaultsBareStudy": { + "kind": "genome" + } + }, + "drawsOwnWaiting": false, + "sub": "from copy-number segments", + "help": { + "title": "What is this?", + "text": "Walks along every chromosome and shows, at each stretch, what share of the group's tumours have extra copies of that stretch of DNA (drawn upward, in red) or have lost copies of it (drawn downward, in blue). Stretches gained or lost in many tumours often hold genes that help the cancer grow, or genes that would have held it back." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Genome-wide copy number — A vs B" + }, + "pathways": { + "menu": { + "label": "Oncogenic pathways", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "pathways", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "pathways", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "pathways", + "cohorts": [] + }, "maxCohorts": 4, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "pathways" - }, - "defaultsBareStudy": { - "kind": "pathways" - } - }, - "drawsOwnWaiting": false, - "sub": "OncoKB drivers, Sanchez-Vega et al. 2018", - "help": { - "title": "What is this?", - "text": "Groups genes into ten well-known signalling pathways — chains of genes that together tell a cell when to grow, divide or die — and shows what share of each group of patients has a harmful change in at least one gene of each pathway. Two cancers can reach the same broken pathway through different genes, and this view makes that visible." - }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Oncogenic signalling pathways — A vs B" - }, - "diffexpr": { - "menu": { - "label": "Differential expression + GSEA", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no molecular profile to compare expression on", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "diffexpr", - "profileType": "rna_seq_v2_mrna_median_Zscores", - "cohorts": [] - }, - "addedBareStudy": { - "kind": "diffexpr", - "profileType": "", - "cohorts": [] - }, - "addedWithTwoCohorts": { - "kind": "diffexpr", - "profileType": "rna_seq_v2_mrna_median_Zscores", - "cohorts": [] - }, - "maxCohorts": 2, - "emptyHint": "It compares exactly 2 cohorts.", - "title": "Differential expression + GSEA", - "headTitle": "Differential expression", - "drawsOwnHead": { - "error": true, - "withData": true + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Oncogenic pathways", + "headTitle": "Oncogenic signalling pathways", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Oncogenic pathways", + "title": "Oncogenic signalling pathways", + "maxCohorts": 4, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "pathways" + }, + "defaultsBareStudy": { + "kind": "pathways" + } + }, + "drawsOwnWaiting": false, + "sub": "OncoKB drivers, Sanchez-Vega et al. 2018", + "help": { + "title": "What is this?", + "text": "Groups genes into ten well-known signalling pathways — chains of genes that together tell a cell when to grow, divide or die — and shows what share of each group of patients has a harmful change in at least one gene of each pathway. Two cancers can reach the same broken pathway through different genes, and this view makes that visible." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Oncogenic signalling pathways — A vs B" }, - "card": { - "label": "Differential expression + GSEA", - "title": "Differential expression", - "maxCohorts": 2, - "minCohorts": 2, - "why": null, - "whyBareStudy": "this study has no molecular profile to compare expression on", - "defaults": { + "diffexpr": { + "menu": { + "label": "Differential expression + GSEA", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no molecular profile to compare expression on", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "diffexpr", - "profileType": "rna_seq_v2_mrna_median_Zscores" + "profileType": "rna_seq_v2_mrna_median_Zscores", + "cohorts": [] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "diffexpr", - "profileType": "" - } - }, - "drawsOwnWaiting": false, - "sub": "rna_seq_v2_mrna_median_Zscores", - "help": { - "title": "What is this?", - "text": "Compares how active every gene is between two groups of patients. Each dot is a gene: further left or right means a bigger difference in activity between the groups, higher up means the difference is less likely to be chance. The panel beside it asks whether whole families of related genes, such as those that drive cell division, lean towards one group." - }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Differential expression — A vs B" - }, - "paired": { - "menu": { - "label": "Paired samples (primary vs metastasis, before vs after)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "paired", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "paired", - "genes": [ - "TP53", - "KRAS", - "PIK3CA", - "ESR1", - "AR", - "CDKN2A", - "MYC", - "TERT" - ], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "paired", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], - "cohorts": [] - }, - "maxCohorts": 3, - "emptyHint": "It draws up to 3 cohorts, side by side.", - "title": "Paired samples (primary vs metastasis, before vs after)", - "headTitle": "Paired samples", - "drawsOwnHead": { - "error": true, - "withData": true + "profileType": "", + "cohorts": [] + }, + "addedWithTwoCohorts": { + "kind": "diffexpr", + "profileType": "rna_seq_v2_mrna_median_Zscores", + "cohorts": [] + }, + "maxCohorts": 2, + "emptyHint": "It compares exactly 2 cohorts.", + "title": "Differential expression + GSEA", + "headTitle": "Differential expression", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Differential expression + GSEA", + "title": "Differential expression", + "maxCohorts": 2, + "minCohorts": 2, + "why": null, + "whyBareStudy": "this study has no molecular profile to compare expression on", + "defaults": { + "kind": "diffexpr", + "profileType": "rna_seq_v2_mrna_median_Zscores" + }, + "defaultsBareStudy": { + "kind": "diffexpr", + "profileType": "" + } + }, + "drawsOwnWaiting": false, + "sub": "rna_seq_v2_mrna_median_Zscores", + "help": { + "title": "What is this?", + "text": "Compares how active every gene is between two groups of patients. Each dot is a gene: further left or right means a bigger difference in activity between the groups, higher up means the difference is less likely to be chance. The panel beside it asks whether whole families of related genes, such as those that drive cell division, lean towards one group." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Differential expression — A vs B" }, - "card": { - "label": "Paired samples (primary vs metastasis, before vs after)", - "title": "Paired samples", - "genes": true, - "maxCohorts": 3, - "why": null, - "whyBareStudy": null, - "defaults": { + "paired": { + "menu": { + "label": "Paired samples (primary vs metastasis, before vs after)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "paired", "genes": [ "TP53", @@ -1144,9 +1172,12 @@ "PIK3CA", "APC", "BRAF" + ], + "cohorts": [ + "A" ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "paired", "genes": [ "TP53", @@ -1157,93 +1188,92 @@ "CDKN2A", "MYC", "TERT" + ], + "cohorts": [ + "A" ] - } - }, - "drawsOwnWaiting": false, - "sub": "SAMPLE_TYPE: Primary vs Metastasis, drivers", - "help": { - "title": "What is this?", - "text": "For patients who had two kinds of tumour sample tested — usually the original tumour and a later spread of it elsewhere in the body — shows, gene by gene, how often a change was found in both samples, only in the original, or only in the spread. Changes found only in the spread can point to how the cancer adapted, for example to a treatment. The two kinds can also be samples taken before and after a treatment. Without a list of genes, it checks every gene and shows the ones most often found on one side only." - }, - "need": "local", - "preview": null, - "isNew": true, - "figureTitle": "Paired samples — A vs B" - }, - "clonality": { - "menu": { - "label": "Clonality (FACETS)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "clonality", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "clonality", - "genes": [ - "TP53", - "KRAS", - "APC", - "PIK3CA", - "SMAD4", - "CDH1" - ], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "clonality", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], - "cohorts": [] - }, - "maxCohorts": 3, - "emptyHint": "It draws up to 3 cohorts, side by side.", - "title": "Clonality (FACETS)", - "headTitle": "Clonality", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Clonality (FACETS)", - "title": "Clonality", - "genes": true, + }, + "addedWithTwoCohorts": { + "kind": "paired", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [] + }, "maxCohorts": 3, - "why": null, - "whyBareStudy": null, - "defaults": { + "emptyHint": "It draws up to 3 cohorts, side by side.", + "title": "Paired samples (primary vs metastasis, before vs after)", + "headTitle": "Paired samples", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Paired samples (primary vs metastasis, before vs after)", + "title": "Paired samples", + "genes": true, + "maxCohorts": 3, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "paired", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ] + }, + "defaultsBareStudy": { + "kind": "paired", + "genes": [ + "TP53", + "KRAS", + "PIK3CA", + "ESR1", + "AR", + "CDKN2A", + "MYC", + "TERT" + ] + } + }, + "drawsOwnWaiting": false, + "sub": "SAMPLE_TYPE: Primary vs Metastasis, drivers", + "help": { + "title": "What is this?", + "text": "For patients who had two kinds of tumour sample tested — usually the original tumour and a later spread of it elsewhere in the body — shows, gene by gene, how often a change was found in both samples, only in the original, or only in the spread. Changes found only in the spread can point to how the cancer adapted, for example to a treatment. The two kinds can also be samples taken before and after a treatment. Without a list of genes, it checks every gene and shows the ones most often found on one side only." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Paired samples — A vs B" + }, + "clonality": { + "menu": { + "label": "Clonality (FACETS)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "clonality", "genes": [ "TP53", @@ -1254,9 +1284,12 @@ "PIK3CA", "APC", "BRAF" + ], + "cohorts": [ + "A" ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "clonality", "genes": [ "TP53", @@ -1265,301 +1298,308 @@ "PIK3CA", "SMAD4", "CDH1" + ], + "cohorts": [ + "A" ] - } - }, - "drawsOwnWaiting": false, - "sub": "FACETS allele-specific copy number, driver mutations, QC-passing samples", - "help": { - "title": "What is this?", - "text": "A tumour is a population of cells, and a genetic change can be in all of them (present from early on) or only in some (arriving later). For each gene, this shows how often its changes are in all the tumour's cells, and how often the cell has also lost its backup copy of that gene. Beside it: how often tumours have doubled their entire set of chromosomes." - }, - "need": "local", - "preview": null, - "isNew": true, - "figureTitle": "Clonality (FACETS) — A vs B" - }, - "incidence": { - "menu": { - "label": "Cumulative incidence (competing risks)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study records no timeline events to estimate the incidence of", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "incidence", - "event": { - "eventType": "Diagnosis" - }, - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "incidence", - "event": { - "eventType": "" - }, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "incidence", - "event": { - "eventType": "Diagnosis" }, - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Cumulative incidence (competing risks)", - "headTitle": "Cumulative incidence", - "drawsOwnHead": { - "error": true, - "withData": true + "addedWithTwoCohorts": { + "kind": "clonality", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [] + }, + "maxCohorts": 3, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "title": "Clonality (FACETS)", + "headTitle": "Clonality", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Clonality (FACETS)", + "title": "Clonality", + "genes": true, + "maxCohorts": 3, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "clonality", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ] + }, + "defaultsBareStudy": { + "kind": "clonality", + "genes": [ + "TP53", + "KRAS", + "APC", + "PIK3CA", + "SMAD4", + "CDH1" + ] + } + }, + "drawsOwnWaiting": false, + "sub": "FACETS allele-specific copy number, driver mutations, QC-passing samples", + "help": { + "title": "What is this?", + "text": "A tumour is a population of cells, and a genetic change can be in all of them (present from early on) or only in some (arriving later). For each gene, this shows how often its changes are in all the tumour's cells, and how often the cell has also lost its backup copy of that gene. Beside it: how often tumours have doubled their entire set of chromosomes." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Clonality (FACETS) — A vs B" }, - "card": { - "label": "Cumulative incidence (competing risks)", - "title": "Cumulative incidence", - "maxCohorts": 6, - "why": null, - "whyBareStudy": "this study records no timeline events to estimate the incidence of", - "defaults": { + "incidence": { + "menu": { + "label": "Cumulative incidence (competing risks)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study records no timeline events to estimate the incidence of", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "incidence", "event": { "eventType": "Diagnosis" - } + }, + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "incidence", "event": { "eventType": "" + }, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "incidence", + "event": { + "eventType": "Diagnosis" + }, + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Cumulative incidence (competing risks)", + "headTitle": "Cumulative incidence", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Cumulative incidence (competing risks)", + "title": "Cumulative incidence", + "maxCohorts": 6, + "why": null, + "whyBareStudy": "this study records no timeline events to estimate the incidence of", + "defaults": { + "kind": "incidence", + "event": { + "eventType": "Diagnosis" + } + }, + "defaultsBareStudy": { + "kind": "incidence", + "event": { + "eventType": "" + } } - } - }, - "drawsOwnWaiting": false, - "sub": "Diagnosis from day 0, death competing", - "help": { - "title": "What is this?", - "text": "Shows what share of patients have experienced a particular event — such as the cancer spreading to the brain — by each point in time. It counts patients who died before the event as never having it, rather than pretending they might still, which is why its numbers can be noticeably lower, and more honest, than a simple survival-style curve would suggest." - }, - "need": "local", - "preview": null, - "isNew": true, - "figureTitle": "Cumulative incidence — A vs B" - }, - "labs": { - "menu": { - "label": "Lab course and response (e.g. PSA50)", - "why": "this study records no lab-test timeline events", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no lab-test timeline events", - "loading": false - }, - "menuAggregateOnly": { - "why": "this study records no lab-test timeline events" - }, - "added": { - "kind": "labs", - "test": "PSA", - "anchor": { - "eventType": "Treatment" - }, - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "labs", - "test": "PSA", - "anchor": { - "eventType": "Treatment" - }, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "labs", - "test": "PSA", - "anchor": { - "eventType": "Treatment" }, - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Lab course and response (e.g. PSA50)", - "headTitle": "Lab course", - "drawsOwnHead": { - "error": true, - "withData": true + "drawsOwnWaiting": false, + "sub": "Diagnosis from day 0, death competing", + "help": { + "title": "What is this?", + "text": "Shows what share of patients have experienced a particular event — such as the cancer spreading to the brain — by each point in time. It counts patients who died before the event as never having it, rather than pretending they might still, which is why its numbers can be noticeably lower, and more honest, than a simple survival-style curve would suggest." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Cumulative incidence — A vs B" }, - "card": { - "label": "Lab course and response (e.g. PSA50)", - "title": "Lab course", - "maxCohorts": 4, - "why": null, - "whyBareStudy": "this study records no lab-test timeline events", - "defaults": { + "labs": { + "menu": { + "label": "Lab course and response (e.g. PSA50)", + "why": "this study records no lab-test timeline events", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no lab-test timeline events", + "loading": false + }, + "menuAggregateOnly": { + "why": "this study records no lab-test timeline events" + }, + "added": { "kind": "labs", "test": "PSA", "anchor": { "eventType": "Treatment" - } + }, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "labs", + "test": "PSA", + "anchor": { + "eventType": "Treatment" + }, + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedWithTwoCohorts": { "kind": "labs", "test": "PSA", "anchor": { "eventType": "Treatment" + }, + "cohorts": [] + }, + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Lab course and response (e.g. PSA50)", + "headTitle": "Lab course", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Lab course and response (e.g. PSA50)", + "title": "Lab course", + "maxCohorts": 4, + "why": null, + "whyBareStudy": "this study records no lab-test timeline events", + "defaults": { + "kind": "labs", + "test": "PSA", + "anchor": { + "eventType": "Treatment" + } + }, + "defaultsBareStudy": { + "kind": "labs", + "test": "PSA", + "anchor": { + "eventType": "Treatment" + } } - } - }, - "drawsOwnWaiting": false, - "sub": "PSA from first Treatment · responders fell ≥50%", - "help": { - "title": "What is this?", - "text": "Follows a blood test — such as PSA in prostate cancer — from the moment a treatment started, as the change from each patient's own starting level. The line shows the typical patient, the shaded band the middle half of patients, and the bars on the right each patient's best result: how far their level fell, or rose, after treatment began." - }, - "need": "local", - "preview": null, - "isNew": true, - "figureTitle": "Lab course — A vs B" - }, - "riskmodel": { - "menu": { - "label": "Risk model (elastic-net Cox, OncoCast-style)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "riskmodel", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "riskmodel", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "riskmodel", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Risk model (elastic-net Cox, OncoCast-style)", - "headTitle": "Risk model", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Risk model (elastic-net Cox, OncoCast-style)", - "title": "Risk model", + }, + "drawsOwnWaiting": false, + "sub": "PSA from first Treatment · responders fell ≥50%", + "help": { + "title": "What is this?", + "text": "Follows a blood test — such as PSA in prostate cancer — from the moment a treatment started, as the change from each patient's own starting level. The line shows the typical patient, the shaded band the middle half of patients, and the bars on the right each patient's best result: how far their level fell, or rose, after treatment began." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Lab course — A vs B" + }, + "riskmodel": { + "menu": { + "label": "Risk model (elastic-net Cox, OncoCast-style)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "riskmodel", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "riskmodel", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "riskmodel", + "cohorts": [] + }, "maxCohorts": 1, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "riskmodel" - }, - "defaultsBareStudy": { - "kind": "riskmodel" - } - }, - "drawsOwnWaiting": false, - "sub": "OS on mutated genes · 10 held-out splits", - "help": { - "title": "What is this?", - "text": "A model that predicts survival from which genes are mutated in each patient, in the style of the OncoCast package. It is trained many times, each time on two thirds of the patients and tested on the other third, so every patient's risk score comes from a model that never saw them. Patients are then split into low, intermediate and high risk, and their survival curves are drawn. The list shows the genes the model relied on most, with a hazard ratio above 1 meaning higher risk. Tick \"validate\" to train on one half of the patients and grade the model on the other half, which it never saw, or name another group of patients under \"validate on\" to grade it there — for example patients from a different study — with the same risk-group boundaries." - }, - "need": "patients", - "preview": null, - "isNew": true, - "figureTitle": "Risk model — A vs B" - }, - "cancertypes": { - "menu": { - "label": "Cancer types summary", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "cancertypes", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "cancertypes", - "genes": [ - "TP53", - "KRAS" - ], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "cancertypes", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1" - ], - "cohorts": [] - }, - "maxCohorts": 2, - "emptyHint": "It draws up to 2 cohorts, side by side.", - "title": "Cancer types summary", - "headTitle": "Cancer types summary", - "drawsOwnHead": { - "error": true, - "withData": true + "emptyHint": "It draws one cohort.", + "title": "Risk model (elastic-net Cox, OncoCast-style)", + "headTitle": "Risk model", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Risk model (elastic-net Cox, OncoCast-style)", + "title": "Risk model", + "maxCohorts": 1, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "riskmodel" + }, + "defaultsBareStudy": { + "kind": "riskmodel" + } + }, + "drawsOwnWaiting": false, + "sub": "OS on mutated genes · 10 held-out splits", + "help": { + "title": "What is this?", + "text": "A model that predicts survival from which genes are mutated in each patient, in the style of the OncoCast package. It is trained many times, each time on two thirds of the patients and tested on the other third, so every patient's risk score comes from a model that never saw them. Patients are then split into low, intermediate and high risk, and their survival curves are drawn. The list shows the genes the model relied on most, with a hazard ratio above 1 meaning higher risk. Tick \"validate\" to train on one half of the patients and grade the model on the other half, which it never saw, or name another group of patients under \"validate on\" to grade it there — for example patients from a different study — with the same risk-group boundaries." + }, + "need": "patients", + "preview": null, + "isNew": true, + "figureTitle": "Risk model — A vs B" }, - "card": { - "label": "Cancer types summary", - "title": "Cancer types summary", - "genes": true, - "maxCohorts": 2, - "why": null, - "whyBareStudy": null, - "defaults": { + "cancertypes": { + "menu": { + "label": "Cancer types summary", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "cancertypes", "genes": [ "TP53", @@ -1567,739 +1607,735 @@ "EGFR", "STK11", "KEAP1" + ], + "cohorts": [ + "A" ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "cancertypes", "genes": [ "TP53", "KRAS" + ], + "cohorts": [ + "A" ] - } - }, - "drawsOwnWaiting": false, - "sub": "by CANCER_TYPE, any alteration, % of profiled samples", - "help": { - "title": "What is this?", - "text": "For a list of genes, shows what share of samples in each cancer type carry a change in at least one of them — mutated, gained in many copies, deleted, or rearranged — with each bar split by which kind of change it was. It answers which cancers these genes matter most in, the way cBioPortal's Cancer Types Summary does." - }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Cancer types summary — A vs B" - }, - "overlap": { - "menu": { - "label": "Cohort overlap (UpSet)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "overlap", - "cohorts": [] - }, - "addedBareStudy": { - "kind": "overlap", - "cohorts": [] - }, - "addedWithTwoCohorts": { - "kind": "overlap", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It compares 2 to 6 cohorts.", - "title": "Cohort overlap (UpSet)", - "headTitle": "Cohort overlap", - "drawsOwnHead": { - "error": true, - "withData": true + }, + "addedWithTwoCohorts": { + "kind": "cancertypes", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "cohorts": [] + }, + "maxCohorts": 2, + "emptyHint": "It draws up to 2 cohorts, side by side.", + "title": "Cancer types summary", + "headTitle": "Cancer types summary", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Cancer types summary", + "title": "Cancer types summary", + "genes": true, + "maxCohorts": 2, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "cancertypes", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ] + }, + "defaultsBareStudy": { + "kind": "cancertypes", + "genes": [ + "TP53", + "KRAS" + ] + } + }, + "drawsOwnWaiting": false, + "sub": "by CANCER_TYPE, any alteration, % of profiled samples", + "help": { + "title": "What is this?", + "text": "For a list of genes, shows what share of samples in each cancer type carry a change in at least one of them — mutated, gained in many copies, deleted, or rearranged — with each bar split by which kind of change it was. It answers which cancers these genes matter most in, the way cBioPortal's Cancer Types Summary does." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Cancer types summary — A vs B" }, - "card": { - "label": "Cohort overlap (UpSet)", - "title": "Cohort overlap", + "overlap": { + "menu": { + "label": "Cohort overlap (UpSet)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "overlap", + "cohorts": [] + }, + "addedBareStudy": { + "kind": "overlap", + "cohorts": [] + }, + "addedWithTwoCohorts": { + "kind": "overlap", + "cohorts": [] + }, "maxCohorts": 6, - "minCohorts": 2, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "overlap" - }, - "defaultsBareStudy": { - "kind": "overlap" - } - }, - "drawsOwnWaiting": false, - "sub": "patients in exactly each combination of cohorts", - "help": { - "title": "What is this?", - "text": "Shows how the groups of patients you defined overlap: for every combination of groups, how many patients belong to exactly those groups and none of the others. Each column is one combination — the dots below it say which groups — and its bar is how many patients fall there, so you can see at a glance whether two groups are mostly the same people or nearly separate." - }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Cohort overlap — A vs B" - }, - "enrichment": { - "menu": { - "label": "Alteration enrichment (all genes)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "enrichment", - "cohorts": [] - }, - "addedBareStudy": { - "kind": "enrichment", - "cohorts": [] - }, - "addedWithTwoCohorts": { - "kind": "enrichment", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It compares 2 to 6 cohorts.", - "title": "Alteration enrichment (all genes)", - "headTitle": "Alteration enrichment", - "drawsOwnHead": { - "error": true, - "withData": true + "emptyHint": "It compares 2 to 6 cohorts.", + "title": "Cohort overlap (UpSet)", + "headTitle": "Cohort overlap", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Cohort overlap (UpSet)", + "title": "Cohort overlap", + "maxCohorts": 6, + "minCohorts": 2, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "overlap" + }, + "defaultsBareStudy": { + "kind": "overlap" + } + }, + "drawsOwnWaiting": false, + "sub": "patients in exactly each combination of cohorts", + "help": { + "title": "What is this?", + "text": "Shows how the groups of patients you defined overlap: for every combination of groups, how many patients belong to exactly those groups and none of the others. Each column is one combination — the dots below it say which groups — and its bar is how many patients fall there, so you can see at a glance whether two groups are mostly the same people or nearly separate." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Cohort overlap — A vs B" }, - "card": { - "label": "Alteration enrichment (all genes)", - "title": "Alteration enrichment", + "enrichment": { + "menu": { + "label": "Alteration enrichment (all genes)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "enrichment", + "cohorts": [] + }, + "addedBareStudy": { + "kind": "enrichment", + "cohorts": [] + }, + "addedWithTwoCohorts": { + "kind": "enrichment", + "cohorts": [] + }, "maxCohorts": 6, - "minCohorts": 2, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "enrichment" - }, - "defaultsBareStudy": { - "kind": "enrichment" - } - }, - "drawsOwnWaiting": false, - "sub": "mutations in every gene, per sample", - "help": { - "title": "What is this?", - "text": "Checks every gene at once for whether it is changed more often in one group of patients than another — mutated, gained, deleted or rearranged, whichever you choose. The genes at the top of the list, and furthest out on the chart, differ most clearly between the groups; the share in each group is beside every gene, so a difference that is clear but small is visible as small." - }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Alteration enrichment — A vs B" - }, - "assays": { - "menu": { - "label": "Generic assays (signatures, arm-level CNA, …)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "assays", - "profileType": "mutational_signatures_contribution_v2", - "cohorts": [] - }, - "addedBareStudy": { - "kind": "assays", - "profileType": "", - "cohorts": [] - }, - "addedWithTwoCohorts": { - "kind": "assays", - "profileType": "mutational_signatures_contribution_v2", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It compares 2 to 6 cohorts.", - "title": "Generic assays (signatures, arm-level CNA, …)", - "headTitle": "Generic assays", - "drawsOwnHead": { - "error": true, - "withData": true + "emptyHint": "It compares 2 to 6 cohorts.", + "title": "Alteration enrichment (all genes)", + "headTitle": "Alteration enrichment", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Alteration enrichment (all genes)", + "title": "Alteration enrichment", + "maxCohorts": 6, + "minCohorts": 2, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "enrichment" + }, + "defaultsBareStudy": { + "kind": "enrichment" + } + }, + "drawsOwnWaiting": false, + "sub": "mutations in every gene, per sample", + "help": { + "title": "What is this?", + "text": "Checks every gene at once for whether it is changed more often in one group of patients than another — mutated, gained, deleted or rearranged, whichever you choose. The genes at the top of the list, and furthest out on the chart, differ most clearly between the groups; the share in each group is beside every gene, so a difference that is clear but small is visible as small." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Alteration enrichment — A vs B" }, - "card": { - "label": "Generic assays (signatures, arm-level CNA, …)", - "title": "Generic assays", - "maxCohorts": 6, - "minCohorts": 2, - "why": null, - "whyBareStudy": "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)", - "defaults": { + "assays": { + "menu": { + "label": "Generic assays (signatures, arm-level CNA, …)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "assays", - "profileType": "mutational_signatures_contribution_v2" + "profileType": "mutational_signatures_contribution_v2", + "cohorts": [] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "assays", - "profileType": "" - } - }, - "drawsOwnWaiting": false, - "sub": "mutational_signatures_contribution_v2, per sample", - "help": { - "title": "What is this?", - "text": "Compares two or more groups on every measurement in one kind of extra lab data the study recorded — such as which mutational signatures (like sun damage or smoking) shaped each tumour, or which chromosome arms were gained or lost. The measurements at the top differ most clearly between the groups; each one shows its average, or its mix of results, in every group, so a difference that is clear but small reads as small." - }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Generic assays — A vs B" - }, - "composition": { - "menu": { - "label": "Composition (cell types, states, signatures as parts of a whole)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no generic-assay profile and no numeric attribute to draw as parts", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "composition", - "profileType": "mutational_signatures_contribution_v2", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "composition", - "attributes": [], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "composition", - "profileType": "mutational_signatures_contribution_v2", - "cohorts": [] - }, - "maxCohorts": 8, - "emptyHint": "It compares 1 to 8 cohorts.", - "title": "Composition (cell types, states, signatures as parts of a whole)", - "headTitle": "Composition", - "drawsOwnHead": { - "error": true, - "withData": true + "profileType": "", + "cohorts": [] + }, + "addedWithTwoCohorts": { + "kind": "assays", + "profileType": "mutational_signatures_contribution_v2", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It compares 2 to 6 cohorts.", + "title": "Generic assays (signatures, arm-level CNA, …)", + "headTitle": "Generic assays", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Generic assays (signatures, arm-level CNA, …)", + "title": "Generic assays", + "maxCohorts": 6, + "minCohorts": 2, + "why": null, + "whyBareStudy": "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)", + "defaults": { + "kind": "assays", + "profileType": "mutational_signatures_contribution_v2" + }, + "defaultsBareStudy": { + "kind": "assays", + "profileType": "" + } + }, + "drawsOwnWaiting": false, + "sub": "mutational_signatures_contribution_v2, per sample", + "help": { + "title": "What is this?", + "text": "Compares two or more groups on every measurement in one kind of extra lab data the study recorded — such as which mutational signatures (like sun damage or smoking) shaped each tumour, or which chromosome arms were gained or lost. The measurements at the top differ most clearly between the groups; each one shows its average, or its mix of results, in every group, so a difference that is clear but small reads as small." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Generic assays — A vs B" }, - "card": { - "label": "Composition (cell types, states, signatures as parts of a whole)", - "title": "Composition", - "maxCohorts": 8, - "minCohorts": 1, - "why": null, - "whyBareStudy": "this study has no generic-assay profile and no numeric attribute to draw as parts", - "defaults": { + "composition": { + "menu": { + "label": "Composition (cell types, states, signatures as parts of a whole)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no generic-assay profile and no numeric attribute to draw as parts", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "composition", - "profileType": "mutational_signatures_contribution_v2" + "profileType": "mutational_signatures_contribution_v2", + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "composition", - "attributes": [] - } - }, - "drawsOwnWaiting": false, - "sub": "mutational_signatures_contribution_v2, as shares", - "help": { - "title": "What is this?", - "text": "Shows what each patient's (or sample's) tumour is made of — the share of each cell type, cell state or signature — as one stacked bar per case, sorted by the part you choose, with each group's average beside it. Under the bars, every group is set against the others part by part: the colour says whether a part is higher (red) or lower (blue) there, its size how sure that is. Parts of a whole move together, so one part rising means others fall." - }, - "need": "local", - "preview": null, - "isNew": true, - "figureTitle": "Composition — A vs B" - }, - "correlates": { - "menu": { - "label": "Co-expression with one gene (genome-wide)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no continuous molecular profile (mRNA, protein, …) to correlate on", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "correlates", - "gene": "TP53", - "profileType": "rna_seq_v2_mrna", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "correlates", - "gene": "TP53", - "profileType": "", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "correlates", - "gene": "TP53", - "profileType": "rna_seq_v2_mrna", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Co-expression with one gene (genome-wide)", - "headTitle": "Co-expression", - "drawsOwnHead": { - "error": true, - "withData": true + "attributes": [], + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "composition", + "profileType": "mutational_signatures_contribution_v2", + "cohorts": [] + }, + "maxCohorts": 8, + "emptyHint": "It compares 1 to 8 cohorts.", + "title": "Composition (cell types, states, signatures as parts of a whole)", + "headTitle": "Composition", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Composition (cell types, states, signatures as parts of a whole)", + "title": "Composition", + "maxCohorts": 8, + "minCohorts": 1, + "why": null, + "whyBareStudy": "this study has no generic-assay profile and no numeric attribute to draw as parts", + "defaults": { + "kind": "composition", + "profileType": "mutational_signatures_contribution_v2" + }, + "defaultsBareStudy": { + "kind": "composition", + "attributes": [] + } + }, + "drawsOwnWaiting": false, + "sub": "mutational_signatures_contribution_v2, as shares", + "help": { + "title": "What is this?", + "text": "Shows what each patient's (or sample's) tumour is made of — the share of each cell type, cell state or signature — as one stacked bar per case, sorted by the part you choose, with each group's average beside it. Under the bars, every group is set against the others part by part: the colour says whether a part is higher (red) or lower (blue) there, its size how sure that is. Parts of a whole move together, so one part rising means others fall." + }, + "need": "local", + "preview": null, + "isNew": true, + "figureTitle": "Composition — A vs B" }, - "card": { - "label": "Co-expression with one gene (genome-wide)", - "title": "Co-expression", - "maxCohorts": 1, - "why": null, - "whyBareStudy": "this study has no continuous molecular profile (mRNA, protein, …) to correlate on", - "defaults": { + "correlates": { + "menu": { + "label": "Co-expression with one gene (genome-wide)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no continuous molecular profile (mRNA, protein, …) to correlate on", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "correlates", "gene": "TP53", - "profileType": "rna_seq_v2_mrna" + "profileType": "rna_seq_v2_mrna", + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "correlates", "gene": "TP53", - "profileType": "" - } - }, - "drawsOwnWaiting": false, - "sub": "TP53 on rna_seq_v2_mrna · Spearman ρ, every gene", - "help": { - "title": "What is this?", - "text": "Takes one gene and checks every other gene for whether its activity rises and falls along with it across these patients. The genes at the top go up when it goes up; those at the bottom go down when it goes up. The chart on the left shows how all genes fall: most barely move with it, and the ones far out on either side are the ones listed. Moving together is a clue, not proof that one gene controls the other." - }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Co-expression — A vs B" - }, - "table": { - "menu": { - "label": "Clinical data table (download)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "table", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "table", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "table", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Clinical data table (download)", - "headTitle": "Clinical data", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Clinical data table (download)", - "title": "Clinical data", + "profileType": "", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "correlates", + "gene": "TP53", + "profileType": "rna_seq_v2_mrna", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Co-expression with one gene (genome-wide)", + "headTitle": "Co-expression", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Co-expression with one gene (genome-wide)", + "title": "Co-expression", + "maxCohorts": 1, + "why": null, + "whyBareStudy": "this study has no continuous molecular profile (mRNA, protein, …) to correlate on", + "defaults": { + "kind": "correlates", + "gene": "TP53", + "profileType": "rna_seq_v2_mrna" + }, + "defaultsBareStudy": { + "kind": "correlates", + "gene": "TP53", + "profileType": "" + } + }, + "drawsOwnWaiting": false, + "sub": "TP53 on rna_seq_v2_mrna · Spearman ρ, every gene", + "help": { + "title": "What is this?", + "text": "Takes one gene and checks every other gene for whether its activity rises and falls along with it across these patients. The genes at the top go up when it goes up; those at the bottom go down when it goes up. The chart on the left shows how all genes fall: most barely move with it, and the ones far out on either side are the ones listed. Moving together is a clue, not proof that one gene controls the other." + }, + "need": "local", + "preview": null, + "isNew": false, + "figureTitle": "Co-expression — A vs B" + }, + "table": { + "menu": { + "label": "Clinical data table (download)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "table", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "table", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "table", + "cohorts": [] + }, "maxCohorts": 6, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "table" - }, - "defaultsBareStudy": { - "kind": "table" - } - }, - "drawsOwnWaiting": true, - "sub": "one row per sample", - "help": { - "title": "What is this?", - "text": "Lists every sample (or every patient) in your groups with everything the study recorded about them — cancer type, age, stage, survival and the rest — one row each. Click a column's name to sort by it; the download button saves every row as a file a spreadsheet can open. Studies that only share counts, never individual patients, are named rather than listed." - }, - "need": "patients", - "preview": null, - "isNew": false, - "figureTitle": "table — A vs B" - }, - "mutations": { - "menu": { - "label": "Mutation table (one gene)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "mutations", - "gene": "TP53", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "mutations", - "gene": "TP53", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "mutations", - "gene": "TP53", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Mutation table (one gene)", - "headTitle": "Mutations", - "drawsOwnHead": { - "error": true, - "withData": true + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Clinical data table (download)", + "headTitle": "Clinical data", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Clinical data table (download)", + "title": "Clinical data", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "table" + }, + "defaultsBareStudy": { + "kind": "table" + } + }, + "drawsOwnWaiting": true, + "sub": "one row per sample", + "help": { + "title": "What is this?", + "text": "Lists every sample (or every patient) in your groups with everything the study recorded about them — cancer type, age, stage, survival and the rest — one row each. Click a column's name to sort by it; the download button saves every row as a file a spreadsheet can open. Studies that only share counts, never individual patients, are named rather than listed." + }, + "need": "patients", + "preview": null, + "isNew": false, + "figureTitle": "table — A vs B" }, - "card": { - "label": "Mutation table (one gene)", - "title": "Mutations", - "maxCohorts": 6, - "why": null, - "whyBareStudy": null, - "defaults": { + "mutations": { + "menu": { + "label": "Mutation table (one gene)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { "kind": "mutations", - "gene": "TP53" + "gene": "TP53", + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "mutations", - "gene": "TP53" - } - }, - "drawsOwnWaiting": true, - "sub": "TP53 · every mutation call", - "help": { - "title": "What is this?", - "text": "Lists every mutation found in one gene in your groups, one row per mutation: which sample it was in, the exact protein change, whether OncoKB considers it cancer-causing, what kind of change it is, and how much of the tumour carries it. Click a column to sort by it; the download saves every row. It sits under the lollipop chart, which shows the same mutations as positions along the protein." - }, - "need": "patients", - "preview": null, - "isNew": false, - "figureTitle": "mutations — A vs B" - }, - "alterations": { - "menu": { - "label": "Patient alterations (mutations, copy number, SVs; one patient at a time)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "alterations", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "alterations", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "alterations", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Patient alterations (mutations, copy number, SVs; one patient at a time)", - "headTitle": "Patient alterations", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Patient alterations (mutations, copy number, SVs; one patient at a time)", - "title": "Patient alterations", + "gene": "TP53", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "mutations", + "gene": "TP53", + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Mutation table (one gene)", + "headTitle": "Mutations", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Mutation table (one gene)", + "title": "Mutations", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "mutations", + "gene": "TP53" + }, + "defaultsBareStudy": { + "kind": "mutations", + "gene": "TP53" + } + }, + "drawsOwnWaiting": true, + "sub": "TP53 · every mutation call", + "help": { + "title": "What is this?", + "text": "Lists every mutation found in one gene in your groups, one row per mutation: which sample it was in, the exact protein change, whether OncoKB considers it cancer-causing, what kind of change it is, and how much of the tumour carries it. Click a column to sort by it; the download saves every row. It sits under the lollipop chart, which shows the same mutations as positions along the protein." + }, + "need": "patients", + "preview": null, + "isNew": false, + "figureTitle": "mutations — A vs B" + }, + "alterations": { + "menu": { + "label": "Patient alterations (mutations, copy number, SVs; one patient at a time)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "alterations", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "alterations", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "alterations", + "cohorts": [] + }, "maxCohorts": 1, - "cohortInHeader": true, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "alterations" - }, - "defaultsBareStudy": { - "kind": "alterations" - } - }, - "drawsOwnWaiting": true, - "sub": "(element)", - "help": { - "title": "What is this?", - "text": "Lists one patient's genetic changes as cBioPortal's patient page does, in three tables: mutations (the gene, the protein change, whether OncoKB considers it cancer-causing, and how much of the tumour carries it), copy-number changes (genes amplified or deleted outright) and structural variants (fusions and rearrangements). A change found in several of the patient's samples is one row, with a numbered marker for each sample. Cohort % says how common the same kind of change in that gene is across the study. Each table can be searched, filtered, sorted and downloaded on its own. Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one." - }, - "need": "alterations", - "preview": null, - "isNew": false, - "figureTitle": "Patient alterations — A vs B" - }, - "timeline": { - "menu": { - "label": "Patient timeline (one patient at a time)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study records no timeline events", - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "timeline", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "timeline", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "timeline", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Patient timeline (one patient at a time)", - "headTitle": "Patient timeline", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Patient timeline (one patient at a time)", - "title": "Patient timeline", + "emptyHint": "It draws one cohort.", + "title": "Patient alterations (mutations, copy number, SVs; one patient at a time)", + "headTitle": "Patient alterations", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Patient alterations (mutations, copy number, SVs; one patient at a time)", + "title": "Patient alterations", + "maxCohorts": 1, + "cohortInHeader": true, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "alterations" + }, + "defaultsBareStudy": { + "kind": "alterations" + } + }, + "drawsOwnWaiting": true, + "sub": "(element)", + "help": { + "title": "What is this?", + "text": "Lists one patient's genetic changes as cBioPortal's patient page does, in three tables: mutations (the gene, the protein change, whether OncoKB considers it cancer-causing, and how much of the tumour carries it), copy-number changes (genes amplified or deleted outright) and structural variants (fusions and rearrangements). A change found in several of the patient's samples is one row, with a numbered marker for each sample. Cohort % says how common the same kind of change in that gene is across the study. Each table can be searched, filtered, sorted and downloaded on its own. Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one." + }, + "need": "alterations", + "preview": null, + "isNew": false, + "figureTitle": "Patient alterations — A vs B" + }, + "timeline": { + "menu": { + "label": "Patient timeline (one patient at a time)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study records no timeline events", + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "timeline", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "timeline", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "timeline", + "cohorts": [] + }, "maxCohorts": 1, - "cohortInHeader": true, - "why": null, - "whyBareStudy": "this study records no timeline events", - "defaults": { - "kind": "timeline" - }, - "defaultsBareStudy": { - "kind": "timeline" - } - }, - "drawsOwnWaiting": true, - "sub": "(element)", - "help": { - "title": "What is this?", - "text": "Shows one patient's medical history on a single line of time, as cBioPortal's patient page does: when samples were taken, operations, each treatment and how long it lasted, diagnoses, and lab results drawn as small charts. Time is counted from the day of diagnosis, and long stretches with nothing recorded are cut short (the dashed line). Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one. Studies that only share counts, never individual patients, are named rather than listed." - }, - "need": "timeline", - "preview": null, - "isNew": false, - "figureTitle": "Patient timeline — A vs B" - }, - "acquired": { - "menu": { - "label": "Acquired under therapy (samples before vs after a treatment start)", - "why": "this study records no treatment timeline events to anchor on", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no treatment timeline events to anchor on", - "loading": false - }, - "menuAggregateOnly": { - "why": "this study records no treatment timeline events to anchor on" - }, - "added": { - "kind": "acquired", - "anchor": { - "eventType": "Treatment" - }, - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "acquired", - "anchor": { - "eventType": "Treatment" - }, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "acquired", - "anchor": { - "eventType": "Treatment" + "emptyHint": "It draws one cohort.", + "title": "Patient timeline (one patient at a time)", + "headTitle": "Patient timeline", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Patient timeline (one patient at a time)", + "title": "Patient timeline", + "maxCohorts": 1, + "cohortInHeader": true, + "why": null, + "whyBareStudy": "this study records no timeline events", + "defaults": { + "kind": "timeline" + }, + "defaultsBareStudy": { + "kind": "timeline" + } }, - "cohorts": [] - }, - "maxCohorts": 3, - "emptyHint": "It draws up to 3 cohorts, side by side.", - "title": "Acquired under therapy (samples before vs after a treatment start)", - "headTitle": "Acquired under therapy", - "drawsOwnHead": { - "error": true, - "withData": true + "drawsOwnWaiting": true, + "sub": "(element)", + "help": { + "title": "What is this?", + "text": "Shows one patient's medical history on a single line of time, as cBioPortal's patient page does: when samples were taken, operations, each treatment and how long it lasted, diagnoses, and lab results drawn as small charts. Time is counted from the day of diagnosis, and long stretches with nothing recorded are cut short (the dashed line). Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one. Studies that only share counts, never individual patients, are named rather than listed." + }, + "need": "timeline", + "preview": null, + "isNew": false, + "figureTitle": "Patient timeline — A vs B" }, - "card": { - "label": "Acquired under therapy (samples before vs after a treatment start)", - "title": "Acquired under therapy", - "genes": true, - "maxCohorts": 3, - "why": null, - "whyBareStudy": "this study records no treatment timeline events to anchor on", - "defaults": { + "acquired": { + "menu": { + "label": "Acquired under therapy (samples before vs after a treatment start)", + "why": "this study records no treatment timeline events to anchor on", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no treatment timeline events to anchor on", + "loading": false + }, + "menuAggregateOnly": { + "why": "this study records no treatment timeline events to anchor on" + }, + "added": { "kind": "acquired", "anchor": { "eventType": "Treatment" - } + }, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "acquired", + "anchor": { + "eventType": "Treatment" + }, + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedWithTwoCohorts": { "kind": "acquired", "anchor": { "eventType": "Treatment" + }, + "cohorts": [] + }, + "maxCohorts": 3, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "title": "Acquired under therapy (samples before vs after a treatment start)", + "headTitle": "Acquired under therapy", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Acquired under therapy (samples before vs after a treatment start)", + "title": "Acquired under therapy", + "genes": true, + "maxCohorts": 3, + "why": null, + "whyBareStudy": "this study records no treatment timeline events to anchor on", + "defaults": { + "kind": "acquired", + "anchor": { + "eventType": "Treatment" + } + }, + "defaultsBareStudy": { + "kind": "acquired", + "anchor": { + "eventType": "Treatment" + } } - } - }, - "drawsOwnWaiting": false, - "sub": "last sample before the first Treatment vs the first ≥30 days after, any alteration, every gene scanned", - "help": { - "title": "What is this?", - "text": "For patients who had a tumour sample tested before a treatment started and another one afterwards, shows gene by gene how many had a change only in the later sample (acquired, to the right) or only in the earlier one (lost, to the left). Genes that keep turning up only after treatment can point to how tumours resist it. It cannot say the treatment caused the change: second biopsies are usually taken when the cancer grows again, and a change can simply have been missed the first time." - }, - "need": "local", - "preview": "no method record or golden test of the before/after pairing and McNemar counts yet", - "isNew": true, - "figureTitle": "Acquired under therapy — A vs B" - }, - "ctdna": { - "menu": { - "label": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "ctdna", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF", - "ERBB2" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "ctdna", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "ctdna", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF", - "ERBB2" - ], - "cohorts": [] - }, - "maxCohorts": 3, - "emptyHint": "It draws up to 3 cohorts, side by side.", - "title": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", - "headTitle": "ctDNA vs tissue", - "drawsOwnHead": { - "error": true, - "withData": true + }, + "drawsOwnWaiting": false, + "sub": "last sample before the first Treatment vs the first ≥30 days after, any alteration, every gene scanned", + "help": { + "title": "What is this?", + "text": "For patients who had a tumour sample tested before a treatment started and another one afterwards, shows gene by gene how many had a change only in the later sample (acquired, to the right) or only in the earlier one (lost, to the left). Genes that keep turning up only after treatment can point to how tumours resist it. It cannot say the treatment caused the change: second biopsies are usually taken when the cancer grows again, and a change can simply have been missed the first time." + }, + "need": "local", + "preview": "no method record or golden test of the before/after pairing and McNemar counts yet", + "isNew": true, + "figureTitle": "Acquired under therapy — A vs B" }, - "card": { - "label": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", - "title": "ctDNA vs tissue", - "genes": true, - "maxCohorts": 3, - "why": null, - "whyBareStudy": null, - "defaults": { + "ctdna": { + "menu": { + "label": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "ctdna", "genes": [ "TP53", @@ -2311,97 +2347,90 @@ "APC", "BRAF", "ERBB2" + ], + "cohorts": [ + "A" ] }, - "defaultsBareStudy": { - "kind": "ctdna" - } - }, - "drawsOwnWaiting": false, - "sub": "plasma SAMPLE_CLASS = cfDNA/ctDNA vs tissue Tumor, any alteration", - "help": { - "title": "What is this?", - "text": "Tumours shed DNA into the blood, and a blood test (a \"liquid biopsy\") can read it. For patients who had both a blood sample and a tumour tissue sample tested, this shows gene by gene how often a change was found in both, only in the tissue, or only in the blood — and how often the blood test caught what the tissue showed. Below, it shows how many patients had tumour DNA detected in their blood at all, and whether those patients lived shorter or longer than those without it. With the clonal-haematopoiesis filter on, changes that probably come from ageing blood cells rather than the tumour — in genes such as DNMT3A and TET2, or also found in the patient's white blood cells — are left out first, and the figure says how many were removed and what the numbers were without the filter." - }, - "need": "local", - "preview": "no method record or golden test of the agreement counts, Wilson intervals and survival split, or of the clonal-haematopoiesis filter (chFilter) yet", - "isNew": true, - "figureTitle": "ctDNA vs tissue — A vs B" - }, - "exclusivity": { - "menu": { - "label": "Mutual exclusivity (DISCOVER, burden-aware)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "exclusivity", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "exclusivity", - "genes": [ - "KRAS", - "EGFR", - "BRAF", - "STK11", - "KEAP1", - "TP53" - ], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "exclusivity", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Mutual exclusivity (DISCOVER, burden-aware)", - "headTitle": "Mutual exclusivity", - "drawsOwnHead": { - "error": true, - "withData": true + "addedBareStudy": { + "kind": "ctdna", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "ctdna", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ], + "cohorts": [] + }, + "maxCohorts": 3, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "title": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", + "headTitle": "ctDNA vs tissue", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", + "title": "ctDNA vs tissue", + "genes": true, + "maxCohorts": 3, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "ctdna", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ] + }, + "defaultsBareStudy": { + "kind": "ctdna" + } + }, + "drawsOwnWaiting": false, + "sub": "plasma SAMPLE_CLASS = cfDNA/ctDNA vs tissue Tumor, any alteration", + "help": { + "title": "What is this?", + "text": "Tumours shed DNA into the blood, and a blood test (a \"liquid biopsy\") can read it. For patients who had both a blood sample and a tumour tissue sample tested, this shows gene by gene how often a change was found in both, only in the tissue, or only in the blood — and how often the blood test caught what the tissue showed. Below, it shows how many patients had tumour DNA detected in their blood at all, and whether those patients lived shorter or longer than those without it. With the clonal-haematopoiesis filter on, changes that probably come from ageing blood cells rather than the tumour — in genes such as DNMT3A and TET2, or also found in the patient's white blood cells — are left out first, and the figure says how many were removed and what the numbers were without the filter." + }, + "need": "local", + "preview": "no method record or golden test of the agreement counts, Wilson intervals and survival split, or of the clonal-haematopoiesis filter (chFilter) yet", + "isNew": true, + "figureTitle": "ctDNA vs tissue — A vs B" }, - "card": { - "label": "Mutual exclusivity (DISCOVER, burden-aware)", - "title": "Mutual exclusivity", - "genes": true, - "maxCohorts": 1, - "cohortInHeader": true, - "why": null, - "whyBareStudy": null, - "defaults": { + "exclusivity": { + "menu": { + "label": "Mutual exclusivity (DISCOVER, burden-aware)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "exclusivity", "genes": [ "TP53", @@ -2412,9 +2441,12 @@ "PIK3CA", "APC", "BRAF" + ], + "cohorts": [ + "A" ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "exclusivity", "genes": [ "KRAS", @@ -2423,179 +2455,182 @@ "STK11", "KEAP1", "TP53" + ], + "cohorts": [ + "A" ] - } - }, - "drawsOwnWaiting": false, - "sub": "(element)", - "help": { - "title": "What is this?", - "text": "Asks, for each pair of genes, whether tumours carry changes in both more or less often than chance — \"mutually exclusive\" genes are rarely changed together, which can mean either change alone is enough to drive the cancer. Its sense of \"chance\" accounts for how many changes each tumour has overall: a tumour with hundreds of changes is expected to share genes with many others, so it does not make two genes look linked just because they both turn up in heavily altered tumours. Beside it is the simpler test that ignores this, to show the difference." - }, - "need": "local", - "preview": "no method record or golden test of the DISCOVER background fit against the R package yet", - "isNew": true, - "figureTitle": "Mutual exclusivity (DISCOVER) — A vs B" - }, - "txendpoints": { - "menu": { - "label": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", - "why": "this study records no treatment timeline events", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no treatment timeline events", - "loading": false - }, - "menuAggregateOnly": { - "why": "this study records no treatment timeline events" - }, - "added": { - "kind": "txendpoints", - "endpoint": "ttnt", - "anchor": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "txendpoints", - "endpoint": "ttnt", - "anchor": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "txendpoints", - "endpoint": "ttnt", - "anchor": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", - "headTitle": "Treatment endpoints", - "drawsOwnHead": { - "error": true, - "withData": true + }, + "addedWithTwoCohorts": { + "kind": "exclusivity", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Mutual exclusivity (DISCOVER, burden-aware)", + "headTitle": "Mutual exclusivity", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Mutual exclusivity (DISCOVER, burden-aware)", + "title": "Mutual exclusivity", + "genes": true, + "maxCohorts": 1, + "cohortInHeader": true, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "exclusivity", + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ] + }, + "defaultsBareStudy": { + "kind": "exclusivity", + "genes": [ + "KRAS", + "EGFR", + "BRAF", + "STK11", + "KEAP1", + "TP53" + ] + } + }, + "drawsOwnWaiting": false, + "sub": "(element)", + "help": { + "title": "What is this?", + "text": "Asks, for each pair of genes, whether tumours carry changes in both more or less often than chance — \"mutually exclusive\" genes are rarely changed together, which can mean either change alone is enough to drive the cancer. Its sense of \"chance\" accounts for how many changes each tumour has overall: a tumour with hundreds of changes is expected to share genes with many others, so it does not make two genes look linked just because they both turn up in heavily altered tumours. Beside it is the simpler test that ignores this, to show the difference." + }, + "need": "local", + "preview": "no method record or golden test of the DISCOVER background fit against the R package yet", + "isNew": true, + "figureTitle": "Mutual exclusivity (DISCOVER) — A vs B" }, - "card": { - "label": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", - "title": "Treatment endpoints", - "maxCohorts": 6, - "why": null, - "whyBareStudy": "this study records no treatment timeline events", - "defaults": { + "txendpoints": { + "menu": { + "label": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", + "why": "this study records no treatment timeline events", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no treatment timeline events", + "loading": false + }, + "menuAggregateOnly": { + "why": "this study records no treatment timeline events" + }, + "added": { "kind": "txendpoints", "endpoint": "ttnt", "anchor": { "eventType": "Treatment", "key": "SUBTYPE", "value": "Immuno" - } + }, + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "txendpoints", "endpoint": "ttnt", "anchor": { "eventType": "Treatment", "key": "SUBTYPE", "value": "Immuno" - } - } - }, - "drawsOwnWaiting": false, - "sub": "time to next treatment from first Treatment [SUBTYPE=Immuno], death an event", - "help": { - "title": "What is this?", - "text": "Starts each patient's clock on the day a treatment began — for example their first immunotherapy — and follows them to an outcome that real-world studies use in place of a trial's: when they moved on to their next treatment (time to next treatment), when they stopped this one (time on treatment), or when their cancer was recorded as growing (real-world progression-free survival). Death counts as the outcome too, and patients still followed without it are counted up to their last visit. Each group's curve shows the share not yet there at each month, with the number still followed below. The groups were not randomised to the treatment, so a gap between curves is a clue, not proof that a gene or a group changes how well the treatment works." - }, - "need": "local", - "preview": "no method record or golden test of the endpoint derivation (lines, stops, progression) or the median intervals yet", - "isNew": true, - "figureTitle": "Treatment endpoints — A vs B" - }, - "multistate": { - "menu": { - "label": "Multistate model (progression, then death)", - "why": "this study records no diagnosis or progression timeline events", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no diagnosis or progression timeline events", - "loading": false - }, - "menuAggregateOnly": { - "why": "this study records no diagnosis or progression timeline events" - }, - "added": { - "kind": "multistate", - "progression": { - "eventType": "Diagnosis", - "key": "PROGRESSION", - "values": [ - "Y", - "Yes" - ] - }, - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "multistate", - "progression": { - "eventType": "Diagnosis", - "key": "PROGRESSION", - "values": [ - "Y", - "Yes" - ] - }, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "multistate", - "progression": { - "eventType": "Diagnosis", - "key": "PROGRESSION", - "values": [ - "Y", - "Yes" + }, + "cohorts": [ + "A" ] }, - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Multistate model (progression, then death)", - "headTitle": "Multistate model", - "drawsOwnHead": { - "error": true, - "withData": true + "addedWithTwoCohorts": { + "kind": "txendpoints", + "endpoint": "ttnt", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", + "headTitle": "Treatment endpoints", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", + "title": "Treatment endpoints", + "maxCohorts": 6, + "why": null, + "whyBareStudy": "this study records no treatment timeline events", + "defaults": { + "kind": "txendpoints", + "endpoint": "ttnt", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + } + }, + "defaultsBareStudy": { + "kind": "txendpoints", + "endpoint": "ttnt", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + } + } + }, + "drawsOwnWaiting": false, + "sub": "time to next treatment from first Treatment [SUBTYPE=Immuno], death an event", + "help": { + "title": "What is this?", + "text": "Starts each patient's clock on the day a treatment began — for example their first immunotherapy — and follows them to an outcome that real-world studies use in place of a trial's: when they moved on to their next treatment (time to next treatment), when they stopped this one (time on treatment), or when their cancer was recorded as growing (real-world progression-free survival). Death counts as the outcome too, and patients still followed without it are counted up to their last visit. Each group's curve shows the share not yet there at each month, with the number still followed below. The groups were not randomised to the treatment, so a gap between curves is a clue, not proof that a gene or a group changes how well the treatment works." + }, + "need": "local", + "preview": "no method record or golden test of the endpoint derivation (lines, stops, progression) or the median intervals yet", + "isNew": true, + "figureTitle": "Treatment endpoints — A vs B" }, - "card": { - "label": "Multistate model (progression, then death)", - "title": "Multistate model", - "maxCohorts": 4, - "why": null, - "whyBareStudy": "this study records no diagnosis or progression timeline events", - "defaults": { + "multistate": { + "menu": { + "label": "Multistate model (progression, then death)", + "why": "this study records no diagnosis or progression timeline events", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no diagnosis or progression timeline events", + "loading": false + }, + "menuAggregateOnly": { + "why": "this study records no diagnosis or progression timeline events" + }, + "added": { "kind": "multistate", "progression": { "eventType": "Diagnosis", @@ -2604,9 +2639,12 @@ "Y", "Yes" ] - } + }, + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "multistate", "progression": { "eventType": "Diagnosis", @@ -2615,168 +2653,146 @@ "Y", "Yes" ] - } - } - }, - "drawsOwnWaiting": false, - "sub": "start → progression → death, months from day 0 · progression = Diagnosis PROGRESSION", - "help": { - "title": "What is this?", - "text": "Follows each patient through three states — alive without progression, alive after their cancer was first recorded as growing, and dead — from the start of their record (in MSK-CHORD, their first tumour sequencing). The coloured bands show, month by month, what share of each group is in each state; the lines below show how fast patients move along each of the three paths (progressing, dying without progressing, dying after progressing), and the table gives each path's rate and how much faster or slower each group moves along it than the first. Splitting death into 'after progression' and 'without it' shows where a group's worse survival comes from. Progression is read from radiology reports by a model, so it is only seen in patients who were scanned; the groups were not randomised, so a difference is a clue, not proof." - }, - "need": "local", - "preview": "no method record or golden test of the Aalen–Johansen occupation, Nelson–Aalen hazards or the delayed-entry Cox fits yet (no interval on the occupation)", - "isNew": true, - "figureTitle": "Multistate model — progression and death — A vs B" - }, - "rmst": { - "menu": { - "label": "Restricted mean OS time (RMST, landmarks)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no _MONTHS outcome column", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "rmst", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "rmst", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "rmst", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Restricted mean OS time (RMST, landmarks)", - "headTitle": "Restricted mean OS time", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Restricted mean OS time (RMST, landmarks)", - "title": "Restricted mean OS time", + }, + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "multistate", + "progression": { + "eventType": "Diagnosis", + "key": "PROGRESSION", + "values": [ + "Y", + "Yes" + ] + }, + "cohorts": [] + }, + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Multistate model (progression, then death)", + "headTitle": "Multistate model", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Multistate model (progression, then death)", + "title": "Multistate model", + "maxCohorts": 4, + "why": null, + "whyBareStudy": "this study records no diagnosis or progression timeline events", + "defaults": { + "kind": "multistate", + "progression": { + "eventType": "Diagnosis", + "key": "PROGRESSION", + "values": [ + "Y", + "Yes" + ] + } + }, + "defaultsBareStudy": { + "kind": "multistate", + "progression": { + "eventType": "Diagnosis", + "key": "PROGRESSION", + "values": [ + "Y", + "Yes" + ] + } + } + }, + "drawsOwnWaiting": false, + "sub": "start → progression → death, months from day 0 · progression = Diagnosis PROGRESSION", + "help": { + "title": "What is this?", + "text": "Follows each patient through three states — alive without progression, alive after their cancer was first recorded as growing, and dead — from the start of their record (in MSK-CHORD, their first tumour sequencing). The coloured bands show, month by month, what share of each group is in each state; the lines below show how fast patients move along each of the three paths (progressing, dying without progressing, dying after progressing), and the table gives each path's rate and how much faster or slower each group moves along it than the first. Splitting death into 'after progression' and 'without it' shows where a group's worse survival comes from. Progression is read from radiology reports by a model, so it is only seen in patients who were scanned; the groups were not randomised, so a difference is a clue, not proof." + }, + "need": "local", + "preview": "no method record or golden test of the Aalen–Johansen occupation, Nelson–Aalen hazards or the delayed-entry Cox fits yet (no interval on the occupation)", + "isNew": true, + "figureTitle": "Multistate model — progression and death — A vs B" + }, + "rmst": { + "menu": { + "label": "Restricted mean OS time (RMST, landmarks)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no _MONTHS outcome column", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "rmst", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "rmst", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "rmst", + "cohorts": [] + }, "maxCohorts": 6, - "why": null, - "whyBareStudy": "this study has no _MONTHS outcome column", - "defaults": { - "kind": "rmst" - }, - "defaultsBareStudy": { - "kind": "rmst" - } - }, - "drawsOwnWaiting": false, - "sub": "area under the KM curve to τ where every cohort has 10 at risk · OS", - "help": { - "title": "What is this?", - "text": "Answers “how many months, on average, did patients in each group live within the first τ months?” — the shaded area under each survival curve up to τ. The difference between two groups is the months gained or lost in that window, which reads plainly even when the curves cross or only separate late (as with immunotherapy), where a single hazard ratio can mislead. τ is where every group still has at least ten patients followed, unless it is set. Landmark panels ask the same question again among the patients still alive and followed at 6 or 12 months, restarting the clock there. The groups were not randomised, so a difference is a clue, not proof." - }, - "need": "local", - "preview": "no method record or golden test of the restricted means, their variances and the landmark sets against R survRM2 yet", - "isNew": true, - "figureTitle": "Restricted mean survival time — A vs B" - }, - "predictive": { - "menu": { - "label": "Predictive biomarkers (gene × treatment interaction)", - "why": "this study records no treatment timeline events to define an exposure", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no treatment timeline events to define an exposure", - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "predictive", - "exposure": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "predictive", - "exposure": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, - "genes": [ - "STK11", - "KEAP1", - "KRAS", - "TP53", - "EGFR" - ], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "predictive", - "exposure": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], - "cohorts": [] - }, - "maxCohorts": 2, - "emptyHint": "It draws up to 2 cohorts, side by side.", - "title": "Predictive biomarkers (gene × treatment interaction)", - "headTitle": "Predictive biomarkers", - "drawsOwnHead": { - "error": true, - "withData": true + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Restricted mean OS time (RMST, landmarks)", + "headTitle": "Restricted mean OS time", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Restricted mean OS time (RMST, landmarks)", + "title": "Restricted mean OS time", + "maxCohorts": 6, + "why": null, + "whyBareStudy": "this study has no _MONTHS outcome column", + "defaults": { + "kind": "rmst" + }, + "defaultsBareStudy": { + "kind": "rmst" + } + }, + "drawsOwnWaiting": false, + "sub": "area under the KM curve to τ where every cohort has 10 at risk · OS", + "help": { + "title": "What is this?", + "text": "Answers “how many months, on average, did patients in each group live within the first τ months?” — the shaded area under each survival curve up to τ. The difference between two groups is the months gained or lost in that window, which reads plainly even when the curves cross or only separate late (as with immunotherapy), where a single hazard ratio can mislead. τ is where every group still has at least ten patients followed, unless it is set. Landmark panels ask the same question again among the patients still alive and followed at 6 or 12 months, restarting the clock there. The groups were not randomised, so a difference is a clue, not proof." + }, + "need": "local", + "preview": "no method record or golden test of the restricted means, their variances and the landmark sets against R survRM2 yet", + "isNew": true, + "figureTitle": "Restricted mean survival time — A vs B" }, - "card": { - "label": "Predictive biomarkers (gene × treatment interaction)", - "title": "Predictive biomarkers", - "genes": true, - "maxCohorts": 2, - "why": null, - "whyBareStudy": "this study records no treatment timeline events to define an exposure", - "defaults": { + "predictive": { + "menu": { + "label": "Predictive biomarkers (gene × treatment interaction)", + "why": "this study records no treatment timeline events to define an exposure", + "loading": false + }, + "menuBareStudy": { + "why": "this study records no treatment timeline events to define an exposure", + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { "kind": "predictive", "exposure": { "eventType": "Treatment", @@ -2792,9 +2808,12 @@ "PIK3CA", "APC", "BRAF" + ], + "cohorts": [ + "A" ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "predictive", "exposure": { "eventType": "Treatment", @@ -2807,665 +2826,736 @@ "KRAS", "TP53", "EGFR" + ], + "cohorts": [ + "A" ] - } - }, - "drawsOwnWaiting": false, - "sub": "gene × Treatment [SUBTYPE=Immuno] on OS · Cox per gene", - "help": { - "title": "What is this?", - "text": "Asks, gene by gene, whether a mutation changes how patients do on a particular treatment — not just whether it marks a better or worse outlook in general. For each gene it compares patients with and without the mutation twice: among those who received the treatment (upper marker) and among those who did not (lower marker). A gene whose markers sit far apart — say, much worse with the mutation only among the treated — may predict who benefits from that treatment; the right-hand column tests that difference. Treatment here was not assigned at random, and patients had to live long enough to receive it, so a finding is a lead to test properly, not proof." - }, - "need": "patients", - "preview": "no method record or golden test of the per-gene interaction fits yet", - "isNew": true, - "figureTitle": "Predictive biomarkers — A vs B" - }, - "cutpoint": { - "menu": { - "label": "Biomarker cut-point (best split of a number for OS)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no numeric clinical attribute to find a cut-point of", - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "cutpoint", - "attribute": "TMB_NONSYNONYMOUS", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "cutpoint", - "attribute": "TMB_NONSYNONYMOUS", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "cutpoint", - "attribute": "TMB_NONSYNONYMOUS", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Biomarker cut-point (best split of a number for OS)", - "headTitle": "Cut-point", - "drawsOwnHead": { - "error": true, - "withData": true + }, + "addedWithTwoCohorts": { + "kind": "predictive", + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "cohorts": [] + }, + "maxCohorts": 2, + "emptyHint": "It draws up to 2 cohorts, side by side.", + "title": "Predictive biomarkers (gene × treatment interaction)", + "headTitle": "Predictive biomarkers", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Predictive biomarkers (gene × treatment interaction)", + "title": "Predictive biomarkers", + "genes": true, + "maxCohorts": 2, + "why": null, + "whyBareStudy": "this study records no treatment timeline events to define an exposure", + "defaults": { + "kind": "predictive", + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ] + }, + "defaultsBareStudy": { + "kind": "predictive", + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "STK11", + "KEAP1", + "KRAS", + "TP53", + "EGFR" + ] + } + }, + "drawsOwnWaiting": false, + "sub": "gene × Treatment [SUBTYPE=Immuno] on OS · Cox per gene", + "help": { + "title": "What is this?", + "text": "Asks, gene by gene, whether a mutation changes how patients do on a particular treatment — not just whether it marks a better or worse outlook in general. For each gene it compares patients with and without the mutation twice: among those who received the treatment (upper marker) and among those who did not (lower marker). A gene whose markers sit far apart — say, much worse with the mutation only among the treated — may predict who benefits from that treatment; the right-hand column tests that difference. Treatment here was not assigned at random, and patients had to live long enough to receive it, so a finding is a lead to test properly, not proof." + }, + "need": "patients", + "preview": "no method record or golden test of the per-gene interaction fits yet", + "isNew": true, + "figureTitle": "Predictive biomarkers — A vs B" }, - "card": { - "label": "Biomarker cut-point (best split of a number for OS)", - "title": "Cut-point", - "maxCohorts": 1, - "why": null, - "whyBareStudy": "this study has no numeric clinical attribute to find a cut-point of", - "defaults": { + "cutpoint": { + "menu": { + "label": "Biomarker cut-point (best split of a number for OS)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study has no numeric clinical attribute to find a cut-point of", + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { "kind": "cutpoint", - "attribute": "TMB_NONSYNONYMOUS" + "attribute": "TMB_NONSYNONYMOUS", + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "cutpoint", - "attribute": "TMB_NONSYNONYMOUS" - } - }, - "drawsOwnWaiting": false, - "sub": "TMB_NONSYNONYMOUS on OS · best log-rank cut, 1000 permutations", - "help": { - "title": "What is this?", - "text": "Looks for the value of a measurement — such as tumour mutational burden — that best splits patients into a group that did better and a group that did worse. It tries every reasonable dividing line (from the lowest tenth of patients to the highest) and draws how strongly each one separates survival; the dot marks the best. Because trying many lines and keeping the best always finds something, the view also reshuffles the measurement among the patients a thousand times to see how good a 'best line' chance alone produces, and reports how surprising the real best line is once that search is accounted for. A line found this way needs checking in other patients before anyone relies on it." - }, - "need": "patients", - "preview": "no method record or golden test of the maximally selected log-rank scan against R maxstat yet", - "isNew": true, - "figureTitle": "Cut-point — A vs B" - }, - "hla": { - "menu": { - "label": "HLA class I genotype (homozygosity, HLA LOH and OS)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study records no HLA class I alleles", - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "hla", - "covariates": [ - "TMB_NONSYNONYMOUS" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "hla", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "hla", - "covariates": [ - "TMB_NONSYNONYMOUS" - ], - "cohorts": [] - }, - "maxCohorts": 3, - "emptyHint": "It draws up to 3 cohorts, side by side.", - "title": "HLA class I genotype (homozygosity, HLA LOH and OS)", - "headTitle": "HLA class I genotype", - "drawsOwnHead": { - "error": true, - "withData": true + "attribute": "TMB_NONSYNONYMOUS", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "cutpoint", + "attribute": "TMB_NONSYNONYMOUS", + "cohorts": [] + }, + "maxCohorts": 1, + "emptyHint": "It draws one cohort.", + "title": "Biomarker cut-point (best split of a number for OS)", + "headTitle": "Cut-point", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Biomarker cut-point (best split of a number for OS)", + "title": "Cut-point", + "maxCohorts": 1, + "why": null, + "whyBareStudy": "this study has no numeric clinical attribute to find a cut-point of", + "defaults": { + "kind": "cutpoint", + "attribute": "TMB_NONSYNONYMOUS" + }, + "defaultsBareStudy": { + "kind": "cutpoint", + "attribute": "TMB_NONSYNONYMOUS" + } + }, + "drawsOwnWaiting": false, + "sub": "TMB_NONSYNONYMOUS on OS · best log-rank cut, 1000 permutations", + "help": { + "title": "What is this?", + "text": "Looks for the value of a measurement — such as tumour mutational burden — that best splits patients into a group that did better and a group that did worse. It tries every reasonable dividing line (from the lowest tenth of patients to the highest) and draws how strongly each one separates survival; the dot marks the best. Because trying many lines and keeping the best always finds something, the view also reshuffles the measurement among the patients a thousand times to see how good a 'best line' chance alone produces, and reports how surprising the real best line is once that search is accounted for. A line found this way needs checking in other patients before anyone relies on it." + }, + "need": "patients", + "preview": "no method record or golden test of the maximally selected log-rank scan against R maxstat yet", + "isNew": true, + "figureTitle": "Cut-point — A vs B" }, - "card": { - "label": "HLA class I genotype (homozygosity, HLA LOH and OS)", - "title": "HLA class I genotype", - "maxCohorts": 3, - "why": null, - "whyBareStudy": "this study records no HLA class I alleles", - "defaults": { + "hla": { + "menu": { + "label": "HLA class I genotype (homozygosity, HLA LOH and OS)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study records no HLA class I alleles", + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { "kind": "hla", "covariates": [ "TMB_NONSYNONYMOUS" + ], + "cohorts": [ + "A" ] }, - "defaultsBareStudy": { - "kind": "hla" - } - }, - "drawsOwnWaiting": false, - "sub": "alleles at 2 fields · OS by homozygosity and by HLA LOH · adjusted for TMB_NONSYNONYMOUS", - "help": { - "title": "What is this?", - "text": "HLA class I molecules (HLA-A, -B and -C) show pieces of a tumour's proteins to the immune system. Everyone inherits two copies of each; a person who inherited two identical copies at a gene (homozygous) shows a narrower range of pieces, and a tumour can also lose one copy (HLA loss of heterozygosity) to hide. This shows how common each is in a cohort, and whether patients homozygous at any of the three genes, or whose tumour lost a copy, lived shorter or longer. In patients treated with immunotherapy, homozygosity has been linked to worse survival; in a cohort not limited to them the comparison describes prognosis, not response to treatment." - }, - "need": "patients", - "preview": "no method record or golden test of the homozygosity and LOH counts or the survival splits yet", - "isNew": true, - "figureTitle": "HLA class I genotype and OS — A vs B" - }, - "forest": { - "menu": { - "label": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "forest", - "exposure": { - "source": "alteration", - "gene": "TP53" - }, - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "forest", - "exposure": { - "source": "alteration", - "gene": "TP53" - }, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "forest", - "exposure": { - "source": "alteration", - "gene": "TP53" + "addedBareStudy": { + "kind": "hla", + "cohorts": [ + "A" + ] }, - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", - "headTitle": "Forest plot", - "drawsOwnHead": { - "error": true, - "withData": true + "addedWithTwoCohorts": { + "kind": "hla", + "covariates": [ + "TMB_NONSYNONYMOUS" + ], + "cohorts": [] + }, + "maxCohorts": 3, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "title": "HLA class I genotype (homozygosity, HLA LOH and OS)", + "headTitle": "HLA class I genotype", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "HLA class I genotype (homozygosity, HLA LOH and OS)", + "title": "HLA class I genotype", + "maxCohorts": 3, + "why": null, + "whyBareStudy": "this study records no HLA class I alleles", + "defaults": { + "kind": "hla", + "covariates": [ + "TMB_NONSYNONYMOUS" + ] + }, + "defaultsBareStudy": { + "kind": "hla" + } + }, + "drawsOwnWaiting": false, + "sub": "alleles at 2 fields · OS by homozygosity and by HLA LOH · adjusted for TMB_NONSYNONYMOUS", + "help": { + "title": "What is this?", + "text": "HLA class I molecules (HLA-A, -B and -C) show pieces of a tumour's proteins to the immune system. Everyone inherits two copies of each; a person who inherited two identical copies at a gene (homozygous) shows a narrower range of pieces, and a tumour can also lose one copy (HLA loss of heterozygosity) to hide. This shows how common each is in a cohort, and whether patients homozygous at any of the three genes, or whose tumour lost a copy, lived shorter or longer. In patients treated with immunotherapy, homozygosity has been linked to worse survival; in a cohort not limited to them the comparison describes prognosis, not response to treatment." + }, + "need": "patients", + "preview": "no method record or golden test of the homozygosity and LOH counts or the survival splits yet", + "isNew": true, + "figureTitle": "HLA class I genotype and OS — A vs B" }, - "card": { - "label": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", - "title": "Forest plot", - "maxCohorts": 6, - "why": null, - "whyBareStudy": null, - "defaults": { + "forest": { + "menu": { + "label": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { "kind": "forest", "exposure": { "source": "alteration", "gene": "TP53" - } + }, + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "forest", + "exposure": { + "source": "alteration", + "gene": "TP53" + }, + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedWithTwoCohorts": { "kind": "forest", "exposure": { "source": "alteration", "gene": "TP53" + }, + "cohorts": [] + }, + "maxCohorts": 6, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", + "headTitle": "Forest plot", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", + "title": "Forest plot", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "forest", + "exposure": { + "source": "alteration", + "gene": "TP53" + } + }, + "defaultsBareStudy": { + "kind": "forest", + "exposure": { + "source": "alteration", + "gene": "TP53" + } } - } - }, - "drawsOwnWaiting": false, - "sub": "TP53 mutated · OS hazard ratio per CANCER_TYPE, random-effects pooled", - "help": { - "title": "What is this?", - "text": "Asks whether one thing — a gene being mutated, a high tumour mutation burden — goes with shorter or longer survival, separately in each cancer type, and then combines those answers. Each row is one cancer type: its square is the hazard ratio (right of the dashed line at 1 means patients with the change died sooner; left means they lived longer) and its line how sure we can be. The diamonds at the bottom combine the rows into one overall estimate. When the rows disagree a lot (a high I²), the overall estimate is an average of different effects, so read the rows. This shows an association, not a cause." - }, - "need": "patients", - "preview": "no method record or golden test of the per-stratum Cox fits and the DerSimonian–Laird pooling yet", - "isNew": true, - "figureTitle": "Forest plot — A vs B" - }, - "adjusted": { - "menu": { - "label": "Adjusted enrichment (every gene, logistic on covariates)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "adjusted", - "cohorts": [] - }, - "addedBareStudy": { - "kind": "adjusted", - "cohorts": [] - }, - "addedWithTwoCohorts": { - "kind": "adjusted", - "cohorts": [] - }, - "maxCohorts": 2, - "emptyHint": "It compares exactly 2 cohorts.", - "title": "Adjusted enrichment (every gene, logistic on covariates)", - "headTitle": "Adjusted enrichment", - "drawsOwnHead": { - "error": true, - "withData": true + }, + "drawsOwnWaiting": false, + "sub": "TP53 mutated · OS hazard ratio per CANCER_TYPE, random-effects pooled", + "help": { + "title": "What is this?", + "text": "Asks whether one thing — a gene being mutated, a high tumour mutation burden — goes with shorter or longer survival, separately in each cancer type, and then combines those answers. Each row is one cancer type: its square is the hazard ratio (right of the dashed line at 1 means patients with the change died sooner; left means they lived longer) and its line how sure we can be. The diamonds at the bottom combine the rows into one overall estimate. When the rows disagree a lot (a high I²), the overall estimate is an average of different effects, so read the rows. This shows an association, not a cause." + }, + "need": "patients", + "preview": "no method record or golden test of the per-stratum Cox fits and the DerSimonian–Laird pooling yet", + "isNew": true, + "figureTitle": "Forest plot — A vs B" }, - "card": { - "label": "Adjusted enrichment (every gene, logistic on covariates)", - "title": "Adjusted enrichment", + "adjusted": { + "menu": { + "label": "Adjusted enrichment (every gene, logistic on covariates)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "added": { + "kind": "adjusted", + "cohorts": [] + }, + "addedBareStudy": { + "kind": "adjusted", + "cohorts": [] + }, + "addedWithTwoCohorts": { + "kind": "adjusted", + "cohorts": [] + }, "maxCohorts": 2, - "minCohorts": 2, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "adjusted" - }, - "defaultsBareStudy": { - "kind": "adjusted" - } - }, - "drawsOwnWaiting": false, - "sub": "mutations in every gene mutated in ≥5% · adjusted for nothing", - "help": { - "title": "What is this?", - "text": "Compares how often each commonly mutated gene is mutated in two groups of patients — say, colorectal cancers diagnosed before and after age 50 — after taking into account other things that differ between the groups and also change mutation rates, such as how many mutations a tumour carries overall. Each dot is a gene: right of centre means more often mutated in the first group, left in the second, and higher means more certain. The table shows each gene's raw difference beside the adjusted one, so you can see which differences were explained away. A difference here is a clue, not proof of a cause." - }, - "need": "patients", - "preview": "no method record or golden test of the per-gene logistic fits yet", - "isNew": true, - "figureTitle": "Adjusted enrichment — A vs B" - }, - "instability": { - "menu": { - "label": "Genomic instability (aneuploidy score, FGA, arm-level calls)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "instability", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "instability", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "instability", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Genomic instability (aneuploidy score, FGA, arm-level calls)", - "headTitle": "Genomic instability", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Genomic instability (aneuploidy score, FGA, arm-level calls)", - "title": "Genomic instability", + "emptyHint": "It compares exactly 2 cohorts.", + "title": "Adjusted enrichment (every gene, logistic on covariates)", + "headTitle": "Adjusted enrichment", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Adjusted enrichment (every gene, logistic on covariates)", + "title": "Adjusted enrichment", + "maxCohorts": 2, + "minCohorts": 2, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "adjusted" + }, + "defaultsBareStudy": { + "kind": "adjusted" + } + }, + "drawsOwnWaiting": false, + "sub": "mutations in every gene mutated in ≥5% · adjusted for nothing", + "help": { + "title": "What is this?", + "text": "Compares how often each commonly mutated gene is mutated in two groups of patients — say, colorectal cancers diagnosed before and after age 50 — after taking into account other things that differ between the groups and also change mutation rates, such as how many mutations a tumour carries overall. Each dot is a gene: right of centre means more often mutated in the first group, left in the second, and higher means more certain. The table shows each gene's raw difference beside the adjusted one, so you can see which differences were explained away. A difference here is a clue, not proof of a cause." + }, + "need": "patients", + "preview": "no method record or golden test of the per-gene logistic fits yet", + "isNew": true, + "figureTitle": "Adjusted enrichment — A vs B" + }, + "instability": { + "menu": { + "label": "Genomic instability (aneuploidy score, FGA, arm-level calls)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "instability", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "instability", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "instability", + "cohorts": [] + }, "maxCohorts": 6, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "instability" - }, - "defaultsBareStudy": { - "kind": "instability" - } - }, - "drawsOwnWaiting": false, - "sub": "from hg19 segments, ±0.2, arm called past 50% of its length", - "help": { - "title": "What is this?", - "text": "Cancer cells often gain or lose whole chromosome arms, and how much of a tumour's genome is out of balance is a measure of how unstable it is. For each group of tumours, this shows how many chromosome arms are typically gained or lost (the \"aneuploidy score\") and what share of the genome is altered, then which arms are gained (red) or lost (blue) most often — patterns that differ by cancer type, such as chromosome 20q gained and 18q lost in colorectal cancer." - }, - "need": "local", - "preview": "no method record, and no check of the arm calls and aneuploidy scores against Taylor et al.'s published values yet", - "isNew": true, - "figureTitle": "Genomic instability — aneuploidy score and FGA — A vs B" - }, - "actionability": { - "menu": { - "label": "Clinical actionability (OncoKB levels, primary vs metastasis)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "actionability", - "groupBy": "CANCER_TYPE", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "actionability", - "groupBy": "CANCER_TYPE", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "actionability", - "groupBy": "CANCER_TYPE", - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Clinical actionability (OncoKB levels, primary vs metastasis)", - "headTitle": "Clinical actionability", - "drawsOwnHead": { - "error": true, - "withData": true + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Genomic instability (aneuploidy score, FGA, arm-level calls)", + "headTitle": "Genomic instability", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Genomic instability (aneuploidy score, FGA, arm-level calls)", + "title": "Genomic instability", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "instability" + }, + "defaultsBareStudy": { + "kind": "instability" + } + }, + "drawsOwnWaiting": false, + "sub": "from hg19 segments, ±0.2, arm called past 50% of its length", + "help": { + "title": "What is this?", + "text": "Cancer cells often gain or lose whole chromosome arms, and how much of a tumour's genome is out of balance is a measure of how unstable it is. For each group of tumours, this shows how many chromosome arms are typically gained or lost (the \"aneuploidy score\") and what share of the genome is altered, then which arms are gained (red) or lost (blue) most often — patterns that differ by cancer type, such as chromosome 20q gained and 18q lost in colorectal cancer." + }, + "need": "local", + "preview": "no method record, and no check of the arm calls and aneuploidy scores against Taylor et al.'s published values yet", + "isNew": true, + "figureTitle": "Genomic instability — aneuploidy score and FGA — A vs B" }, - "card": { - "label": "Clinical actionability (OncoKB levels, primary vs metastasis)", - "title": "Clinical actionability", - "maxCohorts": 4, - "why": null, - "whyBareStudy": null, - "defaults": { + "actionability": { + "menu": { + "label": "Clinical actionability (OncoKB levels, primary vs metastasis)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { "kind": "actionability", - "groupBy": "CANCER_TYPE" + "groupBy": "CANCER_TYPE", + "cohorts": [ + "A" + ] }, - "defaultsBareStudy": { + "addedBareStudy": { "kind": "actionability", - "groupBy": "CANCER_TYPE" - } - }, - "drawsOwnWaiting": false, - "sub": "highest OncoKB level per patient, any cancer type · by CANCER_TYPE · SAMPLE_TYPE concordance", - "help": { - "title": "What is this?", - "text": "For each patient, finds the strongest evidence OncoKB lists for a drug that targets one of their tumour's genetic changes — Level 1 means an FDA-approved drug for that change, down to Level 4 for early biological evidence — and shows what share of patients reach each level, overall or per cancer type. The levels here are the best OncoKB gives the change in ANY cancer, so a change that is only actionable in another cancer still counts: read the bars as an upper bound. The table below compares, for patients sequenced twice, the level found in their primary tumour against their metastasis." - }, - "need": "local", - "preview": "no method record of the per-patient highest-level counts yet", - "isNew": true, - "figureTitle": "Clinical actionability — A vs B" - }, - "drivernegative": { - "menu": { - "label": "Driver-negative analysis (genes enriched without a canonical driver)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "drivernegative", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "drivernegative", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "drivernegative", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Driver-negative analysis (genes enriched without a canonical driver)", - "headTitle": "Driver-negative analysis", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Driver-negative analysis (genes enriched without a canonical driver)", - "title": "Driver-negative analysis", + "groupBy": "CANCER_TYPE", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "actionability", + "groupBy": "CANCER_TYPE", + "cohorts": [] + }, + "maxCohorts": 4, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Clinical actionability (OncoKB levels, primary vs metastasis)", + "headTitle": "Clinical actionability", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Clinical actionability (OncoKB levels, primary vs metastasis)", + "title": "Clinical actionability", + "maxCohorts": 4, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "actionability", + "groupBy": "CANCER_TYPE" + }, + "defaultsBareStudy": { + "kind": "actionability", + "groupBy": "CANCER_TYPE" + } + }, + "drawsOwnWaiting": false, + "sub": "highest OncoKB level per patient, any cancer type · by CANCER_TYPE · SAMPLE_TYPE concordance", + "help": { + "title": "What is this?", + "text": "For each patient, finds the strongest evidence OncoKB lists for a drug that targets one of their tumour's genetic changes — Level 1 means an FDA-approved drug for that change, down to Level 4 for early biological evidence — and shows what share of patients reach each level, overall or per cancer type. The levels here are the best OncoKB gives the change in ANY cancer, so a change that is only actionable in another cancer still counts: read the bars as an upper bound. The table below compares, for patients sequenced twice, the level found in their primary tumour against their metastasis." + }, + "need": "local", + "preview": "no method record of the per-patient highest-level counts yet", + "isNew": true, + "figureTitle": "Clinical actionability — A vs B" + }, + "drivernegative": { + "menu": { + "label": "Driver-negative analysis (genes enriched without a canonical driver)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "drivernegative", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "drivernegative", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "drivernegative", + "cohorts": [] + }, "maxCohorts": 1, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "drivernegative" - }, - "defaultsBareStudy": { - "kind": "drivernegative" - } - }, - "drawsOwnWaiting": false, - "sub": "mutations without vs with a driver in the NSCLC set, per patient", - "help": { - "title": "What is this?", - "text": "Splits one group of patients — say, lung cancers — into those whose tumour carries one of the well-known, usually treatable cancer-driving changes (EGFR, KRAS, ALK and the rest of the list) and those whose tumour carries none, and then asks which other genes are changed more often in the second group. Those genes are candidates for what drives the tumours without a known driver. Patients whose test did not cover every gene on the list are kept apart, since we cannot tell whether they have a driver. The bar shows how big each group is; in the chart, genes to the right are more common without a known driver." - }, - "need": "local", - "preview": "no method record or golden test of the group split and the per-gene Fisher tests yet", - "isNew": true, - "figureTitle": "Driver-negative analysis — A vs B" - }, - "organotropism": { - "menu": { - "label": "Metastatic organotropism (genes × metastatic sites, within cancer types)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)", - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "organotropism", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "organotropism", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "organotropism", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Metastatic organotropism (genes × metastatic sites, within cancer types)", - "headTitle": "Metastatic organotropism", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Metastatic organotropism (genes × metastatic sites, within cancer types)", - "title": "Metastatic organotropism", + "emptyHint": "It draws one cohort.", + "title": "Driver-negative analysis (genes enriched without a canonical driver)", + "headTitle": "Driver-negative analysis", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Driver-negative analysis (genes enriched without a canonical driver)", + "title": "Driver-negative analysis", + "maxCohorts": 1, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "drivernegative" + }, + "defaultsBareStudy": { + "kind": "drivernegative" + } + }, + "drawsOwnWaiting": false, + "sub": "mutations without vs with a driver in the NSCLC set, per patient", + "help": { + "title": "What is this?", + "text": "Splits one group of patients — say, lung cancers — into those whose tumour carries one of the well-known, usually treatable cancer-driving changes (EGFR, KRAS, ALK and the rest of the list) and those whose tumour carries none, and then asks which other genes are changed more often in the second group. Those genes are candidates for what drives the tumours without a known driver. Patients whose test did not cover every gene on the list are kept apart, since we cannot tell whether they have a driver. The bar shows how big each group is; in the chart, genes to the right are more common without a known driver." + }, + "need": "local", + "preview": "no method record or golden test of the group split and the per-gene Fisher tests yet", + "isNew": true, + "figureTitle": "Driver-negative analysis — A vs B" + }, + "organotropism": { + "menu": { + "label": "Metastatic organotropism (genes × metastatic sites, within cancer types)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)", + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "organotropism", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "organotropism", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "organotropism", + "cohorts": [] + }, "maxCohorts": 1, - "cohortInHeader": true, - "why": null, - "whyBareStudy": "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)", - "defaults": { - "kind": "organotropism" - }, - "defaultsBareStudy": { - "kind": "organotropism" - } - }, - "drawsOwnWaiting": false, - "sub": "driver mutations in the 12 most altered genes × metastatic sites from the study's site source, within CANCER_TYPE", - "help": { - "title": "What is this?", - "text": "Asks, for each organ a cancer spread to — brain, liver, bone and the rest — whether patients whose tumour carries a change in a given gene spread there more or less often than patients whose tumour does not, comparing patients within the same cancer type. Red squares mean the gene's change goes with spread to that organ, blue that it goes with spreading there less; outlined squares are the ones unlikely to be chance after testing many pairs. Where the study dates each site, the curve below shows how quickly patients with and without one gene's change develop spread to one organ, counting deaths as a separate outcome. Sites are only known where scans or biopsies found them." - }, - "need": "local", - "preview": "no method record or golden test of the per-cell Cochran–Mantel–Haenszel tests or the site incidence yet", - "isNew": true, - "figureTitle": "Organotropism — A vs B" - }, - "molsubtype": { - "menu": { - "label": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "molsubtype", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "molsubtype", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "molsubtype", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", - "headTitle": "Molecular subtypes", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", - "title": "Molecular subtypes", + "emptyHint": "It draws one cohort.", + "title": "Metastatic organotropism (genes × metastatic sites, within cancer types)", + "headTitle": "Metastatic organotropism", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Metastatic organotropism (genes × metastatic sites, within cancer types)", + "title": "Metastatic organotropism", + "maxCohorts": 1, + "cohortInHeader": true, + "why": null, + "whyBareStudy": "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)", + "defaults": { + "kind": "organotropism" + }, + "defaultsBareStudy": { + "kind": "organotropism" + } + }, + "drawsOwnWaiting": false, + "sub": "driver mutations in the 12 most altered genes × metastatic sites from the study's site source, within CANCER_TYPE", + "help": { + "title": "What is this?", + "text": "Asks, for each organ a cancer spread to — brain, liver, bone and the rest — whether patients whose tumour carries a change in a given gene spread there more or less often than patients whose tumour does not, comparing patients within the same cancer type. Red squares mean the gene's change goes with spread to that organ, blue that it goes with spreading there less; outlined squares are the ones unlikely to be chance after testing many pairs. Where the study dates each site, the curve below shows how quickly patients with and without one gene's change develop spread to one organ, counting deaths as a separate outcome. Sites are only known where scans or biopsies found them." + }, + "need": "local", + "preview": "no method record or golden test of the per-cell Cochran–Mantel–Haenszel tests or the site incidence yet", + "isNew": true, + "figureTitle": "Organotropism — A vs B" + }, + "molsubtype": { + "menu": { + "label": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "molsubtype", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "molsubtype", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "molsubtype", + "cohorts": [] + }, "maxCohorts": 6, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "molsubtype" - }, - "defaultsBareStudy": { - "kind": "molsubtype" - } - }, - "drawsOwnWaiting": false, - "sub": "endometrial molecular classification per patient (POLE, MMRd, p53abn, NSMP) · OS by subtype", - "help": { - "title": "What is this?", - "text": "Sorts each patient's endometrial cancer into the four molecular groups doctors now use alongside the microscope: POLE-ultramutated (a tumour with a broken DNA-copying enzyme, which does very well), mismatch-repair deficient (a tumour that cannot fix certain DNA errors, read here from its MSI test), p53-abnormal (the group that does worst), and no specific molecular profile. The groups are assigned in that order, as the WHO guideline does. Patients whose test could not tell — no MSI result, or a gene panel that missed POLE or TP53 — are counted as not classifiable rather than guessed. The bars show each group's share, overall and by the attribute you pick; the curves show survival in each group." - }, - "need": "local", - "preview": "no method record or golden test of the per-patient classification, the chi-squared tests or the survival split yet", - "isNew": true, - "figureTitle": "Molecular subtypes — A vs B" - }, - "germline": { - "menu": { - "label": "Germline carriers (and their somatic second hits)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "germline", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "germline", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "germline", - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Germline carriers (and their somatic second hits)", - "headTitle": "Germline carriers", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Germline carriers (and their somatic second hits)", - "title": "Germline carriers", + "emptyHint": "It draws up to 6 cohorts, side by side.", + "title": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", + "headTitle": "Molecular subtypes", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", + "title": "Molecular subtypes", + "maxCohorts": 6, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "molsubtype" + }, + "defaultsBareStudy": { + "kind": "molsubtype" + } + }, + "drawsOwnWaiting": false, + "sub": "endometrial molecular classification per patient (POLE, MMRd, p53abn, NSMP) · OS by subtype", + "help": { + "title": "What is this?", + "text": "Sorts each patient's endometrial cancer into the four molecular groups doctors now use alongside the microscope: POLE-ultramutated (a tumour with a broken DNA-copying enzyme, which does very well), mismatch-repair deficient (a tumour that cannot fix certain DNA errors, read here from its MSI test), p53-abnormal (the group that does worst), and no specific molecular profile. The groups are assigned in that order, as the WHO guideline does. Patients whose test could not tell — no MSI result, or a gene panel that missed POLE or TP53 — are counted as not classifiable rather than guessed. The bars show each group's share, overall and by the attribute you pick; the curves show survival in each group." + }, + "need": "local", + "preview": "no method record or golden test of the per-patient classification, the chi-squared tests or the survival split yet", + "isNew": true, + "figureTitle": "Molecular subtypes — A vs B" + }, + "germline": { + "menu": { + "label": "Germline carriers (and their somatic second hits)", + "why": null, + "loading": false + }, + "menuBareStudy": { + "why": null, + "loading": false + }, + "menuAggregateOnly": { + "why": null + }, + "added": { + "kind": "germline", + "cohorts": [ + "A" + ] + }, + "addedBareStudy": { + "kind": "germline", + "cohorts": [ + "A" + ] + }, + "addedWithTwoCohorts": { + "kind": "germline", + "cohorts": [] + }, "maxCohorts": 4, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "germline" - }, - "defaultsBareStudy": { - "kind": "germline" - } - }, - "drawsOwnWaiting": false, - "sub": "germline carriers per patient sequenced, every gene with ≥3 carriers · somatic second hit vs non-carriers", - "help": { - "title": "What is this?", - "text": "Some people are born with a faulty copy of a cancer gene such as BRCA2 in every cell. For each such gene this counts those patients (carriers) among everyone whose tumour was sequenced for it, and asks whether their tumour knocked out the remaining good copy — a second hit — by a further mutation or by deleting the gene, compared with how often the same gene is hit in patients born without a faulty copy. The commonest second hit, losing the good copy outright (loss of heterozygosity), needs data this view does not read, so the second-hit share is a minimum. Optionally the curves compare survival of carriers and non-carriers for one gene." - }, - "need": "local", - "preview": "no method record or golden test of the carrier and second-hit counts or the Fisher tests yet", - "isNew": true, - "figureTitle": "Germline carriers and second hits — A vs B" + "emptyHint": "It draws up to 4 cohorts, side by side.", + "title": "Germline carriers (and their somatic second hits)", + "headTitle": "Germline carriers", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "card": { + "label": "Germline carriers (and their somatic second hits)", + "title": "Germline carriers", + "maxCohorts": 4, + "why": null, + "whyBareStudy": null, + "defaults": { + "kind": "germline" + }, + "defaultsBareStudy": { + "kind": "germline" + } + }, + "drawsOwnWaiting": false, + "sub": "germline carriers per patient sequenced, every gene with ≥3 carriers · somatic second hit vs non-carriers", + "help": { + "title": "What is this?", + "text": "Some people are born with a faulty copy of a cancer gene such as BRCA2 in every cell. For each such gene this counts those patients (carriers) among everyone whose tumour was sequenced for it, and asks whether their tumour knocked out the remaining good copy — a second hit — by a further mutation or by deleting the gene, compared with how often the same gene is hit in patients born without a faulty copy. The commonest second hit, losing the good copy outright (loss of heterozygosity), needs data this view does not read, so the second-hit share is a minimum. Optionally the curves compare survival of carriers and non-carriers for one gene." + }, + "need": "local", + "preview": "no method record or golden test of the carrier and second-hit counts or the Fisher tests yet", + "isNew": true, + "figureTitle": "Germline carriers and second hits — A vs B" + } } } diff --git a/test/view-kinds.test.ts b/test/view-kinds.test.ts index e16610b..9cdbb00 100644 --- a/test/view-kinds.test.ts +++ b/test/view-kinds.test.ts @@ -90,7 +90,7 @@ function facts() { figureTitle: viewTitle({ kind, cohorts: ["A", "B"] } as never), }; } - return out; + return { menuOrder: rich.opts.VIEW_KINDS.map((k) => k.kind), cardOrder: Object.keys(kindCards.KIND_CARDS), kinds: out }; } test("what every view kind tells the page before data arrives", () => { From 649d928fc428db9a4065abed442ae38ea996f5d9 Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Fri, 23 Oct 2026 22:07:52 +0000 Subject: [PATCH 5/7] kinds.json lists keys in order, so it pins what each kind says rather than how its source is laid out Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CuBjiBEjYispDSEYDZDWM1 --- test/snapshots/kinds.json | 4816 ++++++++++++++++++------------------- test/view-kinds.test.ts | 6 +- 2 files changed, 2413 insertions(+), 2409 deletions(-) diff --git a/test/snapshots/kinds.json b/test/snapshots/kinds.json index 5481cf5..6954108 100644 --- a/test/snapshots/kinds.json +++ b/test/snapshots/kinds.json @@ -1,54 +1,4 @@ { - "menuOrder": [ - "compare", - "survival", - "cox", - "logistic", - "oncoprint", - "hotspots", - "plot", - "cooccurrence", - "clinical", - "coexpression", - "course", - "sequence", - "attrition", - "genome", - "pathways", - "enrichment", - "assays", - "composition", - "table", - "mutations", - "timeline", - "alterations", - "correlates", - "overlap", - "cancertypes", - "labs", - "incidence", - "hla", - "cutpoint", - "predictive", - "rmst", - "multistate", - "txendpoints", - "clonality", - "paired", - "acquired", - "instability", - "exclusivity", - "ctdna", - "riskmodel", - "drivernegative", - "organotropism", - "germline", - "molsubtype", - "actionability", - "adjusted", - "forest", - "diffexpr" - ], "cardOrder": [ "attrition", "genome", @@ -88,570 +38,486 @@ "diffexpr" ], "kinds": { - "compare": { - "menu": { - "label": "Compare genes", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, + "acquired": { "added": { - "kind": "compare", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF", - "ERBB2" - ], + "anchor": { + "eventType": "Treatment" + }, "cohorts": [ "A" - ] + ], + "kind": "acquired" }, "addedBareStudy": { - "kind": "compare", - "genes": [], + "anchor": { + "eventType": "Treatment" + }, "cohorts": [ "A" - ] + ], + "kind": "acquired" }, "addedWithTwoCohorts": { - "kind": "compare", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF", - "ERBB2" - ], - "cohorts": [] + "anchor": { + "eventType": "Treatment" + }, + "cohorts": [], + "kind": "acquired" + }, + "card": { + "defaults": { + "anchor": { + "eventType": "Treatment" + }, + "kind": "acquired" + }, + "defaultsBareStudy": { + "anchor": { + "eventType": "Treatment" + }, + "kind": "acquired" + }, + "genes": true, + "label": "Acquired under therapy (samples before vs after a treatment start)", + "maxCohorts": 3, + "title": "Acquired under therapy", + "why": null, + "whyBareStudy": "this study records no treatment timeline events to anchor on" }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Compare genes", - "headTitle": "Compare", "drawsOwnHead": { - "error": false, + "error": true, "withData": true }, - "card": null, "drawsOwnWaiting": false, - "sub": null, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "figureTitle": "Acquired under therapy — A vs B", + "headTitle": "Acquired under therapy", "help": { - "title": "What is this?", - "text": "Compares how often specific genes are altered — mutated, gained, or lost — between two or more groups of patients. If a gene is altered much more often in one group than another, that's a clue it might be connected to whatever makes the groups different, like a cancer subtype or how patients respond to treatment." + "text": "For patients who had a tumour sample tested before a treatment started and another one afterwards, shows gene by gene how many had a change only in the later sample (acquired, to the right) or only in the earlier one (lost, to the left). Genes that keep turning up only after treatment can point to how tumours resist it. It cannot say the treatment caused the change: second biopsies are usually taken when the cancer grows again, and a change can simply have been missed the first time.", + "title": "What is this?" }, - "need": null, - "preview": null, - "isNew": false, - "figureTitle": "Alteration frequencies — A vs B" - }, - "survival": { + "isNew": true, + "maxCohorts": 3, "menu": { - "label": "Survival", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Acquired under therapy (samples before vs after a treatment start)", + "loading": false, + "why": "this study records no treatment timeline events to anchor on" }, "menuAggregateOnly": { - "why": null + "why": "this study records no treatment timeline events to anchor on" + }, + "menuBareStudy": { + "loading": false, + "why": "this study records no treatment timeline events to anchor on" }, + "need": "local", + "preview": "no method record or golden test of the before/after pairing and McNemar counts yet", + "sub": "last sample before the first Treatment vs the first ≥30 days after, any alteration, every gene scanned", + "title": "Acquired under therapy (samples before vs after a treatment start)" + }, + "actionability": { "added": { - "kind": "survival", "cohorts": [ "A" - ] + ], + "groupBy": "CANCER_TYPE", + "kind": "actionability" }, "addedBareStudy": { - "kind": "survival", "cohorts": [ "A" - ] + ], + "groupBy": "CANCER_TYPE", + "kind": "actionability" }, "addedWithTwoCohorts": { - "kind": "survival", - "cohorts": [] + "cohorts": [], + "groupBy": "CANCER_TYPE", + "kind": "actionability" + }, + "card": { + "defaults": { + "groupBy": "CANCER_TYPE", + "kind": "actionability" + }, + "defaultsBareStudy": { + "groupBy": "CANCER_TYPE", + "kind": "actionability" + }, + "label": "Clinical actionability (OncoKB levels, primary vs metastasis)", + "maxCohorts": 4, + "title": "Clinical actionability", + "why": null, + "whyBareStudy": null }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Survival", - "headTitle": "Survival", "drawsOwnHead": { - "error": false, + "error": true, "withData": true }, - "card": null, "drawsOwnWaiting": false, - "sub": null, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "figureTitle": "Clinical actionability — A vs B", + "headTitle": "Clinical actionability", "help": { - "title": "What is this?", - "text": "Shows what share of patients in each group were still alive (or still free of disease progression) at each point in time, drawn as a curve that steps downward every time a patient's outcome is recorded. Comparing the curves for two or more groups answers: did one group tend to do better than the other, and if so, is the difference too large to be chance?" + "text": "For each patient, finds the strongest evidence OncoKB lists for a drug that targets one of their tumour's genetic changes — Level 1 means an FDA-approved drug for that change, down to Level 4 for early biological evidence — and shows what share of patients reach each level, overall or per cancer type. The levels here are the best OncoKB gives the change in ANY cancer, so a change that is only actionable in another cancer still counts: read the bars as an upper bound. The table below compares, for patients sequenced twice, the level found in their primary tumour against their metastasis.", + "title": "What is this?" }, - "need": null, - "preview": null, - "isNew": false, - "figureTitle": "OS survival — A vs B" - }, - "cox": { + "isNew": true, + "maxCohorts": 4, "menu": { - "label": "Cox regression (forest plot)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Clinical actionability (OncoKB levels, primary vs metastasis)", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + "why": null + }, + "menuBareStudy": { + "loading": false, + "why": null }, + "need": "local", + "preview": "no method record of the per-patient highest-level counts yet", + "sub": "highest OncoKB level per patient, any cancer type · by CANCER_TYPE · SAMPLE_TYPE concordance", + "title": "Clinical actionability (OncoKB levels, primary vs metastasis)" + }, + "adjusted": { "added": { - "kind": "cox", - "covariates": [ - { - "source": "alteration", - "gene": "TP53", - "by": "mutated" - } - ], - "cohorts": [ - "A" - ] + "cohorts": [], + "kind": "adjusted" }, "addedBareStudy": { - "kind": "cox", - "covariates": [ - { - "source": "alteration", - "gene": "TP53", - "by": "mutated" - } - ], - "cohorts": [ - "A" - ] + "cohorts": [], + "kind": "adjusted" }, "addedWithTwoCohorts": { - "kind": "cox", - "covariates": [ - { - "source": "alteration", - "gene": "TP53", - "by": "mutated" - } - ], - "cohorts": [] + "cohorts": [], + "kind": "adjusted" + }, + "card": { + "defaults": { + "kind": "adjusted" + }, + "defaultsBareStudy": { + "kind": "adjusted" + }, + "label": "Adjusted enrichment (every gene, logistic on covariates)", + "maxCohorts": 2, + "minCohorts": 2, + "title": "Adjusted enrichment", + "why": null, + "whyBareStudy": null }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Cox regression (forest plot)", - "headTitle": "Cox regression", "drawsOwnHead": { "error": true, "withData": true }, - "card": null, "drawsOwnWaiting": false, - "sub": null, + "emptyHint": "It compares exactly 2 cohorts.", + "figureTitle": "Adjusted enrichment — A vs B", + "headTitle": "Adjusted enrichment", "help": { - "title": "What is this?", - "text": "A more advanced version of the survival comparison above, which can weigh several factors at once — for example, a patient's age, a specific gene, and how far the disease had spread — to estimate how much each one changes risk on its own, holding the others equal. Each result is a \"hazard ratio\": above 1 means that factor is linked to worse outcomes, below 1 means it's linked to better ones." + "text": "Compares how often each commonly mutated gene is mutated in two groups of patients — say, colorectal cancers diagnosed before and after age 50 — after taking into account other things that differ between the groups and also change mutation rates, such as how many mutations a tumour carries overall. Each dot is a gene: right of centre means more often mutated in the first group, left in the second, and higher means more certain. The table shows each gene's raw difference beside the adjusted one, so you can see which differences were explained away. A difference here is a clue, not proof of a cause.", + "title": "What is this?" }, - "need": "patients", - "preview": null, "isNew": true, - "figureTitle": "Cox regression — OS — A vs B" - }, - "logistic": { + "maxCohorts": 2, "menu": { - "label": "Logistic regression (odds ratios)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Adjusted enrichment (every gene, logistic on covariates)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "patients", + "preview": "no method record or golden test of the per-gene logistic fits yet", + "sub": "mutations in every gene mutated in ≥5% · adjusted for nothing", + "title": "Adjusted enrichment (every gene, logistic on covariates)" + }, + "alterations": { "added": { - "kind": "logistic", - "outcome": { - "source": "alteration", - "gene": "TP53", - "by": "mutated" - }, - "outcomeEvent": "Mutated", - "covariates": [ - { - "source": "clinical", - "attribute": "TMB_NONSYNONYMOUS" - } - ], "cohorts": [ "A" - ] + ], + "kind": "alterations" }, "addedBareStudy": { - "kind": "logistic", - "outcome": { - "source": "alteration", - "gene": "TP53", - "by": "mutated" - }, - "outcomeEvent": "Mutated", - "covariates": [ - { - "source": "clinical", - "attribute": "SEX" - } - ], "cohorts": [ "A" - ] + ], + "kind": "alterations" }, "addedWithTwoCohorts": { - "kind": "logistic", - "outcome": { - "source": "alteration", - "gene": "TP53", - "by": "mutated" + "cohorts": [], + "kind": "alterations" + }, + "card": { + "cohortInHeader": true, + "defaults": { + "kind": "alterations" }, - "outcomeEvent": "Mutated", - "covariates": [ - { - "source": "clinical", - "attribute": "TMB_NONSYNONYMOUS" - } - ], - "cohorts": [] + "defaultsBareStudy": { + "kind": "alterations" + }, + "label": "Patient alterations (mutations, copy number, SVs; one patient at a time)", + "maxCohorts": 1, + "title": "Patient alterations", + "why": null, + "whyBareStudy": null }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Logistic regression (odds ratios)", - "headTitle": "Logistic regression", "drawsOwnHead": { "error": true, "withData": true }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, + "drawsOwnWaiting": true, + "emptyHint": "It draws one cohort.", + "figureTitle": "Patient alterations — A vs B", + "headTitle": "Patient alterations", "help": { - "title": "What is this?", - "text": "Estimates how strongly different factors are linked to a yes-or-no outcome — for example, whether a patient responded to a treatment — while accounting for several factors together. Each result is an \"odds ratio\": above 1 means that factor makes the outcome more likely, below 1 means less likely." + "text": "Lists one patient's genetic changes as cBioPortal's patient page does, in three tables: mutations (the gene, the protein change, whether OncoKB considers it cancer-causing, and how much of the tumour carries it), copy-number changes (genes amplified or deleted outright) and structural variants (fusions and rearrangements). A change found in several of the patient's samples is one row, with a numbered marker for each sample. Cohort % says how common the same kind of change in that gene is across the study. Each table can be searched, filtered, sorted and downloaded on its own. Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one.", + "title": "What is this?" }, - "need": "patients", - "preview": null, - "isNew": true, - "figureTitle": "Logistic regression — A vs B" - }, - "oncoprint": { + "isNew": false, + "maxCohorts": 1, "menu": { - "label": "Oncoprint", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Patient alterations (mutations, copy number, SVs; one patient at a time)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." }, - "added": { - "kind": "oncoprint", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1" - ], - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "oncoprint", - "genes": [], - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "oncoprint", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1" - ], - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Oncoprint", - "headTitle": "Oncoprint", - "drawsOwnHead": { - "error": false, - "withData": true - }, - "card": null, - "drawsOwnWaiting": false, - "sub": null, - "help": { - "title": "What is this?", - "text": "A grid with one column per patient and one row per gene, color-coded to show what kind of genetic change (if any) each patient has in each gene. It's a fast way to see patterns across many patients and genes at once — for example, whether certain alterations tend to show up in the same patients, or never together." - }, - "need": "patients", - "preview": null, - "isNew": false, - "figureTitle": "Oncoprint — A vs B" - }, - "hotspots": { - "menu": { - "label": "Lollipop (hotspots)", - "why": null, - "loading": false - }, "menuBareStudy": { - "why": null, - "loading": false - }, - "menuAggregateOnly": { + "loading": false, "why": null }, + "need": "alterations", + "preview": null, + "sub": "(element)", + "title": "Patient alterations (mutations, copy number, SVs; one patient at a time)" + }, + "assays": { "added": { - "kind": "hotspots", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11" - ], - "minCount": 1, - "cohorts": [ - "A" - ] + "cohorts": [], + "kind": "assays", + "profileType": "mutational_signatures_contribution_v2" }, "addedBareStudy": { - "kind": "hotspots", - "genes": [], - "minCount": 1, - "cohorts": [ - "A" - ] + "cohorts": [], + "kind": "assays", + "profileType": "" }, "addedWithTwoCohorts": { - "kind": "hotspots", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11" - ], - "minCount": 1, - "cohorts": [] + "cohorts": [], + "kind": "assays", + "profileType": "mutational_signatures_contribution_v2" + }, + "card": { + "defaults": { + "kind": "assays", + "profileType": "mutational_signatures_contribution_v2" + }, + "defaultsBareStudy": { + "kind": "assays", + "profileType": "" + }, + "label": "Generic assays (signatures, arm-level CNA, …)", + "maxCohorts": 6, + "minCohorts": 2, + "title": "Generic assays", + "why": null, + "whyBareStudy": "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)" }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Lollipop (hotspots)", - "headTitle": "Mutations", "drawsOwnHead": { - "error": false, + "error": true, "withData": true }, - "card": null, "drawsOwnWaiting": false, - "sub": null, + "emptyHint": "It compares 2 to 6 cohorts.", + "figureTitle": "Generic assays — A vs B", + "headTitle": "Generic assays", "help": { - "title": "What is this?", - "text": "Shows exactly where along a gene's protein its mutations land, drawn as a lollipop chart — a taller stem means more patients had a mutation at that exact spot. Mutations clustering tightly at one or two spots, rather than scattering evenly, usually points to a location that matters for how the protein works." + "text": "Compares two or more groups on every measurement in one kind of extra lab data the study recorded — such as which mutational signatures (like sun damage or smoking) shaped each tumour, or which chromosome arms were gained or lost. The measurements at the top differ most clearly between the groups; each one shows its average, or its mix of results, in every group, so a difference that is clear but small reads as small.", + "title": "What is this?" }, - "need": null, - "preview": null, "isNew": false, - "figureTitle": "Recurrent protein changes — A vs B" - }, - "plot": { + "maxCohorts": 6, "menu": { - "label": "Plot", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study declares no clinical attributes to plot", - "loading": false + "label": "Generic assays (signatures, arm-level CNA, …)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)" + }, + "need": "local", + "preview": null, + "sub": "mutational_signatures_contribution_v2, per sample", + "title": "Generic assays (signatures, arm-level CNA, …)" + }, + "attrition": { "added": { - "kind": "plot", - "x": { - "source": "clinical", - "attribute": "TMB_NONSYNONYMOUS" - }, - "y": { - "source": "clinical", - "attribute": "AGE" - }, - "unit": "sample", "cohorts": [ "A" - ] + ], + "kind": "attrition" }, "addedBareStudy": { - "kind": "plot", - "unit": "sample", - "x": { - "source": "alteration", - "gene": "TP53", - "by": "mutationType" - }, - "y": { - "source": "alteration", - "gene": "KRAS", - "by": "mutated" - }, "cohorts": [ "A" - ] + ], + "kind": "attrition" }, "addedWithTwoCohorts": { - "kind": "plot", - "x": { - "source": "clinical", - "attribute": "TMB_NONSYNONYMOUS" + "cohorts": [], + "kind": "attrition" + }, + "card": { + "defaults": { + "kind": "attrition" }, - "y": { - "source": "clinical", - "attribute": "AGE" + "defaultsBareStudy": { + "kind": "attrition" }, - "unit": "sample", - "cohorts": [] + "label": "Cohort attrition (CONSORT)", + "maxCohorts": 6, + "title": "Cohort attrition", + "why": null, + "whyBareStudy": null }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Plot", - "headTitle": "Plot", "drawsOwnHead": { "error": true, "withData": true }, - "card": null, "drawsOwnWaiting": false, - "sub": null, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "figureTitle": "Cohort attrition — A vs B", + "headTitle": "Cohort attrition", "help": { - "title": "What is this?", - "text": "Plots two measurements against each other for the same patients or samples — for example, a gene's activity level against a patient's age — so you can see whether they tend to rise and fall together, split into distinct clusters, or show no relationship at all." + "text": "Shows how each group of patients was narrowed down, one criterion at a time: how many patients the study started with, how many were left after each rule in the group's definition, and how many each rule removed. It lets a reader see who was left out before trusting any result drawn from the patients who remain.", + "title": "What is this?" }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "plot — A vs B" - }, - "cooccurrence": { + "isNew": true, + "maxCohorts": 6, "menu": { - "label": "Co-occurrence", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Cohort attrition (CONSORT)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": null, + "preview": null, + "sub": "patients left after each clause, in order", + "title": "Cohort attrition (CONSORT)" + }, + "cancertypes": { "added": { - "kind": "cooccurrence", + "cohorts": [ + "A" + ], "genes": [ "TP53", "KRAS", "EGFR", "STK11", - "KEAP1", - "PIK3CA" + "KEAP1" ], - "cohorts": [ - "A" - ] + "kind": "cancertypes" }, "addedBareStudy": { - "kind": "cooccurrence", - "genes": [], "cohorts": [ "A" - ] + ], + "genes": [ + "TP53", + "KRAS" + ], + "kind": "cancertypes" }, "addedWithTwoCohorts": { - "kind": "cooccurrence", + "cohorts": [], "genes": [ "TP53", "KRAS", "EGFR", "STK11", - "KEAP1", - "PIK3CA" + "KEAP1" ], - "cohorts": [] + "kind": "cancertypes" }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Co-occurrence", - "headTitle": "Co-occurrence", - "drawsOwnHead": { - "error": false, + "card": { + "defaults": { + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "kind": "cancertypes" + }, + "defaultsBareStudy": { + "genes": [ + "TP53", + "KRAS" + ], + "kind": "cancertypes" + }, + "genes": true, + "label": "Cancer types summary", + "maxCohorts": 2, + "title": "Cancer types summary", + "why": null, + "whyBareStudy": null + }, + "drawsOwnHead": { + "error": true, "withData": true }, - "card": null, "drawsOwnWaiting": false, - "sub": null, + "emptyHint": "It draws up to 2 cohorts, side by side.", + "figureTitle": "Cancer types summary — A vs B", + "headTitle": "Cancer types summary", "help": { - "title": "What is this?", - "text": "Checks whether alterations in two genes tend to show up in the same patients more often than you'd expect by chance (\"co-occurring\"), or rarely appear together (\"mutually exclusive\"). Genes that are mutually exclusive often play overlapping roles — if one is already altered, changing the other adds little, so cells rarely need both." + "text": "For a list of genes, shows what share of samples in each cancer type carry a change in at least one of them — mutated, gained in many copies, deleted, or rearranged — with each bar split by which kind of change it was. It answers which cancers these genes matter most in, the way cBioPortal's Cancer Types Summary does.", + "title": "What is this?" }, - "need": null, - "preview": null, "isNew": false, - "figureTitle": "Co-occurrence and mutual exclusivity — A vs B" - }, - "clinical": { + "maxCohorts": 2, "menu": { - "label": "Clinical comparison", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study declares no clinical attributes to compare", - "loading": false + "label": "Cancer types summary", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "local", + "preview": null, + "sub": "by CANCER_TYPE, any alteration, % of profiled samples", + "title": "Cancer types summary" + }, + "clinical": { "added": { - "kind": "clinical", "attributes": [ "TMB_NONSYNONYMOUS", "AGE", @@ -659,622 +525,658 @@ ], "cohorts": [ "A" - ] + ], + "kind": "clinical" }, "addedBareStudy": { - "kind": "clinical", "attributes": [], "cohorts": [ "A" - ] + ], + "kind": "clinical" }, "addedWithTwoCohorts": { - "kind": "clinical", "attributes": [ "TMB_NONSYNONYMOUS", "AGE", "OS_MONTHS" ], - "cohorts": [] + "cohorts": [], + "kind": "clinical" }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Clinical comparison", - "headTitle": "Clinical comparison", + "card": null, "drawsOwnHead": { "error": false, "withData": true }, - "card": null, "drawsOwnWaiting": false, - "sub": null, + "emptyHint": "Add one cohort, or several to compare them.", + "figureTitle": "Clinical comparison — A vs B", + "headTitle": "Clinical comparison", "help": { - "title": "What is this?", - "text": "Compares things recorded about the patients themselves — age, sex, a lab result, and similar details — rather than their genetics, between two or more groups. It answers whether the groups differ in ways beyond whatever genetic difference defined them, which matters for judging whether a genetic finding is really the explanation." + "text": "Compares things recorded about the patients themselves — age, sex, a lab result, and similar details — rather than their genetics, between two or more groups. It answers whether the groups differ in ways beyond whatever genetic difference defined them, which matters for judging whether a genetic finding is really the explanation.", + "title": "What is this?" }, - "need": null, - "preview": null, "isNew": false, - "figureTitle": "Clinical comparison — A vs B" - }, - "coexpression": { + "maxCohorts": null, "menu": { - "label": "Co-expression", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no continuous molecular profile (mRNA z-scores) to correlate", - "loading": false + "label": "Clinical comparison", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": "this study declares no clinical attributes to compare" + }, + "need": null, + "preview": null, + "sub": null, + "title": "Clinical comparison" + }, + "clonality": { "added": { - "kind": "coexpression", + "cohorts": [ + "A" + ], "genes": [ "TP53", "KRAS", "EGFR", "STK11", - "KEAP1" + "KEAP1", + "PIK3CA", + "APC", + "BRAF" ], - "profileType": "rna_seq_v2_mrna", - "cohorts": [ - "A" - ] + "kind": "clonality" }, "addedBareStudy": { - "kind": "coexpression", - "genes": [], - "profileType": null, "cohorts": [ "A" - ] + ], + "genes": [ + "TP53", + "KRAS", + "APC", + "PIK3CA", + "SMAD4", + "CDH1" + ], + "kind": "clonality" }, "addedWithTwoCohorts": { - "kind": "coexpression", + "cohorts": [], "genes": [ "TP53", "KRAS", "EGFR", "STK11", - "KEAP1" + "KEAP1", + "PIK3CA", + "APC", + "BRAF" ], - "profileType": "rna_seq_v2_mrna", - "cohorts": [] + "kind": "clonality" + }, + "card": { + "defaults": { + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "kind": "clonality" + }, + "defaultsBareStudy": { + "genes": [ + "TP53", + "KRAS", + "APC", + "PIK3CA", + "SMAD4", + "CDH1" + ], + "kind": "clonality" + }, + "genes": true, + "label": "Clonality (FACETS)", + "maxCohorts": 3, + "title": "Clonality", + "why": null, + "whyBareStudy": null }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Co-expression", - "headTitle": "Co-expression", "drawsOwnHead": { - "error": false, + "error": true, "withData": true }, - "card": null, "drawsOwnWaiting": false, - "sub": null, + "emptyHint": "It draws up to 3 cohorts, side by side.", + "figureTitle": "Clonality (FACETS) — A vs B", + "headTitle": "Clonality", "help": { - "title": "What is this?", - "text": "Checks whether two genes tend to be more or less active together across patients — measured by how much each gene is \"read out\" into the working molecules a cell actually uses. Genes that rise and fall together are often working in the same biological pathway, or controlled by the same switch." + "text": "A tumour is a population of cells, and a genetic change can be in all of them (present from early on) or only in some (arriving later). For each gene, this shows how often its changes are in all the tumour's cells, and how often the cell has also lost its backup copy of that gene. Beside it: how often tumours have doubled their entire set of chromosomes.", + "title": "What is this?" }, - "need": null, - "preview": null, - "isNew": false, - "figureTitle": "Co-expression — A vs B" - }, - "course": { + "isNew": true, + "maxCohorts": 3, "menu": { - "label": "Clinical course", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study records no timeline events (only 131 of 545 studies have any)", - "loading": false + "label": "Clonality (FACETS)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "local", + "preview": null, + "sub": "FACETS allele-specific copy number, driver mutations, QC-passing samples", + "title": "Clonality (FACETS)" + }, + "coexpression": { "added": { - "kind": "course", - "eventTypes": [ - "treatment", - "diagnosis" - ], - "anchor": { - "eventType": "Treatment", - "occurrence": "FIRST" - }, - "binMonths": 3, - "fromMonths": -12, - "toMonths": 60, "cohorts": [ "A" - ] + ], + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" + ], + "kind": "coexpression", + "profileType": "rna_seq_v2_mrna" }, "addedBareStudy": { - "kind": "course", - "eventTypes": [], - "binMonths": 3, - "fromMonths": -12, - "toMonths": 60, "cohorts": [ "A" - ] + ], + "genes": [], + "kind": "coexpression", + "profileType": null }, "addedWithTwoCohorts": { - "kind": "course", - "eventTypes": [ - "treatment", - "diagnosis" + "cohorts": [], + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1" ], - "anchor": { - "eventType": "Treatment", - "occurrence": "FIRST" - }, - "binMonths": 3, - "fromMonths": -12, - "toMonths": 60, - "cohorts": [] + "kind": "coexpression", + "profileType": "rna_seq_v2_mrna" }, - "maxCohorts": null, - "emptyHint": "Add one cohort, or several to compare them.", - "title": "Clinical course", - "headTitle": "Clinical course", + "card": null, "drawsOwnHead": { "error": false, "withData": true }, - "card": null, "drawsOwnWaiting": false, - "sub": null, + "emptyHint": "It draws one cohort.", + "figureTitle": "Co-expression — A vs B", + "headTitle": "Co-expression", "help": { - "title": "What is this?", - "text": "Shows what share of patients were in each stage of their care — on treatment, in follow-up, and so on — at each point in time after diagnosis or another starting event. It's a picture of the typical patient's journey through care, not any one individual's." + "text": "Checks whether two genes tend to be more or less active together across patients — measured by how much each gene is \"read out\" into the working molecules a cell actually uses. Genes that rise and fall together are often working in the same biological pathway, or controlled by the same switch.", + "title": "What is this?" }, - "need": null, - "preview": null, - "isNew": true, - "figureTitle": "Clinical course — A vs B" - }, - "sequence": { + "isNew": false, + "maxCohorts": 1, "menu": { - "label": "Treatment sequence", - "why": "this study records no treatment events, so there are no lines of therapy to sequence", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no treatment events, so there are no lines of therapy to sequence", - "loading": false + "label": "Co-expression", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": "this study records no treatment events, so there are no lines of therapy to sequence" + "why": null + }, + "menuBareStudy": { + "loading": false, + "why": "this study has no continuous molecular profile (mRNA z-scores) to correlate" }, + "need": null, + "preview": null, + "sub": null, + "title": "Co-expression" + }, + "compare": { "added": { - "kind": "sequence", - "eventType": "treatment", - "labelKey": "SUBTYPE", - "lineGap": 90, - "maxSteps": 3, - "topN": 8, "cohorts": [ "A" - ] + ], + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ], + "kind": "compare" }, "addedBareStudy": { - "kind": "sequence", - "labelKey": "SUBTYPE", - "lineGap": 90, - "maxSteps": 3, - "topN": 8, "cohorts": [ "A" - ] + ], + "genes": [], + "kind": "compare" }, "addedWithTwoCohorts": { - "kind": "sequence", - "eventType": "treatment", - "labelKey": "SUBTYPE", - "lineGap": 90, - "maxSteps": 3, - "topN": 8, - "cohorts": [] + "cohorts": [], + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF", + "ERBB2" + ], + "kind": "compare" }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Treatment sequence", - "headTitle": "Treatment sequence", + "card": null, "drawsOwnHead": { "error": false, "withData": true }, - "card": null, "drawsOwnWaiting": false, - "sub": null, + "emptyHint": "Add one cohort, or several to compare them.", + "figureTitle": "Alteration frequencies — A vs B", + "headTitle": "Compare", "help": { - "title": "What is this?", - "text": "Shows the order in which patients moved from one treatment to the next, drawn as flows between them. It answers a simple but useful question: after this treatment, what did doctors try next, and how often?" + "text": "Compares how often specific genes are altered — mutated, gained, or lost — between two or more groups of patients. If a gene is altered much more often in one group than another, that's a clue it might be connected to whatever makes the groups different, like a cancer subtype or how patients respond to treatment.", + "title": "What is this?" }, - "need": null, - "preview": null, - "isNew": true, - "figureTitle": "Treatment sequence — A vs B" - }, - "attrition": { + "isNew": false, + "maxCohorts": null, "menu": { - "label": "Cohort attrition (CONSORT)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Compare genes", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": null, + "preview": null, + "sub": null, + "title": "Compare genes" + }, + "composition": { "added": { - "kind": "attrition", "cohorts": [ "A" - ] + ], + "kind": "composition", + "profileType": "mutational_signatures_contribution_v2" }, "addedBareStudy": { - "kind": "attrition", + "attributes": [], "cohorts": [ "A" - ] + ], + "kind": "composition" }, "addedWithTwoCohorts": { - "kind": "attrition", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Cohort attrition (CONSORT)", - "headTitle": "Cohort attrition", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "composition", + "profileType": "mutational_signatures_contribution_v2" }, "card": { - "label": "Cohort attrition (CONSORT)", - "title": "Cohort attrition", - "maxCohorts": 6, - "why": null, - "whyBareStudy": null, "defaults": { - "kind": "attrition" + "kind": "composition", + "profileType": "mutational_signatures_contribution_v2" }, "defaultsBareStudy": { - "kind": "attrition" - } + "attributes": [], + "kind": "composition" + }, + "label": "Composition (cell types, states, signatures as parts of a whole)", + "maxCohorts": 8, + "minCohorts": 1, + "title": "Composition", + "why": null, + "whyBareStudy": "this study has no generic-assay profile and no numeric attribute to draw as parts" + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "patients left after each clause, in order", + "emptyHint": "It compares 1 to 8 cohorts.", + "figureTitle": "Composition — A vs B", + "headTitle": "Composition", "help": { - "title": "What is this?", - "text": "Shows how each group of patients was narrowed down, one criterion at a time: how many patients the study started with, how many were left after each rule in the group's definition, and how many each rule removed. It lets a reader see who was left out before trusting any result drawn from the patients who remain." + "text": "Shows what each patient's (or sample's) tumour is made of — the share of each cell type, cell state or signature — as one stacked bar per case, sorted by the part you choose, with each group's average beside it. Under the bars, every group is set against the others part by part: the colour says whether a part is higher (red) or lower (blue) there, its size how sure that is. Parts of a whole move together, so one part rising means others fall.", + "title": "What is this?" }, - "need": null, - "preview": null, "isNew": true, - "figureTitle": "Cohort attrition — A vs B" - }, - "genome": { + "maxCohorts": 8, "menu": { - "label": "Genome-wide copy number", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Composition (cell types, states, signatures as parts of a whole)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": "this study has no generic-assay profile and no numeric attribute to draw as parts" + }, + "need": "local", + "preview": null, + "sub": "mutational_signatures_contribution_v2, as shares", + "title": "Composition (cell types, states, signatures as parts of a whole)" + }, + "cooccurrence": { "added": { - "kind": "genome", "cohorts": [ "A" - ] + ], + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA" + ], + "kind": "cooccurrence" }, "addedBareStudy": { - "kind": "genome", "cohorts": [ "A" - ] + ], + "genes": [], + "kind": "cooccurrence" }, "addedWithTwoCohorts": { - "kind": "genome", - "cohorts": [] + "cohorts": [], + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA" + ], + "kind": "cooccurrence" }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Genome-wide copy number", - "headTitle": "Genome-wide copy number", + "card": null, "drawsOwnHead": { - "error": true, + "error": false, "withData": true }, - "card": { - "label": "Genome-wide copy number", - "title": "Genome-wide copy number", - "maxCohorts": 4, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "genome" - }, - "defaultsBareStudy": { - "kind": "genome" - } - }, "drawsOwnWaiting": false, - "sub": "from copy-number segments", + "emptyHint": "It draws one cohort.", + "figureTitle": "Co-occurrence and mutual exclusivity — A vs B", + "headTitle": "Co-occurrence", "help": { - "title": "What is this?", - "text": "Walks along every chromosome and shows, at each stretch, what share of the group's tumours have extra copies of that stretch of DNA (drawn upward, in red) or have lost copies of it (drawn downward, in blue). Stretches gained or lost in many tumours often hold genes that help the cancer grow, or genes that would have held it back." + "text": "Checks whether alterations in two genes tend to show up in the same patients more often than you'd expect by chance (\"co-occurring\"), or rarely appear together (\"mutually exclusive\"). Genes that are mutually exclusive often play overlapping roles — if one is already altered, changing the other adds little, so cells rarely need both.", + "title": "What is this?" }, - "need": "local", - "preview": null, - "isNew": true, - "figureTitle": "Genome-wide copy number — A vs B" - }, - "pathways": { + "isNew": false, + "maxCohorts": 1, "menu": { - "label": "Oncogenic pathways", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Co-occurrence", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": null, + "preview": null, + "sub": null, + "title": "Co-occurrence" + }, + "correlates": { "added": { - "kind": "pathways", "cohorts": [ "A" - ] + ], + "gene": "TP53", + "kind": "correlates", + "profileType": "rna_seq_v2_mrna" }, "addedBareStudy": { - "kind": "pathways", "cohorts": [ "A" - ] + ], + "gene": "TP53", + "kind": "correlates", + "profileType": "" }, "addedWithTwoCohorts": { - "kind": "pathways", - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Oncogenic pathways", - "headTitle": "Oncogenic signalling pathways", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "gene": "TP53", + "kind": "correlates", + "profileType": "rna_seq_v2_mrna" }, "card": { - "label": "Oncogenic pathways", - "title": "Oncogenic signalling pathways", - "maxCohorts": 4, - "why": null, - "whyBareStudy": null, "defaults": { - "kind": "pathways" + "gene": "TP53", + "kind": "correlates", + "profileType": "rna_seq_v2_mrna" }, "defaultsBareStudy": { - "kind": "pathways" - } + "gene": "TP53", + "kind": "correlates", + "profileType": "" + }, + "label": "Co-expression with one gene (genome-wide)", + "maxCohorts": 1, + "title": "Co-expression", + "why": null, + "whyBareStudy": "this study has no continuous molecular profile (mRNA, protein, …) to correlate on" + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "OncoKB drivers, Sanchez-Vega et al. 2018", + "emptyHint": "It draws one cohort.", + "figureTitle": "Co-expression — A vs B", + "headTitle": "Co-expression", "help": { - "title": "What is this?", - "text": "Groups genes into ten well-known signalling pathways — chains of genes that together tell a cell when to grow, divide or die — and shows what share of each group of patients has a harmful change in at least one gene of each pathway. Two cancers can reach the same broken pathway through different genes, and this view makes that visible." + "text": "Takes one gene and checks every other gene for whether its activity rises and falls along with it across these patients. The genes at the top go up when it goes up; those at the bottom go down when it goes up. The chart on the left shows how all genes fall: most barely move with it, and the ones far out on either side are the ones listed. Moving together is a clue, not proof that one gene controls the other.", + "title": "What is this?" }, - "need": "local", - "preview": null, "isNew": false, - "figureTitle": "Oncogenic signalling pathways — A vs B" - }, - "diffexpr": { + "maxCohorts": 1, "menu": { - "label": "Differential expression + GSEA", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no molecular profile to compare expression on", - "loading": false + "label": "Co-expression with one gene (genome-wide)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": "this study has no continuous molecular profile (mRNA, protein, …) to correlate on" + }, + "need": "local", + "preview": null, + "sub": "TP53 on rna_seq_v2_mrna · Spearman ρ, every gene", + "title": "Co-expression with one gene (genome-wide)" + }, + "course": { "added": { - "kind": "diffexpr", - "profileType": "rna_seq_v2_mrna_median_Zscores", - "cohorts": [] + "anchor": { + "eventType": "Treatment", + "occurrence": "FIRST" + }, + "binMonths": 3, + "cohorts": [ + "A" + ], + "eventTypes": [ + "treatment", + "diagnosis" + ], + "fromMonths": -12, + "kind": "course", + "toMonths": 60 }, "addedBareStudy": { - "kind": "diffexpr", - "profileType": "", - "cohorts": [] + "binMonths": 3, + "cohorts": [ + "A" + ], + "eventTypes": [], + "fromMonths": -12, + "kind": "course", + "toMonths": 60 }, "addedWithTwoCohorts": { - "kind": "diffexpr", - "profileType": "rna_seq_v2_mrna_median_Zscores", - "cohorts": [] + "anchor": { + "eventType": "Treatment", + "occurrence": "FIRST" + }, + "binMonths": 3, + "cohorts": [], + "eventTypes": [ + "treatment", + "diagnosis" + ], + "fromMonths": -12, + "kind": "course", + "toMonths": 60 }, - "maxCohorts": 2, - "emptyHint": "It compares exactly 2 cohorts.", - "title": "Differential expression + GSEA", - "headTitle": "Differential expression", + "card": null, "drawsOwnHead": { - "error": true, + "error": false, "withData": true }, - "card": { - "label": "Differential expression + GSEA", - "title": "Differential expression", - "maxCohorts": 2, - "minCohorts": 2, - "why": null, - "whyBareStudy": "this study has no molecular profile to compare expression on", - "defaults": { - "kind": "diffexpr", - "profileType": "rna_seq_v2_mrna_median_Zscores" - }, - "defaultsBareStudy": { - "kind": "diffexpr", - "profileType": "" - } - }, "drawsOwnWaiting": false, - "sub": "rna_seq_v2_mrna_median_Zscores", + "emptyHint": "Add one cohort, or several to compare them.", + "figureTitle": "Clinical course — A vs B", + "headTitle": "Clinical course", "help": { - "title": "What is this?", - "text": "Compares how active every gene is between two groups of patients. Each dot is a gene: further left or right means a bigger difference in activity between the groups, higher up means the difference is less likely to be chance. The panel beside it asks whether whole families of related genes, such as those that drive cell division, lean towards one group." + "text": "Shows what share of patients were in each stage of their care — on treatment, in follow-up, and so on — at each point in time after diagnosis or another starting event. It's a picture of the typical patient's journey through care, not any one individual's.", + "title": "What is this?" }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Differential expression — A vs B" - }, - "paired": { + "isNew": true, + "maxCohorts": null, "menu": { - "label": "Paired samples (primary vs metastasis, before vs after)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Clinical course", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": "this study records no timeline events (only 131 of 545 studies have any)" + }, + "need": null, + "preview": null, + "sub": null, + "title": "Clinical course" + }, + "cox": { "added": { - "kind": "paired", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], "cohorts": [ "A" - ] + ], + "covariates": [ + { + "by": "mutated", + "gene": "TP53", + "source": "alteration" + } + ], + "kind": "cox" }, "addedBareStudy": { - "kind": "paired", - "genes": [ - "TP53", - "KRAS", - "PIK3CA", - "ESR1", - "AR", - "CDKN2A", - "MYC", - "TERT" - ], "cohorts": [ "A" - ] + ], + "covariates": [ + { + "by": "mutated", + "gene": "TP53", + "source": "alteration" + } + ], + "kind": "cox" }, "addedWithTwoCohorts": { - "kind": "paired", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" + "cohorts": [], + "covariates": [ + { + "by": "mutated", + "gene": "TP53", + "source": "alteration" + } ], - "cohorts": [] + "kind": "cox" }, - "maxCohorts": 3, - "emptyHint": "It draws up to 3 cohorts, side by side.", - "title": "Paired samples (primary vs metastasis, before vs after)", - "headTitle": "Paired samples", + "card": null, "drawsOwnHead": { "error": true, "withData": true }, - "card": { - "label": "Paired samples (primary vs metastasis, before vs after)", - "title": "Paired samples", - "genes": true, - "maxCohorts": 3, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "paired", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ] - }, - "defaultsBareStudy": { - "kind": "paired", - "genes": [ - "TP53", - "KRAS", - "PIK3CA", - "ESR1", - "AR", - "CDKN2A", - "MYC", - "TERT" - ] - } - }, "drawsOwnWaiting": false, - "sub": "SAMPLE_TYPE: Primary vs Metastasis, drivers", + "emptyHint": "Add one cohort, or several to compare them.", + "figureTitle": "Cox regression — OS — A vs B", + "headTitle": "Cox regression", "help": { - "title": "What is this?", - "text": "For patients who had two kinds of tumour sample tested — usually the original tumour and a later spread of it elsewhere in the body — shows, gene by gene, how often a change was found in both samples, only in the original, or only in the spread. Changes found only in the spread can point to how the cancer adapted, for example to a treatment. The two kinds can also be samples taken before and after a treatment. Without a list of genes, it checks every gene and shows the ones most often found on one side only." + "text": "A more advanced version of the survival comparison above, which can weigh several factors at once — for example, a patient's age, a specific gene, and how far the disease had spread — to estimate how much each one changes risk on its own, holding the others equal. Each result is a \"hazard ratio\": above 1 means that factor is linked to worse outcomes, below 1 means it's linked to better ones.", + "title": "What is this?" }, - "need": "local", - "preview": null, "isNew": true, - "figureTitle": "Paired samples — A vs B" - }, - "clonality": { + "maxCohorts": null, "menu": { - "label": "Clonality (FACETS)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Cox regression (forest plot)", + "loading": false, + "why": null }, "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "menuBareStudy": { + "loading": false, "why": null }, + "need": "patients", + "preview": null, + "sub": null, + "title": "Cox regression (forest plot)" + }, + "ctdna": { "added": { - "kind": "clonality", + "cohorts": [ + "A" + ], "genes": [ "TP53", "KRAS", @@ -1283,28 +1185,19 @@ "KEAP1", "PIK3CA", "APC", - "BRAF" + "BRAF", + "ERBB2" ], - "cohorts": [ - "A" - ] + "kind": "ctdna" }, "addedBareStudy": { - "kind": "clonality", - "genes": [ - "TP53", - "KRAS", - "APC", - "PIK3CA", - "SMAD4", - "CDH1" - ], "cohorts": [ "A" - ] + ], + "kind": "ctdna" }, "addedWithTwoCohorts": { - "kind": "clonality", + "cohorts": [], "genes": [ "TP53", "KRAS", @@ -1313,27 +1206,13 @@ "KEAP1", "PIK3CA", "APC", - "BRAF" + "BRAF", + "ERBB2" ], - "cohorts": [] - }, - "maxCohorts": 3, - "emptyHint": "It draws up to 3 cohorts, side by side.", - "title": "Clonality (FACETS)", - "headTitle": "Clonality", - "drawsOwnHead": { - "error": true, - "withData": true + "kind": "ctdna" }, "card": { - "label": "Clonality (FACETS)", - "title": "Clonality", - "genes": true, - "maxCohorts": 3, - "why": null, - "whyBareStudy": null, "defaults": { - "kind": "clonality", "genes": [ "TP53", "KRAS", @@ -1342,1295 +1221,1116 @@ "KEAP1", "PIK3CA", "APC", - "BRAF" - ] + "BRAF", + "ERBB2" + ], + "kind": "ctdna" }, "defaultsBareStudy": { - "kind": "clonality", - "genes": [ - "TP53", - "KRAS", - "APC", - "PIK3CA", - "SMAD4", - "CDH1" - ] - } + "kind": "ctdna" + }, + "genes": true, + "label": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", + "maxCohorts": 3, + "title": "ctDNA vs tissue", + "why": null, + "whyBareStudy": null + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "FACETS allele-specific copy number, driver mutations, QC-passing samples", + "emptyHint": "It draws up to 3 cohorts, side by side.", + "figureTitle": "ctDNA vs tissue — A vs B", + "headTitle": "ctDNA vs tissue", "help": { - "title": "What is this?", - "text": "A tumour is a population of cells, and a genetic change can be in all of them (present from early on) or only in some (arriving later). For each gene, this shows how often its changes are in all the tumour's cells, and how often the cell has also lost its backup copy of that gene. Beside it: how often tumours have doubled their entire set of chromosomes." + "text": "Tumours shed DNA into the blood, and a blood test (a \"liquid biopsy\") can read it. For patients who had both a blood sample and a tumour tissue sample tested, this shows gene by gene how often a change was found in both, only in the tissue, or only in the blood — and how often the blood test caught what the tissue showed. Below, it shows how many patients had tumour DNA detected in their blood at all, and whether those patients lived shorter or longer than those without it. With the clonal-haematopoiesis filter on, changes that probably come from ageing blood cells rather than the tumour — in genes such as DNMT3A and TET2, or also found in the patient's white blood cells — are left out first, and the figure says how many were removed and what the numbers were without the filter.", + "title": "What is this?" }, - "need": "local", - "preview": null, "isNew": true, - "figureTitle": "Clonality (FACETS) — A vs B" - }, - "incidence": { + "maxCohorts": 3, "menu": { - "label": "Cumulative incidence (competing risks)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study records no timeline events to estimate the incidence of", - "loading": false + "label": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "local", + "preview": "no method record or golden test of the agreement counts, Wilson intervals and survival split, or of the clonal-haematopoiesis filter (chFilter) yet", + "sub": "plasma SAMPLE_CLASS = cfDNA/ctDNA vs tissue Tumor, any alteration", + "title": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)" + }, + "cutpoint": { "added": { - "kind": "incidence", - "event": { - "eventType": "Diagnosis" - }, + "attribute": "TMB_NONSYNONYMOUS", "cohorts": [ "A" - ] + ], + "kind": "cutpoint" }, "addedBareStudy": { - "kind": "incidence", - "event": { - "eventType": "" - }, + "attribute": "TMB_NONSYNONYMOUS", "cohorts": [ "A" - ] + ], + "kind": "cutpoint" }, "addedWithTwoCohorts": { - "kind": "incidence", - "event": { - "eventType": "Diagnosis" + "attribute": "TMB_NONSYNONYMOUS", + "cohorts": [], + "kind": "cutpoint" + }, + "card": { + "defaults": { + "attribute": "TMB_NONSYNONYMOUS", + "kind": "cutpoint" }, - "cohorts": [] + "defaultsBareStudy": { + "attribute": "TMB_NONSYNONYMOUS", + "kind": "cutpoint" + }, + "label": "Biomarker cut-point (best split of a number for OS)", + "maxCohorts": 1, + "title": "Cut-point", + "why": null, + "whyBareStudy": "this study has no numeric clinical attribute to find a cut-point of" }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Cumulative incidence (competing risks)", - "headTitle": "Cumulative incidence", "drawsOwnHead": { "error": true, "withData": true }, - "card": { - "label": "Cumulative incidence (competing risks)", - "title": "Cumulative incidence", - "maxCohorts": 6, - "why": null, - "whyBareStudy": "this study records no timeline events to estimate the incidence of", - "defaults": { - "kind": "incidence", - "event": { - "eventType": "Diagnosis" - } - }, - "defaultsBareStudy": { - "kind": "incidence", - "event": { - "eventType": "" - } - } - }, "drawsOwnWaiting": false, - "sub": "Diagnosis from day 0, death competing", + "emptyHint": "It draws one cohort.", + "figureTitle": "Cut-point — A vs B", + "headTitle": "Cut-point", "help": { - "title": "What is this?", - "text": "Shows what share of patients have experienced a particular event — such as the cancer spreading to the brain — by each point in time. It counts patients who died before the event as never having it, rather than pretending they might still, which is why its numbers can be noticeably lower, and more honest, than a simple survival-style curve would suggest." + "text": "Looks for the value of a measurement — such as tumour mutational burden — that best splits patients into a group that did better and a group that did worse. It tries every reasonable dividing line (from the lowest tenth of patients to the highest) and draws how strongly each one separates survival; the dot marks the best. Because trying many lines and keeping the best always finds something, the view also reshuffles the measurement among the patients a thousand times to see how good a 'best line' chance alone produces, and reports how surprising the real best line is once that search is accounted for. A line found this way needs checking in other patients before anyone relies on it.", + "title": "What is this?" }, - "need": "local", - "preview": null, "isNew": true, - "figureTitle": "Cumulative incidence — A vs B" - }, - "labs": { + "maxCohorts": 1, "menu": { - "label": "Lab course and response (e.g. PSA50)", - "why": "this study records no lab-test timeline events", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no lab-test timeline events", - "loading": false + "label": "Biomarker cut-point (best split of a number for OS)", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": "this study records no lab-test timeline events" + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." }, + "menuBareStudy": { + "loading": false, + "why": "this study has no numeric clinical attribute to find a cut-point of" + }, + "need": "patients", + "preview": "no method record or golden test of the maximally selected log-rank scan against R maxstat yet", + "sub": "TMB_NONSYNONYMOUS on OS · best log-rank cut, 1000 permutations", + "title": "Biomarker cut-point (best split of a number for OS)" + }, + "diffexpr": { "added": { - "kind": "labs", - "test": "PSA", - "anchor": { - "eventType": "Treatment" - }, - "cohorts": [ - "A" - ] + "cohorts": [], + "kind": "diffexpr", + "profileType": "rna_seq_v2_mrna_median_Zscores" }, "addedBareStudy": { - "kind": "labs", - "test": "PSA", - "anchor": { - "eventType": "Treatment" - }, - "cohorts": [ - "A" - ] + "cohorts": [], + "kind": "diffexpr", + "profileType": "" }, "addedWithTwoCohorts": { - "kind": "labs", - "test": "PSA", - "anchor": { - "eventType": "Treatment" - }, - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Lab course and response (e.g. PSA50)", - "headTitle": "Lab course", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "diffexpr", + "profileType": "rna_seq_v2_mrna_median_Zscores" }, "card": { - "label": "Lab course and response (e.g. PSA50)", - "title": "Lab course", - "maxCohorts": 4, - "why": null, - "whyBareStudy": "this study records no lab-test timeline events", "defaults": { - "kind": "labs", - "test": "PSA", - "anchor": { - "eventType": "Treatment" - } + "kind": "diffexpr", + "profileType": "rna_seq_v2_mrna_median_Zscores" }, "defaultsBareStudy": { - "kind": "labs", - "test": "PSA", - "anchor": { - "eventType": "Treatment" - } - } + "kind": "diffexpr", + "profileType": "" + }, + "label": "Differential expression + GSEA", + "maxCohorts": 2, + "minCohorts": 2, + "title": "Differential expression", + "why": null, + "whyBareStudy": "this study has no molecular profile to compare expression on" + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "PSA from first Treatment · responders fell ≥50%", + "emptyHint": "It compares exactly 2 cohorts.", + "figureTitle": "Differential expression — A vs B", + "headTitle": "Differential expression", "help": { - "title": "What is this?", - "text": "Follows a blood test — such as PSA in prostate cancer — from the moment a treatment started, as the change from each patient's own starting level. The line shows the typical patient, the shaded band the middle half of patients, and the bars on the right each patient's best result: how far their level fell, or rose, after treatment began." + "text": "Compares how active every gene is between two groups of patients. Each dot is a gene: further left or right means a bigger difference in activity between the groups, higher up means the difference is less likely to be chance. The panel beside it asks whether whole families of related genes, such as those that drive cell division, lean towards one group.", + "title": "What is this?" }, - "need": "local", - "preview": null, - "isNew": true, - "figureTitle": "Lab course — A vs B" - }, - "riskmodel": { + "isNew": false, + "maxCohorts": 2, "menu": { - "label": "Risk model (elastic-net Cox, OncoCast-style)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Differential expression + GSEA", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + "why": null + }, + "menuBareStudy": { + "loading": false, + "why": "this study has no molecular profile to compare expression on" }, + "need": "local", + "preview": null, + "sub": "rna_seq_v2_mrna_median_Zscores", + "title": "Differential expression + GSEA" + }, + "drivernegative": { "added": { - "kind": "riskmodel", "cohorts": [ "A" - ] + ], + "kind": "drivernegative" }, "addedBareStudy": { - "kind": "riskmodel", "cohorts": [ "A" - ] + ], + "kind": "drivernegative" }, "addedWithTwoCohorts": { - "kind": "riskmodel", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Risk model (elastic-net Cox, OncoCast-style)", - "headTitle": "Risk model", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "drivernegative" }, "card": { - "label": "Risk model (elastic-net Cox, OncoCast-style)", - "title": "Risk model", - "maxCohorts": 1, - "why": null, - "whyBareStudy": null, "defaults": { - "kind": "riskmodel" + "kind": "drivernegative" }, "defaultsBareStudy": { - "kind": "riskmodel" - } + "kind": "drivernegative" + }, + "label": "Driver-negative analysis (genes enriched without a canonical driver)", + "maxCohorts": 1, + "title": "Driver-negative analysis", + "why": null, + "whyBareStudy": null + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "OS on mutated genes · 10 held-out splits", + "emptyHint": "It draws one cohort.", + "figureTitle": "Driver-negative analysis — A vs B", + "headTitle": "Driver-negative analysis", "help": { - "title": "What is this?", - "text": "A model that predicts survival from which genes are mutated in each patient, in the style of the OncoCast package. It is trained many times, each time on two thirds of the patients and tested on the other third, so every patient's risk score comes from a model that never saw them. Patients are then split into low, intermediate and high risk, and their survival curves are drawn. The list shows the genes the model relied on most, with a hazard ratio above 1 meaning higher risk. Tick \"validate\" to train on one half of the patients and grade the model on the other half, which it never saw, or name another group of patients under \"validate on\" to grade it there — for example patients from a different study — with the same risk-group boundaries." + "text": "Splits one group of patients — say, lung cancers — into those whose tumour carries one of the well-known, usually treatable cancer-driving changes (EGFR, KRAS, ALK and the rest of the list) and those whose tumour carries none, and then asks which other genes are changed more often in the second group. Those genes are candidates for what drives the tumours without a known driver. Patients whose test did not cover every gene on the list are kept apart, since we cannot tell whether they have a driver. The bar shows how big each group is; in the chart, genes to the right are more common without a known driver.", + "title": "What is this?" }, - "need": "patients", - "preview": null, "isNew": true, - "figureTitle": "Risk model — A vs B" - }, - "cancertypes": { + "maxCohorts": 1, "menu": { - "label": "Cancer types summary", - "why": null, - "loading": false + "label": "Driver-negative analysis (genes enriched without a canonical driver)", + "loading": false, + "why": null + }, + "menuAggregateOnly": { + "why": null }, "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "local", + "preview": "no method record or golden test of the group split and the per-gene Fisher tests yet", + "sub": "mutations without vs with a driver in the NSCLC set, per patient", + "title": "Driver-negative analysis (genes enriched without a canonical driver)" + }, + "enrichment": { + "added": { + "cohorts": [], + "kind": "enrichment" + }, + "addedBareStudy": { + "cohorts": [], + "kind": "enrichment" + }, + "addedWithTwoCohorts": { + "cohorts": [], + "kind": "enrichment" + }, + "card": { + "defaults": { + "kind": "enrichment" + }, + "defaultsBareStudy": { + "kind": "enrichment" + }, + "label": "Alteration enrichment (all genes)", + "maxCohorts": 6, + "minCohorts": 2, + "title": "Alteration enrichment", "why": null, - "loading": false + "whyBareStudy": null + }, + "drawsOwnHead": { + "error": true, + "withData": true + }, + "drawsOwnWaiting": false, + "emptyHint": "It compares 2 to 6 cohorts.", + "figureTitle": "Alteration enrichment — A vs B", + "headTitle": "Alteration enrichment", + "help": { + "text": "Checks every gene at once for whether it is changed more often in one group of patients than another — mutated, gained, deleted or rearranged, whichever you choose. The genes at the top of the list, and furthest out on the chart, differ most clearly between the groups; the share in each group is beside every gene, so a difference that is clear but small is visible as small.", + "title": "What is this?" + }, + "isNew": false, + "maxCohorts": 6, + "menu": { + "label": "Alteration enrichment (all genes)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "local", + "preview": null, + "sub": "mutations in every gene, per sample", + "title": "Alteration enrichment (all genes)" + }, + "exclusivity": { "added": { - "kind": "cancertypes", + "cohorts": [ + "A" + ], "genes": [ "TP53", "KRAS", "EGFR", "STK11", - "KEAP1" + "KEAP1", + "PIK3CA", + "APC", + "BRAF" ], - "cohorts": [ - "A" - ] + "kind": "exclusivity" }, "addedBareStudy": { - "kind": "cancertypes", - "genes": [ - "TP53", - "KRAS" - ], "cohorts": [ "A" - ] + ], + "genes": [ + "KRAS", + "EGFR", + "BRAF", + "STK11", + "KEAP1", + "TP53" + ], + "kind": "exclusivity" }, "addedWithTwoCohorts": { - "kind": "cancertypes", + "cohorts": [], "genes": [ "TP53", "KRAS", "EGFR", "STK11", - "KEAP1" + "KEAP1", + "PIK3CA", + "APC", + "BRAF" ], - "cohorts": [] - }, - "maxCohorts": 2, - "emptyHint": "It draws up to 2 cohorts, side by side.", - "title": "Cancer types summary", - "headTitle": "Cancer types summary", - "drawsOwnHead": { - "error": true, - "withData": true + "kind": "exclusivity" }, "card": { - "label": "Cancer types summary", - "title": "Cancer types summary", - "genes": true, - "maxCohorts": 2, - "why": null, - "whyBareStudy": null, + "cohortInHeader": true, "defaults": { - "kind": "cancertypes", "genes": [ "TP53", "KRAS", "EGFR", "STK11", - "KEAP1" - ] + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "kind": "exclusivity" }, "defaultsBareStudy": { - "kind": "cancertypes", "genes": [ - "TP53", - "KRAS" - ] - } - }, - "drawsOwnWaiting": false, - "sub": "by CANCER_TYPE, any alteration, % of profiled samples", - "help": { - "title": "What is this?", - "text": "For a list of genes, shows what share of samples in each cancer type carry a change in at least one of them — mutated, gained in many copies, deleted, or rearranged — with each bar split by which kind of change it was. It answers which cancers these genes matter most in, the way cBioPortal's Cancer Types Summary does." - }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Cancer types summary — A vs B" - }, - "overlap": { - "menu": { - "label": "Cohort overlap (UpSet)", - "why": null, - "loading": false - }, - "menuBareStudy": { + "KRAS", + "EGFR", + "BRAF", + "STK11", + "KEAP1", + "TP53" + ], + "kind": "exclusivity" + }, + "genes": true, + "label": "Mutual exclusivity (DISCOVER, burden-aware)", + "maxCohorts": 1, + "title": "Mutual exclusivity", "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": null - }, - "added": { - "kind": "overlap", - "cohorts": [] - }, - "addedBareStudy": { - "kind": "overlap", - "cohorts": [] - }, - "addedWithTwoCohorts": { - "kind": "overlap", - "cohorts": [] + "whyBareStudy": null }, - "maxCohorts": 6, - "emptyHint": "It compares 2 to 6 cohorts.", - "title": "Cohort overlap (UpSet)", - "headTitle": "Cohort overlap", "drawsOwnHead": { "error": true, "withData": true }, - "card": { - "label": "Cohort overlap (UpSet)", - "title": "Cohort overlap", - "maxCohorts": 6, - "minCohorts": 2, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "overlap" - }, - "defaultsBareStudy": { - "kind": "overlap" - } - }, "drawsOwnWaiting": false, - "sub": "patients in exactly each combination of cohorts", + "emptyHint": "It draws one cohort.", + "figureTitle": "Mutual exclusivity (DISCOVER) — A vs B", + "headTitle": "Mutual exclusivity", "help": { - "title": "What is this?", - "text": "Shows how the groups of patients you defined overlap: for every combination of groups, how many patients belong to exactly those groups and none of the others. Each column is one combination — the dots below it say which groups — and its bar is how many patients fall there, so you can see at a glance whether two groups are mostly the same people or nearly separate." + "text": "Asks, for each pair of genes, whether tumours carry changes in both more or less often than chance — \"mutually exclusive\" genes are rarely changed together, which can mean either change alone is enough to drive the cancer. Its sense of \"chance\" accounts for how many changes each tumour has overall: a tumour with hundreds of changes is expected to share genes with many others, so it does not make two genes look linked just because they both turn up in heavily altered tumours. Beside it is the simpler test that ignores this, to show the difference.", + "title": "What is this?" }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Cohort overlap — A vs B" - }, - "enrichment": { + "isNew": true, + "maxCohorts": 1, "menu": { - "label": "Alteration enrichment (all genes)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Mutual exclusivity (DISCOVER, burden-aware)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "local", + "preview": "no method record or golden test of the DISCOVER background fit against the R package yet", + "sub": "(element)", + "title": "Mutual exclusivity (DISCOVER, burden-aware)" + }, + "forest": { "added": { - "kind": "enrichment", - "cohorts": [] + "cohorts": [ + "A" + ], + "exposure": { + "gene": "TP53", + "source": "alteration" + }, + "kind": "forest" }, "addedBareStudy": { - "kind": "enrichment", - "cohorts": [] + "cohorts": [ + "A" + ], + "exposure": { + "gene": "TP53", + "source": "alteration" + }, + "kind": "forest" }, "addedWithTwoCohorts": { - "kind": "enrichment", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It compares 2 to 6 cohorts.", - "title": "Alteration enrichment (all genes)", - "headTitle": "Alteration enrichment", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "exposure": { + "gene": "TP53", + "source": "alteration" + }, + "kind": "forest" }, "card": { - "label": "Alteration enrichment (all genes)", - "title": "Alteration enrichment", - "maxCohorts": 6, - "minCohorts": 2, - "why": null, - "whyBareStudy": null, "defaults": { - "kind": "enrichment" + "exposure": { + "gene": "TP53", + "source": "alteration" + }, + "kind": "forest" }, "defaultsBareStudy": { - "kind": "enrichment" - } + "exposure": { + "gene": "TP53", + "source": "alteration" + }, + "kind": "forest" + }, + "label": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", + "maxCohorts": 6, + "title": "Forest plot", + "why": null, + "whyBareStudy": null + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "mutations in every gene, per sample", + "emptyHint": "It draws up to 6 cohorts, side by side.", + "figureTitle": "Forest plot — A vs B", + "headTitle": "Forest plot", "help": { - "title": "What is this?", - "text": "Checks every gene at once for whether it is changed more often in one group of patients than another — mutated, gained, deleted or rearranged, whichever you choose. The genes at the top of the list, and furthest out on the chart, differ most clearly between the groups; the share in each group is beside every gene, so a difference that is clear but small is visible as small." + "text": "Asks whether one thing — a gene being mutated, a high tumour mutation burden — goes with shorter or longer survival, separately in each cancer type, and then combines those answers. Each row is one cancer type: its square is the hazard ratio (right of the dashed line at 1 means patients with the change died sooner; left means they lived longer) and its line how sure we can be. The diamonds at the bottom combine the rows into one overall estimate. When the rows disagree a lot (a high I²), the overall estimate is an average of different effects, so read the rows. This shows an association, not a cause.", + "title": "What is this?" }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Alteration enrichment — A vs B" - }, - "assays": { + "isNew": true, + "maxCohorts": 6, "menu": { - "label": "Generic assays (signatures, arm-level CNA, …)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)", - "loading": false + "label": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", + "loading": false, + "why": null }, "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "menuBareStudy": { + "loading": false, "why": null }, + "need": "patients", + "preview": "no method record or golden test of the per-stratum Cox fits and the DerSimonian–Laird pooling yet", + "sub": "TP53 mutated · OS hazard ratio per CANCER_TYPE, random-effects pooled", + "title": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)" + }, + "genome": { "added": { - "kind": "assays", - "profileType": "mutational_signatures_contribution_v2", - "cohorts": [] + "cohorts": [ + "A" + ], + "kind": "genome" }, "addedBareStudy": { - "kind": "assays", - "profileType": "", - "cohorts": [] + "cohorts": [ + "A" + ], + "kind": "genome" }, "addedWithTwoCohorts": { - "kind": "assays", - "profileType": "mutational_signatures_contribution_v2", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It compares 2 to 6 cohorts.", - "title": "Generic assays (signatures, arm-level CNA, …)", - "headTitle": "Generic assays", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "genome" }, "card": { - "label": "Generic assays (signatures, arm-level CNA, …)", - "title": "Generic assays", - "maxCohorts": 6, - "minCohorts": 2, - "why": null, - "whyBareStudy": "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)", "defaults": { - "kind": "assays", - "profileType": "mutational_signatures_contribution_v2" + "kind": "genome" }, "defaultsBareStudy": { - "kind": "assays", - "profileType": "" - } + "kind": "genome" + }, + "label": "Genome-wide copy number", + "maxCohorts": 4, + "title": "Genome-wide copy number", + "why": null, + "whyBareStudy": null + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "mutational_signatures_contribution_v2, per sample", + "emptyHint": "It draws up to 4 cohorts, side by side.", + "figureTitle": "Genome-wide copy number — A vs B", + "headTitle": "Genome-wide copy number", "help": { - "title": "What is this?", - "text": "Compares two or more groups on every measurement in one kind of extra lab data the study recorded — such as which mutational signatures (like sun damage or smoking) shaped each tumour, or which chromosome arms were gained or lost. The measurements at the top differ most clearly between the groups; each one shows its average, or its mix of results, in every group, so a difference that is clear but small reads as small." + "text": "Walks along every chromosome and shows, at each stretch, what share of the group's tumours have extra copies of that stretch of DNA (drawn upward, in red) or have lost copies of it (drawn downward, in blue). Stretches gained or lost in many tumours often hold genes that help the cancer grow, or genes that would have held it back.", + "title": "What is this?" }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Generic assays — A vs B" - }, - "composition": { + "isNew": true, + "maxCohorts": 4, "menu": { - "label": "Composition (cell types, states, signatures as parts of a whole)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no generic-assay profile and no numeric attribute to draw as parts", - "loading": false + "label": "Genome-wide copy number", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "local", + "preview": null, + "sub": "from copy-number segments", + "title": "Genome-wide copy number" + }, + "germline": { "added": { - "kind": "composition", - "profileType": "mutational_signatures_contribution_v2", "cohorts": [ "A" - ] + ], + "kind": "germline" }, "addedBareStudy": { - "kind": "composition", - "attributes": [], "cohorts": [ "A" - ] + ], + "kind": "germline" }, "addedWithTwoCohorts": { - "kind": "composition", - "profileType": "mutational_signatures_contribution_v2", - "cohorts": [] - }, - "maxCohorts": 8, - "emptyHint": "It compares 1 to 8 cohorts.", - "title": "Composition (cell types, states, signatures as parts of a whole)", - "headTitle": "Composition", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "germline" }, "card": { - "label": "Composition (cell types, states, signatures as parts of a whole)", - "title": "Composition", - "maxCohorts": 8, - "minCohorts": 1, - "why": null, - "whyBareStudy": "this study has no generic-assay profile and no numeric attribute to draw as parts", "defaults": { - "kind": "composition", - "profileType": "mutational_signatures_contribution_v2" + "kind": "germline" }, "defaultsBareStudy": { - "kind": "composition", - "attributes": [] - } + "kind": "germline" + }, + "label": "Germline carriers (and their somatic second hits)", + "maxCohorts": 4, + "title": "Germline carriers", + "why": null, + "whyBareStudy": null + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "mutational_signatures_contribution_v2, as shares", + "emptyHint": "It draws up to 4 cohorts, side by side.", + "figureTitle": "Germline carriers and second hits — A vs B", + "headTitle": "Germline carriers", "help": { - "title": "What is this?", - "text": "Shows what each patient's (or sample's) tumour is made of — the share of each cell type, cell state or signature — as one stacked bar per case, sorted by the part you choose, with each group's average beside it. Under the bars, every group is set against the others part by part: the colour says whether a part is higher (red) or lower (blue) there, its size how sure that is. Parts of a whole move together, so one part rising means others fall." + "text": "Some people are born with a faulty copy of a cancer gene such as BRCA2 in every cell. For each such gene this counts those patients (carriers) among everyone whose tumour was sequenced for it, and asks whether their tumour knocked out the remaining good copy — a second hit — by a further mutation or by deleting the gene, compared with how often the same gene is hit in patients born without a faulty copy. The commonest second hit, losing the good copy outright (loss of heterozygosity), needs data this view does not read, so the second-hit share is a minimum. Optionally the curves compare survival of carriers and non-carriers for one gene.", + "title": "What is this?" }, - "need": "local", - "preview": null, "isNew": true, - "figureTitle": "Composition — A vs B" - }, - "correlates": { + "maxCohorts": 4, "menu": { - "label": "Co-expression with one gene (genome-wide)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no continuous molecular profile (mRNA, protein, …) to correlate on", - "loading": false + "label": "Germline carriers (and their somatic second hits)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, - "added": { - "kind": "correlates", - "gene": "TP53", - "profileType": "rna_seq_v2_mrna", - "cohorts": [ - "A" - ] + "menuBareStudy": { + "loading": false, + "why": null }, - "addedBareStudy": { - "kind": "correlates", - "gene": "TP53", - "profileType": "", + "need": "local", + "preview": "no method record or golden test of the carrier and second-hit counts or the Fisher tests yet", + "sub": "germline carriers per patient sequenced, every gene with ≥3 carriers · somatic second hit vs non-carriers", + "title": "Germline carriers (and their somatic second hits)" + }, + "hla": { + "added": { "cohorts": [ "A" - ] + ], + "covariates": [ + "TMB_NONSYNONYMOUS" + ], + "kind": "hla" }, - "addedWithTwoCohorts": { - "kind": "correlates", - "gene": "TP53", - "profileType": "rna_seq_v2_mrna", - "cohorts": [] + "addedBareStudy": { + "cohorts": [ + "A" + ], + "kind": "hla" }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Co-expression with one gene (genome-wide)", - "headTitle": "Co-expression", - "drawsOwnHead": { - "error": true, - "withData": true + "addedWithTwoCohorts": { + "cohorts": [], + "covariates": [ + "TMB_NONSYNONYMOUS" + ], + "kind": "hla" }, "card": { - "label": "Co-expression with one gene (genome-wide)", - "title": "Co-expression", - "maxCohorts": 1, - "why": null, - "whyBareStudy": "this study has no continuous molecular profile (mRNA, protein, …) to correlate on", "defaults": { - "kind": "correlates", - "gene": "TP53", - "profileType": "rna_seq_v2_mrna" + "covariates": [ + "TMB_NONSYNONYMOUS" + ], + "kind": "hla" }, "defaultsBareStudy": { - "kind": "correlates", - "gene": "TP53", - "profileType": "" - } + "kind": "hla" + }, + "label": "HLA class I genotype (homozygosity, HLA LOH and OS)", + "maxCohorts": 3, + "title": "HLA class I genotype", + "why": null, + "whyBareStudy": "this study records no HLA class I alleles" + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "TP53 on rna_seq_v2_mrna · Spearman ρ, every gene", + "emptyHint": "It draws up to 3 cohorts, side by side.", + "figureTitle": "HLA class I genotype and OS — A vs B", + "headTitle": "HLA class I genotype", "help": { - "title": "What is this?", - "text": "Takes one gene and checks every other gene for whether its activity rises and falls along with it across these patients. The genes at the top go up when it goes up; those at the bottom go down when it goes up. The chart on the left shows how all genes fall: most barely move with it, and the ones far out on either side are the ones listed. Moving together is a clue, not proof that one gene controls the other." + "text": "HLA class I molecules (HLA-A, -B and -C) show pieces of a tumour's proteins to the immune system. Everyone inherits two copies of each; a person who inherited two identical copies at a gene (homozygous) shows a narrower range of pieces, and a tumour can also lose one copy (HLA loss of heterozygosity) to hide. This shows how common each is in a cohort, and whether patients homozygous at any of the three genes, or whose tumour lost a copy, lived shorter or longer. In patients treated with immunotherapy, homozygosity has been linked to worse survival; in a cohort not limited to them the comparison describes prognosis, not response to treatment.", + "title": "What is this?" }, - "need": "local", - "preview": null, - "isNew": false, - "figureTitle": "Co-expression — A vs B" - }, - "table": { + "isNew": true, + "maxCohorts": 3, "menu": { - "label": "Clinical data table (download)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "HLA class I genotype (homozygosity, HLA LOH and OS)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." }, + "menuBareStudy": { + "loading": false, + "why": "this study records no HLA class I alleles" + }, + "need": "patients", + "preview": "no method record or golden test of the homozygosity and LOH counts or the survival splits yet", + "sub": "alleles at 2 fields · OS by homozygosity and by HLA LOH · adjusted for TMB_NONSYNONYMOUS", + "title": "HLA class I genotype (homozygosity, HLA LOH and OS)" + }, + "hotspots": { "added": { - "kind": "table", "cohorts": [ "A" - ] + ], + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11" + ], + "kind": "hotspots", + "minCount": 1 }, "addedBareStudy": { - "kind": "table", "cohorts": [ "A" - ] + ], + "genes": [], + "kind": "hotspots", + "minCount": 1 }, "addedWithTwoCohorts": { - "kind": "table", - "cohorts": [] + "cohorts": [], + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11" + ], + "kind": "hotspots", + "minCount": 1 }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Clinical data table (download)", - "headTitle": "Clinical data", + "card": null, "drawsOwnHead": { - "error": true, + "error": false, "withData": true }, - "card": { - "label": "Clinical data table (download)", - "title": "Clinical data", - "maxCohorts": 6, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "table" - }, - "defaultsBareStudy": { - "kind": "table" - } - }, - "drawsOwnWaiting": true, - "sub": "one row per sample", + "drawsOwnWaiting": false, + "emptyHint": "It draws one cohort.", + "figureTitle": "Recurrent protein changes — A vs B", + "headTitle": "Mutations", "help": { - "title": "What is this?", - "text": "Lists every sample (or every patient) in your groups with everything the study recorded about them — cancer type, age, stage, survival and the rest — one row each. Click a column's name to sort by it; the download button saves every row as a file a spreadsheet can open. Studies that only share counts, never individual patients, are named rather than listed." + "text": "Shows exactly where along a gene's protein its mutations land, drawn as a lollipop chart — a taller stem means more patients had a mutation at that exact spot. Mutations clustering tightly at one or two spots, rather than scattering evenly, usually points to a location that matters for how the protein works.", + "title": "What is this?" }, - "need": "patients", - "preview": null, "isNew": false, - "figureTitle": "table — A vs B" - }, - "mutations": { + "maxCohorts": 1, "menu": { - "label": "Mutation table (one gene)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Lollipop (hotspots)", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + "why": null + }, + "menuBareStudy": { + "loading": false, + "why": null }, + "need": null, + "preview": null, + "sub": null, + "title": "Lollipop (hotspots)" + }, + "incidence": { "added": { - "kind": "mutations", - "gene": "TP53", "cohorts": [ "A" - ] + ], + "event": { + "eventType": "Diagnosis" + }, + "kind": "incidence" }, "addedBareStudy": { - "kind": "mutations", - "gene": "TP53", "cohorts": [ "A" - ] + ], + "event": { + "eventType": "" + }, + "kind": "incidence" }, "addedWithTwoCohorts": { - "kind": "mutations", - "gene": "TP53", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Mutation table (one gene)", - "headTitle": "Mutations", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "event": { + "eventType": "Diagnosis" + }, + "kind": "incidence" }, "card": { - "label": "Mutation table (one gene)", - "title": "Mutations", - "maxCohorts": 6, - "why": null, - "whyBareStudy": null, "defaults": { - "kind": "mutations", - "gene": "TP53" + "event": { + "eventType": "Diagnosis" + }, + "kind": "incidence" }, "defaultsBareStudy": { - "kind": "mutations", - "gene": "TP53" - } + "event": { + "eventType": "" + }, + "kind": "incidence" + }, + "label": "Cumulative incidence (competing risks)", + "maxCohorts": 6, + "title": "Cumulative incidence", + "why": null, + "whyBareStudy": "this study records no timeline events to estimate the incidence of" }, - "drawsOwnWaiting": true, - "sub": "TP53 · every mutation call", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "drawsOwnWaiting": false, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "figureTitle": "Cumulative incidence — A vs B", + "headTitle": "Cumulative incidence", "help": { - "title": "What is this?", - "text": "Lists every mutation found in one gene in your groups, one row per mutation: which sample it was in, the exact protein change, whether OncoKB considers it cancer-causing, what kind of change it is, and how much of the tumour carries it. Click a column to sort by it; the download saves every row. It sits under the lollipop chart, which shows the same mutations as positions along the protein." + "text": "Shows what share of patients have experienced a particular event — such as the cancer spreading to the brain — by each point in time. It counts patients who died before the event as never having it, rather than pretending they might still, which is why its numbers can be noticeably lower, and more honest, than a simple survival-style curve would suggest.", + "title": "What is this?" }, - "need": "patients", - "preview": null, - "isNew": false, - "figureTitle": "mutations — A vs B" - }, - "alterations": { + "isNew": true, + "maxCohorts": 6, "menu": { - "label": "Patient alterations (mutations, copy number, SVs; one patient at a time)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Cumulative incidence (competing risks)", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + "why": null + }, + "menuBareStudy": { + "loading": false, + "why": "this study records no timeline events to estimate the incidence of" }, + "need": "local", + "preview": null, + "sub": "Diagnosis from day 0, death competing", + "title": "Cumulative incidence (competing risks)" + }, + "instability": { "added": { - "kind": "alterations", "cohorts": [ "A" - ] + ], + "kind": "instability" }, "addedBareStudy": { - "kind": "alterations", "cohorts": [ "A" - ] + ], + "kind": "instability" }, "addedWithTwoCohorts": { - "kind": "alterations", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Patient alterations (mutations, copy number, SVs; one patient at a time)", - "headTitle": "Patient alterations", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "instability" }, "card": { - "label": "Patient alterations (mutations, copy number, SVs; one patient at a time)", - "title": "Patient alterations", - "maxCohorts": 1, - "cohortInHeader": true, - "why": null, - "whyBareStudy": null, "defaults": { - "kind": "alterations" + "kind": "instability" }, "defaultsBareStudy": { - "kind": "alterations" - } + "kind": "instability" + }, + "label": "Genomic instability (aneuploidy score, FGA, arm-level calls)", + "maxCohorts": 6, + "title": "Genomic instability", + "why": null, + "whyBareStudy": null }, - "drawsOwnWaiting": true, - "sub": "(element)", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "drawsOwnWaiting": false, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "figureTitle": "Genomic instability — aneuploidy score and FGA — A vs B", + "headTitle": "Genomic instability", "help": { - "title": "What is this?", - "text": "Lists one patient's genetic changes as cBioPortal's patient page does, in three tables: mutations (the gene, the protein change, whether OncoKB considers it cancer-causing, and how much of the tumour carries it), copy-number changes (genes amplified or deleted outright) and structural variants (fusions and rearrangements). A change found in several of the patient's samples is one row, with a numbered marker for each sample. Cohort % says how common the same kind of change in that gene is across the study. Each table can be searched, filtered, sorted and downloaded on its own. Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one." + "text": "Cancer cells often gain or lose whole chromosome arms, and how much of a tumour's genome is out of balance is a measure of how unstable it is. For each group of tumours, this shows how many chromosome arms are typically gained or lost (the \"aneuploidy score\") and what share of the genome is altered, then which arms are gained (red) or lost (blue) most often — patterns that differ by cancer type, such as chromosome 20q gained and 18q lost in colorectal cancer.", + "title": "What is this?" }, - "need": "alterations", - "preview": null, - "isNew": false, - "figureTitle": "Patient alterations — A vs B" - }, - "timeline": { + "isNew": true, + "maxCohorts": 6, "menu": { - "label": "Patient timeline (one patient at a time)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study records no timeline events", - "loading": false + "label": "Genomic instability (aneuploidy score, FGA, arm-level calls)", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." - }, - "added": { - "kind": "timeline", - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "timeline", - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "timeline", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Patient timeline (one patient at a time)", - "headTitle": "Patient timeline", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Patient timeline (one patient at a time)", - "title": "Patient timeline", - "maxCohorts": 1, - "cohortInHeader": true, - "why": null, - "whyBareStudy": "this study records no timeline events", - "defaults": { - "kind": "timeline" - }, - "defaultsBareStudy": { - "kind": "timeline" - } - }, - "drawsOwnWaiting": true, - "sub": "(element)", - "help": { - "title": "What is this?", - "text": "Shows one patient's medical history on a single line of time, as cBioPortal's patient page does: when samples were taken, operations, each treatment and how long it lasted, diagnoses, and lab results drawn as small charts. Time is counted from the day of diagnosis, and long stretches with nothing recorded are cut short (the dashed line). Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one. Studies that only share counts, never individual patients, are named rather than listed." - }, - "need": "timeline", - "preview": null, - "isNew": false, - "figureTitle": "Patient timeline — A vs B" - }, - "acquired": { - "menu": { - "label": "Acquired under therapy (samples before vs after a treatment start)", - "why": "this study records no treatment timeline events to anchor on", - "loading": false + "why": null }, "menuBareStudy": { - "why": "this study records no treatment timeline events to anchor on", - "loading": false - }, - "menuAggregateOnly": { - "why": "this study records no treatment timeline events to anchor on" + "loading": false, + "why": null }, + "need": "local", + "preview": "no method record, and no check of the arm calls and aneuploidy scores against Taylor et al.'s published values yet", + "sub": "from hg19 segments, ±0.2, arm called past 50% of its length", + "title": "Genomic instability (aneuploidy score, FGA, arm-level calls)" + }, + "labs": { "added": { - "kind": "acquired", "anchor": { "eventType": "Treatment" }, "cohorts": [ "A" - ] + ], + "kind": "labs", + "test": "PSA" }, "addedBareStudy": { - "kind": "acquired", "anchor": { "eventType": "Treatment" }, "cohorts": [ "A" - ] + ], + "kind": "labs", + "test": "PSA" }, "addedWithTwoCohorts": { - "kind": "acquired", "anchor": { "eventType": "Treatment" }, - "cohorts": [] - }, - "maxCohorts": 3, - "emptyHint": "It draws up to 3 cohorts, side by side.", - "title": "Acquired under therapy (samples before vs after a treatment start)", - "headTitle": "Acquired under therapy", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "labs", + "test": "PSA" }, "card": { - "label": "Acquired under therapy (samples before vs after a treatment start)", - "title": "Acquired under therapy", - "genes": true, - "maxCohorts": 3, - "why": null, - "whyBareStudy": "this study records no treatment timeline events to anchor on", "defaults": { - "kind": "acquired", "anchor": { "eventType": "Treatment" - } + }, + "kind": "labs", + "test": "PSA" }, "defaultsBareStudy": { - "kind": "acquired", "anchor": { "eventType": "Treatment" - } - } + }, + "kind": "labs", + "test": "PSA" + }, + "label": "Lab course and response (e.g. PSA50)", + "maxCohorts": 4, + "title": "Lab course", + "why": null, + "whyBareStudy": "this study records no lab-test timeline events" + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "last sample before the first Treatment vs the first ≥30 days after, any alteration, every gene scanned", + "emptyHint": "It draws up to 4 cohorts, side by side.", + "figureTitle": "Lab course — A vs B", + "headTitle": "Lab course", "help": { - "title": "What is this?", - "text": "For patients who had a tumour sample tested before a treatment started and another one afterwards, shows gene by gene how many had a change only in the later sample (acquired, to the right) or only in the earlier one (lost, to the left). Genes that keep turning up only after treatment can point to how tumours resist it. It cannot say the treatment caused the change: second biopsies are usually taken when the cancer grows again, and a change can simply have been missed the first time." + "text": "Follows a blood test — such as PSA in prostate cancer — from the moment a treatment started, as the change from each patient's own starting level. The line shows the typical patient, the shaded band the middle half of patients, and the bars on the right each patient's best result: how far their level fell, or rose, after treatment began.", + "title": "What is this?" }, - "need": "local", - "preview": "no method record or golden test of the before/after pairing and McNemar counts yet", "isNew": true, - "figureTitle": "Acquired under therapy — A vs B" - }, - "ctdna": { + "maxCohorts": 4, "menu": { - "label": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Lab course and response (e.g. PSA50)", + "loading": false, + "why": "this study records no lab-test timeline events" }, "menuAggregateOnly": { - "why": null + "why": "this study records no lab-test timeline events" + }, + "menuBareStudy": { + "loading": false, + "why": "this study records no lab-test timeline events" }, + "need": "local", + "preview": null, + "sub": "PSA from first Treatment · responders fell ≥50%", + "title": "Lab course and response (e.g. PSA50)" + }, + "logistic": { "added": { - "kind": "ctdna", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF", - "ERBB2" - ], "cohorts": [ "A" - ] + ], + "covariates": [ + { + "attribute": "TMB_NONSYNONYMOUS", + "source": "clinical" + } + ], + "kind": "logistic", + "outcome": { + "by": "mutated", + "gene": "TP53", + "source": "alteration" + }, + "outcomeEvent": "Mutated" }, "addedBareStudy": { - "kind": "ctdna", "cohorts": [ "A" - ] + ], + "covariates": [ + { + "attribute": "SEX", + "source": "clinical" + } + ], + "kind": "logistic", + "outcome": { + "by": "mutated", + "gene": "TP53", + "source": "alteration" + }, + "outcomeEvent": "Mutated" }, "addedWithTwoCohorts": { - "kind": "ctdna", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF", - "ERBB2" + "cohorts": [], + "covariates": [ + { + "attribute": "TMB_NONSYNONYMOUS", + "source": "clinical" + } ], - "cohorts": [] + "kind": "logistic", + "outcome": { + "by": "mutated", + "gene": "TP53", + "source": "alteration" + }, + "outcomeEvent": "Mutated" }, - "maxCohorts": 3, - "emptyHint": "It draws up to 3 cohorts, side by side.", - "title": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", - "headTitle": "ctDNA vs tissue", + "card": null, "drawsOwnHead": { "error": true, "withData": true }, - "card": { - "label": "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", - "title": "ctDNA vs tissue", - "genes": true, - "maxCohorts": 3, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "ctdna", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF", - "ERBB2" - ] - }, - "defaultsBareStudy": { - "kind": "ctdna" - } - }, "drawsOwnWaiting": false, - "sub": "plasma SAMPLE_CLASS = cfDNA/ctDNA vs tissue Tumor, any alteration", + "emptyHint": "Add one cohort, or several to compare them.", + "figureTitle": "Logistic regression — A vs B", + "headTitle": "Logistic regression", "help": { - "title": "What is this?", - "text": "Tumours shed DNA into the blood, and a blood test (a \"liquid biopsy\") can read it. For patients who had both a blood sample and a tumour tissue sample tested, this shows gene by gene how often a change was found in both, only in the tissue, or only in the blood — and how often the blood test caught what the tissue showed. Below, it shows how many patients had tumour DNA detected in their blood at all, and whether those patients lived shorter or longer than those without it. With the clonal-haematopoiesis filter on, changes that probably come from ageing blood cells rather than the tumour — in genes such as DNMT3A and TET2, or also found in the patient's white blood cells — are left out first, and the figure says how many were removed and what the numbers were without the filter." + "text": "Estimates how strongly different factors are linked to a yes-or-no outcome — for example, whether a patient responded to a treatment — while accounting for several factors together. Each result is an \"odds ratio\": above 1 means that factor makes the outcome more likely, below 1 means less likely.", + "title": "What is this?" }, - "need": "local", - "preview": "no method record or golden test of the agreement counts, Wilson intervals and survival split, or of the clonal-haematopoiesis filter (chFilter) yet", "isNew": true, - "figureTitle": "ctDNA vs tissue — A vs B" - }, - "exclusivity": { + "maxCohorts": null, "menu": { - "label": "Mutual exclusivity (DISCOVER, burden-aware)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Logistic regression (odds ratios)", + "loading": false, + "why": null }, "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "menuBareStudy": { + "loading": false, "why": null }, + "need": "patients", + "preview": null, + "sub": null, + "title": "Logistic regression (odds ratios)" + }, + "molsubtype": { "added": { - "kind": "exclusivity", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], "cohorts": [ "A" - ] + ], + "kind": "molsubtype" }, "addedBareStudy": { - "kind": "exclusivity", - "genes": [ - "KRAS", - "EGFR", - "BRAF", - "STK11", - "KEAP1", - "TP53" - ], "cohorts": [ "A" - ] + ], + "kind": "molsubtype" }, "addedWithTwoCohorts": { - "kind": "exclusivity", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ], - "cohorts": [] + "cohorts": [], + "kind": "molsubtype" + }, + "card": { + "defaults": { + "kind": "molsubtype" + }, + "defaultsBareStudy": { + "kind": "molsubtype" + }, + "label": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", + "maxCohorts": 6, + "title": "Molecular subtypes", + "why": null, + "whyBareStudy": null }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Mutual exclusivity (DISCOVER, burden-aware)", - "headTitle": "Mutual exclusivity", "drawsOwnHead": { "error": true, "withData": true }, - "card": { - "label": "Mutual exclusivity (DISCOVER, burden-aware)", - "title": "Mutual exclusivity", - "genes": true, - "maxCohorts": 1, - "cohortInHeader": true, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "exclusivity", - "genes": [ - "TP53", - "KRAS", - "EGFR", - "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" - ] - }, - "defaultsBareStudy": { - "kind": "exclusivity", - "genes": [ - "KRAS", - "EGFR", - "BRAF", - "STK11", - "KEAP1", - "TP53" - ] - } - }, "drawsOwnWaiting": false, - "sub": "(element)", + "emptyHint": "It draws up to 6 cohorts, side by side.", + "figureTitle": "Molecular subtypes — A vs B", + "headTitle": "Molecular subtypes", "help": { - "title": "What is this?", - "text": "Asks, for each pair of genes, whether tumours carry changes in both more or less often than chance — \"mutually exclusive\" genes are rarely changed together, which can mean either change alone is enough to drive the cancer. Its sense of \"chance\" accounts for how many changes each tumour has overall: a tumour with hundreds of changes is expected to share genes with many others, so it does not make two genes look linked just because they both turn up in heavily altered tumours. Beside it is the simpler test that ignores this, to show the difference." + "text": "Sorts each patient's endometrial cancer into the four molecular groups doctors now use alongside the microscope: POLE-ultramutated (a tumour with a broken DNA-copying enzyme, which does very well), mismatch-repair deficient (a tumour that cannot fix certain DNA errors, read here from its MSI test), p53-abnormal (the group that does worst), and no specific molecular profile. The groups are assigned in that order, as the WHO guideline does. Patients whose test could not tell — no MSI result, or a gene panel that missed POLE or TP53 — are counted as not classifiable rather than guessed. The bars show each group's share, overall and by the attribute you pick; the curves show survival in each group.", + "title": "What is this?" }, - "need": "local", - "preview": "no method record or golden test of the DISCOVER background fit against the R package yet", "isNew": true, - "figureTitle": "Mutual exclusivity (DISCOVER) — A vs B" - }, - "txendpoints": { + "maxCohorts": 6, "menu": { - "label": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", - "why": "this study records no treatment timeline events", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no treatment timeline events", - "loading": false + "label": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": "this study records no treatment timeline events" - }, - "added": { - "kind": "txendpoints", - "endpoint": "ttnt", - "anchor": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, - "cohorts": [ - "A" - ] - }, - "addedBareStudy": { - "kind": "txendpoints", - "endpoint": "ttnt", - "anchor": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, - "cohorts": [ - "A" - ] - }, - "addedWithTwoCohorts": { - "kind": "txendpoints", - "endpoint": "ttnt", - "anchor": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", - "headTitle": "Treatment endpoints", - "drawsOwnHead": { - "error": true, - "withData": true - }, - "card": { - "label": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", - "title": "Treatment endpoints", - "maxCohorts": 6, - "why": null, - "whyBareStudy": "this study records no treatment timeline events", - "defaults": { - "kind": "txendpoints", - "endpoint": "ttnt", - "anchor": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - } - }, - "defaultsBareStudy": { - "kind": "txendpoints", - "endpoint": "ttnt", - "anchor": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - } - } + "why": null }, - "drawsOwnWaiting": false, - "sub": "time to next treatment from first Treatment [SUBTYPE=Immuno], death an event", - "help": { - "title": "What is this?", - "text": "Starts each patient's clock on the day a treatment began — for example their first immunotherapy — and follows them to an outcome that real-world studies use in place of a trial's: when they moved on to their next treatment (time to next treatment), when they stopped this one (time on treatment), or when their cancer was recorded as growing (real-world progression-free survival). Death counts as the outcome too, and patients still followed without it are counted up to their last visit. Each group's curve shows the share not yet there at each month, with the number still followed below. The groups were not randomised to the treatment, so a gap between curves is a clue, not proof that a gene or a group changes how well the treatment works." + "menuBareStudy": { + "loading": false, + "why": null }, "need": "local", - "preview": "no method record or golden test of the endpoint derivation (lines, stops, progression) or the median intervals yet", - "isNew": true, - "figureTitle": "Treatment endpoints — A vs B" + "preview": "no method record or golden test of the per-patient classification, the chi-squared tests or the survival split yet", + "sub": "endometrial molecular classification per patient (POLE, MMRd, p53abn, NSMP) · OS by subtype", + "title": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)" }, "multistate": { - "menu": { - "label": "Multistate model (progression, then death)", - "why": "this study records no diagnosis or progression timeline events", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no diagnosis or progression timeline events", - "loading": false - }, - "menuAggregateOnly": { - "why": "this study records no diagnosis or progression timeline events" - }, "added": { + "cohorts": [ + "A" + ], "kind": "multistate", "progression": { "eventType": "Diagnosis", @@ -2639,12 +2339,12 @@ "Y", "Yes" ] - }, - "cohorts": [ - "A" - ] + } }, "addedBareStudy": { + "cohorts": [ + "A" + ], "kind": "multistate", "progression": { "eventType": "Diagnosis", @@ -2653,12 +2353,10 @@ "Y", "Yes" ] - }, - "cohorts": [ - "A" - ] + } }, "addedWithTwoCohorts": { + "cohorts": [], "kind": "multistate", "progression": { "eventType": "Diagnosis", @@ -2667,23 +2365,9 @@ "Y", "Yes" ] - }, - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Multistate model (progression, then death)", - "headTitle": "Multistate model", - "drawsOwnHead": { - "error": true, - "withData": true + } }, "card": { - "label": "Multistate model (progression, then death)", - "title": "Multistate model", - "maxCohorts": 4, - "why": null, - "whyBareStudy": "this study records no diagnosis or progression timeline events", "defaults": { "kind": "multistate", "progression": { @@ -2705,139 +2389,299 @@ "Yes" ] } - } + }, + "label": "Multistate model (progression, then death)", + "maxCohorts": 4, + "title": "Multistate model", + "why": null, + "whyBareStudy": "this study records no diagnosis or progression timeline events" + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "start → progression → death, months from day 0 · progression = Diagnosis PROGRESSION", + "emptyHint": "It draws up to 4 cohorts, side by side.", + "figureTitle": "Multistate model — progression and death — A vs B", + "headTitle": "Multistate model", "help": { - "title": "What is this?", - "text": "Follows each patient through three states — alive without progression, alive after their cancer was first recorded as growing, and dead — from the start of their record (in MSK-CHORD, their first tumour sequencing). The coloured bands show, month by month, what share of each group is in each state; the lines below show how fast patients move along each of the three paths (progressing, dying without progressing, dying after progressing), and the table gives each path's rate and how much faster or slower each group moves along it than the first. Splitting death into 'after progression' and 'without it' shows where a group's worse survival comes from. Progression is read from radiology reports by a model, so it is only seen in patients who were scanned; the groups were not randomised, so a difference is a clue, not proof." + "text": "Follows each patient through three states — alive without progression, alive after their cancer was first recorded as growing, and dead — from the start of their record (in MSK-CHORD, their first tumour sequencing). The coloured bands show, month by month, what share of each group is in each state; the lines below show how fast patients move along each of the three paths (progressing, dying without progressing, dying after progressing), and the table gives each path's rate and how much faster or slower each group moves along it than the first. Splitting death into 'after progression' and 'without it' shows where a group's worse survival comes from. Progression is read from radiology reports by a model, so it is only seen in patients who were scanned; the groups were not randomised, so a difference is a clue, not proof.", + "title": "What is this?" }, - "need": "local", - "preview": "no method record or golden test of the Aalen–Johansen occupation, Nelson–Aalen hazards or the delayed-entry Cox fits yet (no interval on the occupation)", "isNew": true, - "figureTitle": "Multistate model — progression and death — A vs B" - }, - "rmst": { + "maxCohorts": 4, "menu": { - "label": "Restricted mean OS time (RMST, landmarks)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no _MONTHS outcome column", - "loading": false + "label": "Multistate model (progression, then death)", + "loading": false, + "why": "this study records no diagnosis or progression timeline events" }, "menuAggregateOnly": { - "why": null + "why": "this study records no diagnosis or progression timeline events" + }, + "menuBareStudy": { + "loading": false, + "why": "this study records no diagnosis or progression timeline events" }, + "need": "local", + "preview": "no method record or golden test of the Aalen–Johansen occupation, Nelson–Aalen hazards or the delayed-entry Cox fits yet (no interval on the occupation)", + "sub": "start → progression → death, months from day 0 · progression = Diagnosis PROGRESSION", + "title": "Multistate model (progression, then death)" + }, + "mutations": { "added": { - "kind": "rmst", "cohorts": [ "A" - ] + ], + "gene": "TP53", + "kind": "mutations" }, "addedBareStudy": { - "kind": "rmst", "cohorts": [ "A" - ] + ], + "gene": "TP53", + "kind": "mutations" }, "addedWithTwoCohorts": { - "kind": "rmst", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Restricted mean OS time (RMST, landmarks)", - "headTitle": "Restricted mean OS time", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "gene": "TP53", + "kind": "mutations" }, "card": { - "label": "Restricted mean OS time (RMST, landmarks)", - "title": "Restricted mean OS time", - "maxCohorts": 6, - "why": null, - "whyBareStudy": "this study has no _MONTHS outcome column", "defaults": { - "kind": "rmst" + "gene": "TP53", + "kind": "mutations" }, "defaultsBareStudy": { - "kind": "rmst" - } + "gene": "TP53", + "kind": "mutations" + }, + "label": "Mutation table (one gene)", + "maxCohorts": 6, + "title": "Mutations", + "why": null, + "whyBareStudy": null }, - "drawsOwnWaiting": false, - "sub": "area under the KM curve to τ where every cohort has 10 at risk · OS", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "drawsOwnWaiting": true, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "figureTitle": "mutations — A vs B", + "headTitle": "Mutations", "help": { - "title": "What is this?", - "text": "Answers “how many months, on average, did patients in each group live within the first τ months?” — the shaded area under each survival curve up to τ. The difference between two groups is the months gained or lost in that window, which reads plainly even when the curves cross or only separate late (as with immunotherapy), where a single hazard ratio can mislead. τ is where every group still has at least ten patients followed, unless it is set. Landmark panels ask the same question again among the patients still alive and followed at 6 or 12 months, restarting the clock there. The groups were not randomised, so a difference is a clue, not proof." + "text": "Lists every mutation found in one gene in your groups, one row per mutation: which sample it was in, the exact protein change, whether OncoKB considers it cancer-causing, what kind of change it is, and how much of the tumour carries it. Click a column to sort by it; the download saves every row. It sits under the lollipop chart, which shows the same mutations as positions along the protein.", + "title": "What is this?" }, - "need": "local", - "preview": "no method record or golden test of the restricted means, their variances and the landmark sets against R survRM2 yet", - "isNew": true, - "figureTitle": "Restricted mean survival time — A vs B" - }, - "predictive": { + "isNew": false, + "maxCohorts": 6, "menu": { - "label": "Predictive biomarkers (gene × treatment interaction)", - "why": "this study records no treatment timeline events to define an exposure", - "loading": false - }, - "menuBareStudy": { - "why": "this study records no treatment timeline events to define an exposure", - "loading": false + "label": "Mutation table (one gene)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "patients", + "preview": null, + "sub": "TP53 · every mutation call", + "title": "Mutation table (one gene)" + }, + "oncoprint": { "added": { - "kind": "predictive", - "exposure": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, + "cohorts": [ + "A" + ], "genes": [ "TP53", "KRAS", "EGFR", "STK11", - "KEAP1", - "PIK3CA", - "APC", - "BRAF" + "KEAP1" ], + "kind": "oncoprint" + }, + "addedBareStudy": { "cohorts": [ "A" - ] + ], + "genes": [], + "kind": "oncoprint" }, - "addedBareStudy": { - "kind": "predictive", - "exposure": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, + "addedWithTwoCohorts": { + "cohorts": [], "genes": [ - "STK11", - "KEAP1", - "KRAS", "TP53", - "EGFR" + "KRAS", + "EGFR", + "STK11", + "KEAP1" ], + "kind": "oncoprint" + }, + "card": null, + "drawsOwnHead": { + "error": false, + "withData": true + }, + "drawsOwnWaiting": false, + "emptyHint": "It draws up to 4 cohorts, side by side.", + "figureTitle": "Oncoprint — A vs B", + "headTitle": "Oncoprint", + "help": { + "text": "A grid with one column per patient and one row per gene, color-coded to show what kind of genetic change (if any) each patient has in each gene. It's a fast way to see patterns across many patients and genes at once — for example, whether certain alterations tend to show up in the same patients, or never together.", + "title": "What is this?" + }, + "isNew": false, + "maxCohorts": 4, + "menu": { + "label": "Oncoprint", + "loading": false, + "why": null + }, + "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "patients", + "preview": null, + "sub": null, + "title": "Oncoprint" + }, + "organotropism": { + "added": { "cohorts": [ "A" - ] + ], + "kind": "organotropism" + }, + "addedBareStudy": { + "cohorts": [ + "A" + ], + "kind": "organotropism" }, "addedWithTwoCohorts": { - "kind": "predictive", - "exposure": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" + "cohorts": [], + "kind": "organotropism" + }, + "card": { + "cohortInHeader": true, + "defaults": { + "kind": "organotropism" + }, + "defaultsBareStudy": { + "kind": "organotropism" }, + "label": "Metastatic organotropism (genes × metastatic sites, within cancer types)", + "maxCohorts": 1, + "title": "Metastatic organotropism", + "why": null, + "whyBareStudy": "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)" + }, + "drawsOwnHead": { + "error": true, + "withData": true + }, + "drawsOwnWaiting": false, + "emptyHint": "It draws one cohort.", + "figureTitle": "Organotropism — A vs B", + "headTitle": "Metastatic organotropism", + "help": { + "text": "Asks, for each organ a cancer spread to — brain, liver, bone and the rest — whether patients whose tumour carries a change in a given gene spread there more or less often than patients whose tumour does not, comparing patients within the same cancer type. Red squares mean the gene's change goes with spread to that organ, blue that it goes with spreading there less; outlined squares are the ones unlikely to be chance after testing many pairs. Where the study dates each site, the curve below shows how quickly patients with and without one gene's change develop spread to one organ, counting deaths as a separate outcome. Sites are only known where scans or biopsies found them.", + "title": "What is this?" + }, + "isNew": true, + "maxCohorts": 1, + "menu": { + "label": "Metastatic organotropism (genes × metastatic sites, within cancer types)", + "loading": false, + "why": null + }, + "menuAggregateOnly": { + "why": null + }, + "menuBareStudy": { + "loading": false, + "why": "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)" + }, + "need": "local", + "preview": "no method record or golden test of the per-cell Cochran–Mantel–Haenszel tests or the site incidence yet", + "sub": "driver mutations in the 12 most altered genes × metastatic sites from the study's site source, within CANCER_TYPE", + "title": "Metastatic organotropism (genes × metastatic sites, within cancer types)" + }, + "overlap": { + "added": { + "cohorts": [], + "kind": "overlap" + }, + "addedBareStudy": { + "cohorts": [], + "kind": "overlap" + }, + "addedWithTwoCohorts": { + "cohorts": [], + "kind": "overlap" + }, + "card": { + "defaults": { + "kind": "overlap" + }, + "defaultsBareStudy": { + "kind": "overlap" + }, + "label": "Cohort overlap (UpSet)", + "maxCohorts": 6, + "minCohorts": 2, + "title": "Cohort overlap", + "why": null, + "whyBareStudy": null + }, + "drawsOwnHead": { + "error": true, + "withData": true + }, + "drawsOwnWaiting": false, + "emptyHint": "It compares 2 to 6 cohorts.", + "figureTitle": "Cohort overlap — A vs B", + "headTitle": "Cohort overlap", + "help": { + "text": "Shows how the groups of patients you defined overlap: for every combination of groups, how many patients belong to exactly those groups and none of the others. Each column is one combination — the dots below it say which groups — and its bar is how many patients fall there, so you can see at a glance whether two groups are mostly the same people or nearly separate.", + "title": "What is this?" + }, + "isNew": false, + "maxCohorts": 6, + "menu": { + "label": "Cohort overlap (UpSet)", + "loading": false, + "why": null + }, + "menuAggregateOnly": { + "why": null + }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": "local", + "preview": null, + "sub": "patients in exactly each combination of cohorts", + "title": "Cohort overlap (UpSet)" + }, + "paired": { + "added": { + "cohorts": [ + "A" + ], "genes": [ "TP53", "KRAS", @@ -2848,30 +2692,40 @@ "APC", "BRAF" ], - "cohorts": [] + "kind": "paired" }, - "maxCohorts": 2, - "emptyHint": "It draws up to 2 cohorts, side by side.", - "title": "Predictive biomarkers (gene × treatment interaction)", - "headTitle": "Predictive biomarkers", - "drawsOwnHead": { - "error": true, - "withData": true + "addedBareStudy": { + "cohorts": [ + "A" + ], + "genes": [ + "TP53", + "KRAS", + "PIK3CA", + "ESR1", + "AR", + "CDKN2A", + "MYC", + "TERT" + ], + "kind": "paired" + }, + "addedWithTwoCohorts": { + "cohorts": [], + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "kind": "paired" }, "card": { - "label": "Predictive biomarkers (gene × treatment interaction)", - "title": "Predictive biomarkers", - "genes": true, - "maxCohorts": 2, - "why": null, - "whyBareStudy": "this study records no treatment timeline events to define an exposure", "defaults": { - "kind": "predictive", - "exposure": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, "genes": [ "TP53", "KRAS", @@ -2881,681 +2735,827 @@ "PIK3CA", "APC", "BRAF" - ] + ], + "kind": "paired" }, "defaultsBareStudy": { - "kind": "predictive", - "exposure": { - "eventType": "Treatment", - "key": "SUBTYPE", - "value": "Immuno" - }, "genes": [ - "STK11", - "KEAP1", - "KRAS", "TP53", - "EGFR" - ] - } + "KRAS", + "PIK3CA", + "ESR1", + "AR", + "CDKN2A", + "MYC", + "TERT" + ], + "kind": "paired" + }, + "genes": true, + "label": "Paired samples (primary vs metastasis, before vs after)", + "maxCohorts": 3, + "title": "Paired samples", + "why": null, + "whyBareStudy": null + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "gene × Treatment [SUBTYPE=Immuno] on OS · Cox per gene", + "emptyHint": "It draws up to 3 cohorts, side by side.", + "figureTitle": "Paired samples — A vs B", + "headTitle": "Paired samples", "help": { - "title": "What is this?", - "text": "Asks, gene by gene, whether a mutation changes how patients do on a particular treatment — not just whether it marks a better or worse outlook in general. For each gene it compares patients with and without the mutation twice: among those who received the treatment (upper marker) and among those who did not (lower marker). A gene whose markers sit far apart — say, much worse with the mutation only among the treated — may predict who benefits from that treatment; the right-hand column tests that difference. Treatment here was not assigned at random, and patients had to live long enough to receive it, so a finding is a lead to test properly, not proof." + "text": "For patients who had two kinds of tumour sample tested — usually the original tumour and a later spread of it elsewhere in the body — shows, gene by gene, how often a change was found in both samples, only in the original, or only in the spread. Changes found only in the spread can point to how the cancer adapted, for example to a treatment. The two kinds can also be samples taken before and after a treatment. Without a list of genes, it checks every gene and shows the ones most often found on one side only.", + "title": "What is this?" }, - "need": "patients", - "preview": "no method record or golden test of the per-gene interaction fits yet", "isNew": true, - "figureTitle": "Predictive biomarkers — A vs B" - }, - "cutpoint": { + "maxCohorts": 3, "menu": { - "label": "Biomarker cut-point (best split of a number for OS)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study has no numeric clinical attribute to find a cut-point of", - "loading": false + "label": "Paired samples (primary vs metastasis, before vs after)", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + "why": null + }, + "menuBareStudy": { + "loading": false, + "why": null }, + "need": "local", + "preview": null, + "sub": "SAMPLE_TYPE: Primary vs Metastasis, drivers", + "title": "Paired samples (primary vs metastasis, before vs after)" + }, + "pathways": { "added": { - "kind": "cutpoint", - "attribute": "TMB_NONSYNONYMOUS", "cohorts": [ "A" - ] + ], + "kind": "pathways" }, "addedBareStudy": { - "kind": "cutpoint", - "attribute": "TMB_NONSYNONYMOUS", "cohorts": [ "A" - ] + ], + "kind": "pathways" }, "addedWithTwoCohorts": { - "kind": "cutpoint", - "attribute": "TMB_NONSYNONYMOUS", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Biomarker cut-point (best split of a number for OS)", - "headTitle": "Cut-point", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "pathways" }, "card": { - "label": "Biomarker cut-point (best split of a number for OS)", - "title": "Cut-point", - "maxCohorts": 1, - "why": null, - "whyBareStudy": "this study has no numeric clinical attribute to find a cut-point of", "defaults": { - "kind": "cutpoint", - "attribute": "TMB_NONSYNONYMOUS" + "kind": "pathways" }, "defaultsBareStudy": { - "kind": "cutpoint", - "attribute": "TMB_NONSYNONYMOUS" - } + "kind": "pathways" + }, + "label": "Oncogenic pathways", + "maxCohorts": 4, + "title": "Oncogenic signalling pathways", + "why": null, + "whyBareStudy": null + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "TMB_NONSYNONYMOUS on OS · best log-rank cut, 1000 permutations", + "emptyHint": "It draws up to 4 cohorts, side by side.", + "figureTitle": "Oncogenic signalling pathways — A vs B", + "headTitle": "Oncogenic signalling pathways", "help": { - "title": "What is this?", - "text": "Looks for the value of a measurement — such as tumour mutational burden — that best splits patients into a group that did better and a group that did worse. It tries every reasonable dividing line (from the lowest tenth of patients to the highest) and draws how strongly each one separates survival; the dot marks the best. Because trying many lines and keeping the best always finds something, the view also reshuffles the measurement among the patients a thousand times to see how good a 'best line' chance alone produces, and reports how surprising the real best line is once that search is accounted for. A line found this way needs checking in other patients before anyone relies on it." + "text": "Groups genes into ten well-known signalling pathways — chains of genes that together tell a cell when to grow, divide or die — and shows what share of each group of patients has a harmful change in at least one gene of each pathway. Two cancers can reach the same broken pathway through different genes, and this view makes that visible.", + "title": "What is this?" }, - "need": "patients", - "preview": "no method record or golden test of the maximally selected log-rank scan against R maxstat yet", - "isNew": true, - "figureTitle": "Cut-point — A vs B" - }, - "hla": { + "isNew": false, + "maxCohorts": 4, "menu": { - "label": "HLA class I genotype (homozygosity, HLA LOH and OS)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study records no HLA class I alleles", - "loading": false + "label": "Oncogenic pathways", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + "why": null + }, + "menuBareStudy": { + "loading": false, + "why": null }, + "need": "local", + "preview": null, + "sub": "OncoKB drivers, Sanchez-Vega et al. 2018", + "title": "Oncogenic pathways" + }, + "plot": { "added": { - "kind": "hla", - "covariates": [ - "TMB_NONSYNONYMOUS" - ], "cohorts": [ "A" - ] + ], + "kind": "plot", + "unit": "sample", + "x": { + "attribute": "TMB_NONSYNONYMOUS", + "source": "clinical" + }, + "y": { + "attribute": "AGE", + "source": "clinical" + } }, "addedBareStudy": { - "kind": "hla", "cohorts": [ "A" - ] + ], + "kind": "plot", + "unit": "sample", + "x": { + "by": "mutationType", + "gene": "TP53", + "source": "alteration" + }, + "y": { + "by": "mutated", + "gene": "KRAS", + "source": "alteration" + } }, "addedWithTwoCohorts": { - "kind": "hla", - "covariates": [ - "TMB_NONSYNONYMOUS" - ], - "cohorts": [] + "cohorts": [], + "kind": "plot", + "unit": "sample", + "x": { + "attribute": "TMB_NONSYNONYMOUS", + "source": "clinical" + }, + "y": { + "attribute": "AGE", + "source": "clinical" + } }, - "maxCohorts": 3, - "emptyHint": "It draws up to 3 cohorts, side by side.", - "title": "HLA class I genotype (homozygosity, HLA LOH and OS)", - "headTitle": "HLA class I genotype", + "card": null, "drawsOwnHead": { "error": true, "withData": true }, + "drawsOwnWaiting": false, + "emptyHint": "It draws one cohort.", + "figureTitle": "plot — A vs B", + "headTitle": "Plot", + "help": { + "text": "Plots two measurements against each other for the same patients or samples — for example, a gene's activity level against a patient's age — so you can see whether they tend to rise and fall together, split into distinct clusters, or show no relationship at all.", + "title": "What is this?" + }, + "isNew": false, + "maxCohorts": 1, + "menu": { + "label": "Plot", + "loading": false, + "why": null + }, + "menuAggregateOnly": { + "why": null + }, + "menuBareStudy": { + "loading": false, + "why": "this study declares no clinical attributes to plot" + }, + "need": "local", + "preview": null, + "sub": null, + "title": "Plot" + }, + "predictive": { + "added": { + "cohorts": [ + "A" + ], + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "kind": "predictive" + }, + "addedBareStudy": { + "cohorts": [ + "A" + ], + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "STK11", + "KEAP1", + "KRAS", + "TP53", + "EGFR" + ], + "kind": "predictive" + }, + "addedWithTwoCohorts": { + "cohorts": [], + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "kind": "predictive" + }, "card": { - "label": "HLA class I genotype (homozygosity, HLA LOH and OS)", - "title": "HLA class I genotype", - "maxCohorts": 3, - "why": null, - "whyBareStudy": "this study records no HLA class I alleles", "defaults": { - "kind": "hla", - "covariates": [ - "TMB_NONSYNONYMOUS" - ] + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "TP53", + "KRAS", + "EGFR", + "STK11", + "KEAP1", + "PIK3CA", + "APC", + "BRAF" + ], + "kind": "predictive" }, "defaultsBareStudy": { - "kind": "hla" - } + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "STK11", + "KEAP1", + "KRAS", + "TP53", + "EGFR" + ], + "kind": "predictive" + }, + "genes": true, + "label": "Predictive biomarkers (gene × treatment interaction)", + "maxCohorts": 2, + "title": "Predictive biomarkers", + "why": null, + "whyBareStudy": "this study records no treatment timeline events to define an exposure" + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "alleles at 2 fields · OS by homozygosity and by HLA LOH · adjusted for TMB_NONSYNONYMOUS", + "emptyHint": "It draws up to 2 cohorts, side by side.", + "figureTitle": "Predictive biomarkers — A vs B", + "headTitle": "Predictive biomarkers", "help": { - "title": "What is this?", - "text": "HLA class I molecules (HLA-A, -B and -C) show pieces of a tumour's proteins to the immune system. Everyone inherits two copies of each; a person who inherited two identical copies at a gene (homozygous) shows a narrower range of pieces, and a tumour can also lose one copy (HLA loss of heterozygosity) to hide. This shows how common each is in a cohort, and whether patients homozygous at any of the three genes, or whose tumour lost a copy, lived shorter or longer. In patients treated with immunotherapy, homozygosity has been linked to worse survival; in a cohort not limited to them the comparison describes prognosis, not response to treatment." + "text": "Asks, gene by gene, whether a mutation changes how patients do on a particular treatment — not just whether it marks a better or worse outlook in general. For each gene it compares patients with and without the mutation twice: among those who received the treatment (upper marker) and among those who did not (lower marker). A gene whose markers sit far apart — say, much worse with the mutation only among the treated — may predict who benefits from that treatment; the right-hand column tests that difference. Treatment here was not assigned at random, and patients had to live long enough to receive it, so a finding is a lead to test properly, not proof.", + "title": "What is this?" }, - "need": "patients", - "preview": "no method record or golden test of the homozygosity and LOH counts or the survival splits yet", "isNew": true, - "figureTitle": "HLA class I genotype and OS — A vs B" - }, - "forest": { + "maxCohorts": 2, "menu": { - "label": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Predictive biomarkers (gene × treatment interaction)", + "loading": false, + "why": "this study records no treatment timeline events to define an exposure" }, "menuAggregateOnly": { "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." }, + "menuBareStudy": { + "loading": false, + "why": "this study records no treatment timeline events to define an exposure" + }, + "need": "patients", + "preview": "no method record or golden test of the per-gene interaction fits yet", + "sub": "gene × Treatment [SUBTYPE=Immuno] on OS · Cox per gene", + "title": "Predictive biomarkers (gene × treatment interaction)" + }, + "riskmodel": { "added": { - "kind": "forest", - "exposure": { - "source": "alteration", - "gene": "TP53" - }, "cohorts": [ "A" - ] + ], + "kind": "riskmodel" }, "addedBareStudy": { - "kind": "forest", - "exposure": { - "source": "alteration", - "gene": "TP53" - }, "cohorts": [ "A" - ] + ], + "kind": "riskmodel" }, "addedWithTwoCohorts": { - "kind": "forest", - "exposure": { - "source": "alteration", - "gene": "TP53" - }, - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", - "headTitle": "Forest plot", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "riskmodel" }, "card": { - "label": "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", - "title": "Forest plot", - "maxCohorts": 6, - "why": null, - "whyBareStudy": null, "defaults": { - "kind": "forest", - "exposure": { - "source": "alteration", - "gene": "TP53" - } + "kind": "riskmodel" }, "defaultsBareStudy": { - "kind": "forest", - "exposure": { - "source": "alteration", - "gene": "TP53" - } - } - }, - "drawsOwnWaiting": false, - "sub": "TP53 mutated · OS hazard ratio per CANCER_TYPE, random-effects pooled", - "help": { - "title": "What is this?", - "text": "Asks whether one thing — a gene being mutated, a high tumour mutation burden — goes with shorter or longer survival, separately in each cancer type, and then combines those answers. Each row is one cancer type: its square is the hazard ratio (right of the dashed line at 1 means patients with the change died sooner; left means they lived longer) and its line how sure we can be. The diamonds at the bottom combine the rows into one overall estimate. When the rows disagree a lot (a high I²), the overall estimate is an average of different effects, so read the rows. This shows an association, not a cause." - }, - "need": "patients", - "preview": "no method record or golden test of the per-stratum Cox fits and the DerSimonian–Laird pooling yet", - "isNew": true, - "figureTitle": "Forest plot — A vs B" - }, - "adjusted": { - "menu": { - "label": "Adjusted enrichment (every gene, logistic on covariates)", - "why": null, - "loading": false - }, - "menuBareStudy": { + "kind": "riskmodel" + }, + "label": "Risk model (elastic-net Cox, OncoCast-style)", + "maxCohorts": 1, + "title": "Risk model", "why": null, - "loading": false - }, - "menuAggregateOnly": { - "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + "whyBareStudy": null }, - "added": { - "kind": "adjusted", - "cohorts": [] - }, - "addedBareStudy": { - "kind": "adjusted", - "cohorts": [] - }, - "addedWithTwoCohorts": { - "kind": "adjusted", - "cohorts": [] - }, - "maxCohorts": 2, - "emptyHint": "It compares exactly 2 cohorts.", - "title": "Adjusted enrichment (every gene, logistic on covariates)", - "headTitle": "Adjusted enrichment", "drawsOwnHead": { "error": true, "withData": true }, - "card": { - "label": "Adjusted enrichment (every gene, logistic on covariates)", - "title": "Adjusted enrichment", - "maxCohorts": 2, - "minCohorts": 2, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "adjusted" - }, - "defaultsBareStudy": { - "kind": "adjusted" - } - }, "drawsOwnWaiting": false, - "sub": "mutations in every gene mutated in ≥5% · adjusted for nothing", + "emptyHint": "It draws one cohort.", + "figureTitle": "Risk model — A vs B", + "headTitle": "Risk model", "help": { - "title": "What is this?", - "text": "Compares how often each commonly mutated gene is mutated in two groups of patients — say, colorectal cancers diagnosed before and after age 50 — after taking into account other things that differ between the groups and also change mutation rates, such as how many mutations a tumour carries overall. Each dot is a gene: right of centre means more often mutated in the first group, left in the second, and higher means more certain. The table shows each gene's raw difference beside the adjusted one, so you can see which differences were explained away. A difference here is a clue, not proof of a cause." + "text": "A model that predicts survival from which genes are mutated in each patient, in the style of the OncoCast package. It is trained many times, each time on two thirds of the patients and tested on the other third, so every patient's risk score comes from a model that never saw them. Patients are then split into low, intermediate and high risk, and their survival curves are drawn. The list shows the genes the model relied on most, with a hazard ratio above 1 meaning higher risk. Tick \"validate\" to train on one half of the patients and grade the model on the other half, which it never saw, or name another group of patients under \"validate on\" to grade it there — for example patients from a different study — with the same risk-group boundaries.", + "title": "What is this?" }, - "need": "patients", - "preview": "no method record or golden test of the per-gene logistic fits yet", "isNew": true, - "figureTitle": "Adjusted enrichment — A vs B" - }, - "instability": { + "maxCohorts": 1, "menu": { - "label": "Genomic instability (aneuploidy score, FGA, arm-level calls)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Risk model (elastic-net Cox, OncoCast-style)", + "loading": false, + "why": null }, "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "menuBareStudy": { + "loading": false, "why": null }, + "need": "patients", + "preview": null, + "sub": "OS on mutated genes · 10 held-out splits", + "title": "Risk model (elastic-net Cox, OncoCast-style)" + }, + "rmst": { "added": { - "kind": "instability", "cohorts": [ "A" - ] + ], + "kind": "rmst" }, "addedBareStudy": { - "kind": "instability", "cohorts": [ "A" - ] + ], + "kind": "rmst" }, "addedWithTwoCohorts": { - "kind": "instability", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Genomic instability (aneuploidy score, FGA, arm-level calls)", - "headTitle": "Genomic instability", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "rmst" }, "card": { - "label": "Genomic instability (aneuploidy score, FGA, arm-level calls)", - "title": "Genomic instability", - "maxCohorts": 6, - "why": null, - "whyBareStudy": null, "defaults": { - "kind": "instability" + "kind": "rmst" }, "defaultsBareStudy": { - "kind": "instability" - } + "kind": "rmst" + }, + "label": "Restricted mean OS time (RMST, landmarks)", + "maxCohorts": 6, + "title": "Restricted mean OS time", + "why": null, + "whyBareStudy": "this study has no _MONTHS outcome column" + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "from hg19 segments, ±0.2, arm called past 50% of its length", + "emptyHint": "It draws up to 6 cohorts, side by side.", + "figureTitle": "Restricted mean survival time — A vs B", + "headTitle": "Restricted mean OS time", "help": { - "title": "What is this?", - "text": "Cancer cells often gain or lose whole chromosome arms, and how much of a tumour's genome is out of balance is a measure of how unstable it is. For each group of tumours, this shows how many chromosome arms are typically gained or lost (the \"aneuploidy score\") and what share of the genome is altered, then which arms are gained (red) or lost (blue) most often — patterns that differ by cancer type, such as chromosome 20q gained and 18q lost in colorectal cancer." + "text": "Answers “how many months, on average, did patients in each group live within the first τ months?” — the shaded area under each survival curve up to τ. The difference between two groups is the months gained or lost in that window, which reads plainly even when the curves cross or only separate late (as with immunotherapy), where a single hazard ratio can mislead. τ is where every group still has at least ten patients followed, unless it is set. Landmark panels ask the same question again among the patients still alive and followed at 6 or 12 months, restarting the clock there. The groups were not randomised, so a difference is a clue, not proof.", + "title": "What is this?" }, - "need": "local", - "preview": "no method record, and no check of the arm calls and aneuploidy scores against Taylor et al.'s published values yet", "isNew": true, - "figureTitle": "Genomic instability — aneuploidy score and FGA — A vs B" - }, - "actionability": { + "maxCohorts": 6, "menu": { - "label": "Clinical actionability (OncoKB levels, primary vs metastasis)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Restricted mean OS time (RMST, landmarks)", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": "this study has no _MONTHS outcome column" + }, + "need": "local", + "preview": "no method record or golden test of the restricted means, their variances and the landmark sets against R survRM2 yet", + "sub": "area under the KM curve to τ where every cohort has 10 at risk · OS", + "title": "Restricted mean OS time (RMST, landmarks)" + }, + "sequence": { "added": { - "kind": "actionability", - "groupBy": "CANCER_TYPE", "cohorts": [ "A" - ] + ], + "eventType": "treatment", + "kind": "sequence", + "labelKey": "SUBTYPE", + "lineGap": 90, + "maxSteps": 3, + "topN": 8 }, "addedBareStudy": { - "kind": "actionability", - "groupBy": "CANCER_TYPE", "cohorts": [ "A" - ] + ], + "kind": "sequence", + "labelKey": "SUBTYPE", + "lineGap": 90, + "maxSteps": 3, + "topN": 8 }, "addedWithTwoCohorts": { - "kind": "actionability", - "groupBy": "CANCER_TYPE", - "cohorts": [] + "cohorts": [], + "eventType": "treatment", + "kind": "sequence", + "labelKey": "SUBTYPE", + "lineGap": 90, + "maxSteps": 3, + "topN": 8 }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Clinical actionability (OncoKB levels, primary vs metastasis)", - "headTitle": "Clinical actionability", + "card": null, "drawsOwnHead": { - "error": true, + "error": false, "withData": true }, - "card": { - "label": "Clinical actionability (OncoKB levels, primary vs metastasis)", - "title": "Clinical actionability", - "maxCohorts": 4, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "actionability", - "groupBy": "CANCER_TYPE" - }, - "defaultsBareStudy": { - "kind": "actionability", - "groupBy": "CANCER_TYPE" - } - }, "drawsOwnWaiting": false, - "sub": "highest OncoKB level per patient, any cancer type · by CANCER_TYPE · SAMPLE_TYPE concordance", + "emptyHint": "It draws one cohort.", + "figureTitle": "Treatment sequence — A vs B", + "headTitle": "Treatment sequence", "help": { - "title": "What is this?", - "text": "For each patient, finds the strongest evidence OncoKB lists for a drug that targets one of their tumour's genetic changes — Level 1 means an FDA-approved drug for that change, down to Level 4 for early biological evidence — and shows what share of patients reach each level, overall or per cancer type. The levels here are the best OncoKB gives the change in ANY cancer, so a change that is only actionable in another cancer still counts: read the bars as an upper bound. The table below compares, for patients sequenced twice, the level found in their primary tumour against their metastasis." + "text": "Shows the order in which patients moved from one treatment to the next, drawn as flows between them. It answers a simple but useful question: after this treatment, what did doctors try next, and how often?", + "title": "What is this?" }, - "need": "local", - "preview": "no method record of the per-patient highest-level counts yet", "isNew": true, - "figureTitle": "Clinical actionability — A vs B" - }, - "drivernegative": { + "maxCohorts": 1, "menu": { - "label": "Driver-negative analysis (genes enriched without a canonical driver)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Treatment sequence", + "loading": false, + "why": "this study records no treatment events, so there are no lines of therapy to sequence" }, "menuAggregateOnly": { - "why": null + "why": "this study records no treatment events, so there are no lines of therapy to sequence" + }, + "menuBareStudy": { + "loading": false, + "why": "this study records no treatment events, so there are no lines of therapy to sequence" }, + "need": null, + "preview": null, + "sub": null, + "title": "Treatment sequence" + }, + "survival": { "added": { - "kind": "drivernegative", "cohorts": [ "A" - ] + ], + "kind": "survival" }, "addedBareStudy": { - "kind": "drivernegative", "cohorts": [ "A" - ] + ], + "kind": "survival" }, "addedWithTwoCohorts": { - "kind": "drivernegative", - "cohorts": [] + "cohorts": [], + "kind": "survival" }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Driver-negative analysis (genes enriched without a canonical driver)", - "headTitle": "Driver-negative analysis", + "card": null, "drawsOwnHead": { - "error": true, + "error": false, "withData": true }, - "card": { - "label": "Driver-negative analysis (genes enriched without a canonical driver)", - "title": "Driver-negative analysis", - "maxCohorts": 1, - "why": null, - "whyBareStudy": null, - "defaults": { - "kind": "drivernegative" - }, - "defaultsBareStudy": { - "kind": "drivernegative" - } - }, "drawsOwnWaiting": false, - "sub": "mutations without vs with a driver in the NSCLC set, per patient", + "emptyHint": "Add one cohort, or several to compare them.", + "figureTitle": "OS survival — A vs B", + "headTitle": "Survival", "help": { - "title": "What is this?", - "text": "Splits one group of patients — say, lung cancers — into those whose tumour carries one of the well-known, usually treatable cancer-driving changes (EGFR, KRAS, ALK and the rest of the list) and those whose tumour carries none, and then asks which other genes are changed more often in the second group. Those genes are candidates for what drives the tumours without a known driver. Patients whose test did not cover every gene on the list are kept apart, since we cannot tell whether they have a driver. The bar shows how big each group is; in the chart, genes to the right are more common without a known driver." + "text": "Shows what share of patients in each group were still alive (or still free of disease progression) at each point in time, drawn as a curve that steps downward every time a patient's outcome is recorded. Comparing the curves for two or more groups answers: did one group tend to do better than the other, and if so, is the difference too large to be chance?", + "title": "What is this?" }, - "need": "local", - "preview": "no method record or golden test of the group split and the per-gene Fisher tests yet", - "isNew": true, - "figureTitle": "Driver-negative analysis — A vs B" - }, - "organotropism": { + "isNew": false, + "maxCohorts": null, "menu": { - "label": "Metastatic organotropism (genes × metastatic sites, within cancer types)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)", - "loading": false + "label": "Survival", + "loading": false, + "why": null }, "menuAggregateOnly": { "why": null }, + "menuBareStudy": { + "loading": false, + "why": null + }, + "need": null, + "preview": null, + "sub": null, + "title": "Survival" + }, + "table": { "added": { - "kind": "organotropism", "cohorts": [ "A" - ] + ], + "kind": "table" }, "addedBareStudy": { - "kind": "organotropism", "cohorts": [ "A" - ] + ], + "kind": "table" }, "addedWithTwoCohorts": { - "kind": "organotropism", - "cohorts": [] - }, - "maxCohorts": 1, - "emptyHint": "It draws one cohort.", - "title": "Metastatic organotropism (genes × metastatic sites, within cancer types)", - "headTitle": "Metastatic organotropism", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "table" }, "card": { - "label": "Metastatic organotropism (genes × metastatic sites, within cancer types)", - "title": "Metastatic organotropism", - "maxCohorts": 1, - "cohortInHeader": true, - "why": null, - "whyBareStudy": "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)", "defaults": { - "kind": "organotropism" + "kind": "table" }, "defaultsBareStudy": { - "kind": "organotropism" - } + "kind": "table" + }, + "label": "Clinical data table (download)", + "maxCohorts": 6, + "title": "Clinical data", + "why": null, + "whyBareStudy": null }, - "drawsOwnWaiting": false, - "sub": "driver mutations in the 12 most altered genes × metastatic sites from the study's site source, within CANCER_TYPE", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "drawsOwnWaiting": true, + "emptyHint": "It draws up to 6 cohorts, side by side.", + "figureTitle": "table — A vs B", + "headTitle": "Clinical data", "help": { - "title": "What is this?", - "text": "Asks, for each organ a cancer spread to — brain, liver, bone and the rest — whether patients whose tumour carries a change in a given gene spread there more or less often than patients whose tumour does not, comparing patients within the same cancer type. Red squares mean the gene's change goes with spread to that organ, blue that it goes with spreading there less; outlined squares are the ones unlikely to be chance after testing many pairs. Where the study dates each site, the curve below shows how quickly patients with and without one gene's change develop spread to one organ, counting deaths as a separate outcome. Sites are only known where scans or biopsies found them." + "text": "Lists every sample (or every patient) in your groups with everything the study recorded about them — cancer type, age, stage, survival and the rest — one row each. Click a column's name to sort by it; the download button saves every row as a file a spreadsheet can open. Studies that only share counts, never individual patients, are named rather than listed.", + "title": "What is this?" }, - "need": "local", - "preview": "no method record or golden test of the per-cell Cochran–Mantel–Haenszel tests or the site incidence yet", - "isNew": true, - "figureTitle": "Organotropism — A vs B" - }, - "molsubtype": { + "isNew": false, + "maxCohorts": 6, "menu": { - "label": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Clinical data table (download)", + "loading": false, + "why": null }, "menuAggregateOnly": { + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "menuBareStudy": { + "loading": false, "why": null }, + "need": "patients", + "preview": null, + "sub": "one row per sample", + "title": "Clinical data table (download)" + }, + "timeline": { "added": { - "kind": "molsubtype", "cohorts": [ "A" - ] + ], + "kind": "timeline" }, "addedBareStudy": { - "kind": "molsubtype", "cohorts": [ "A" - ] + ], + "kind": "timeline" }, "addedWithTwoCohorts": { - "kind": "molsubtype", - "cohorts": [] - }, - "maxCohorts": 6, - "emptyHint": "It draws up to 6 cohorts, side by side.", - "title": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", - "headTitle": "Molecular subtypes", - "drawsOwnHead": { - "error": true, - "withData": true + "cohorts": [], + "kind": "timeline" }, "card": { - "label": "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", - "title": "Molecular subtypes", - "maxCohorts": 6, - "why": null, - "whyBareStudy": null, + "cohortInHeader": true, "defaults": { - "kind": "molsubtype" + "kind": "timeline" }, "defaultsBareStudy": { - "kind": "molsubtype" - } + "kind": "timeline" + }, + "label": "Patient timeline (one patient at a time)", + "maxCohorts": 1, + "title": "Patient timeline", + "why": null, + "whyBareStudy": "this study records no timeline events" }, - "drawsOwnWaiting": false, - "sub": "endometrial molecular classification per patient (POLE, MMRd, p53abn, NSMP) · OS by subtype", + "drawsOwnHead": { + "error": true, + "withData": true + }, + "drawsOwnWaiting": true, + "emptyHint": "It draws one cohort.", + "figureTitle": "Patient timeline — A vs B", + "headTitle": "Patient timeline", "help": { - "title": "What is this?", - "text": "Sorts each patient's endometrial cancer into the four molecular groups doctors now use alongside the microscope: POLE-ultramutated (a tumour with a broken DNA-copying enzyme, which does very well), mismatch-repair deficient (a tumour that cannot fix certain DNA errors, read here from its MSI test), p53-abnormal (the group that does worst), and no specific molecular profile. The groups are assigned in that order, as the WHO guideline does. Patients whose test could not tell — no MSI result, or a gene panel that missed POLE or TP53 — are counted as not classifiable rather than guessed. The bars show each group's share, overall and by the attribute you pick; the curves show survival in each group." + "text": "Shows one patient's medical history on a single line of time, as cBioPortal's patient page does: when samples were taken, operations, each treatment and how long it lasted, diagnoses, and lab results drawn as small charts. Time is counted from the day of diagnosis, and long stretches with nothing recorded are cut short (the dashed line). Step to the previous or next patient with the arrows at the top (or the ← and → keys), pick another cohort in the header, or open the patient list to search, sort and pick one. Studies that only share counts, never individual patients, are named rather than listed.", + "title": "What is this?" }, - "need": "local", - "preview": "no method record or golden test of the per-patient classification, the chi-squared tests or the survival split yet", - "isNew": true, - "figureTitle": "Molecular subtypes — A vs B" - }, - "germline": { + "isNew": false, + "maxCohorts": 1, "menu": { - "label": "Germline carriers (and their somatic second hits)", - "why": null, - "loading": false - }, - "menuBareStudy": { - "why": null, - "loading": false + "label": "Patient timeline (one patient at a time)", + "loading": false, + "why": null }, "menuAggregateOnly": { - "why": null + "why": "Cannot be drawn without leaving out the patients of s1 — primary answers with aggregate counts only." + }, + "menuBareStudy": { + "loading": false, + "why": "this study records no timeline events" }, + "need": "timeline", + "preview": null, + "sub": "(element)", + "title": "Patient timeline (one patient at a time)" + }, + "txendpoints": { "added": { - "kind": "germline", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, "cohorts": [ "A" - ] + ], + "endpoint": "ttnt", + "kind": "txendpoints" }, "addedBareStudy": { - "kind": "germline", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, "cohorts": [ "A" - ] + ], + "endpoint": "ttnt", + "kind": "txendpoints" }, "addedWithTwoCohorts": { - "kind": "germline", - "cohorts": [] - }, - "maxCohorts": 4, - "emptyHint": "It draws up to 4 cohorts, side by side.", - "title": "Germline carriers (and their somatic second hits)", - "headTitle": "Germline carriers", - "drawsOwnHead": { - "error": true, - "withData": true + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "cohorts": [], + "endpoint": "ttnt", + "kind": "txendpoints" }, "card": { - "label": "Germline carriers (and their somatic second hits)", - "title": "Germline carriers", - "maxCohorts": 4, - "why": null, - "whyBareStudy": null, "defaults": { - "kind": "germline" + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "endpoint": "ttnt", + "kind": "txendpoints" }, "defaultsBareStudy": { - "kind": "germline" - } + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "endpoint": "ttnt", + "kind": "txendpoints" + }, + "label": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", + "maxCohorts": 6, + "title": "Treatment endpoints", + "why": null, + "whyBareStudy": "this study records no treatment timeline events" + }, + "drawsOwnHead": { + "error": true, + "withData": true }, "drawsOwnWaiting": false, - "sub": "germline carriers per patient sequenced, every gene with ≥3 carriers · somatic second hit vs non-carriers", + "emptyHint": "It draws up to 6 cohorts, side by side.", + "figureTitle": "Treatment endpoints — A vs B", + "headTitle": "Treatment endpoints", "help": { - "title": "What is this?", - "text": "Some people are born with a faulty copy of a cancer gene such as BRCA2 in every cell. For each such gene this counts those patients (carriers) among everyone whose tumour was sequenced for it, and asks whether their tumour knocked out the remaining good copy — a second hit — by a further mutation or by deleting the gene, compared with how often the same gene is hit in patients born without a faulty copy. The commonest second hit, losing the good copy outright (loss of heterozygosity), needs data this view does not read, so the second-hit share is a minimum. Optionally the curves compare survival of carriers and non-carriers for one gene." + "text": "Starts each patient's clock on the day a treatment began — for example their first immunotherapy — and follows them to an outcome that real-world studies use in place of a trial's: when they moved on to their next treatment (time to next treatment), when they stopped this one (time on treatment), or when their cancer was recorded as growing (real-world progression-free survival). Death counts as the outcome too, and patients still followed without it are counted up to their last visit. Each group's curve shows the share not yet there at each month, with the number still followed below. The groups were not randomised to the treatment, so a gap between curves is a clue, not proof that a gene or a group changes how well the treatment works.", + "title": "What is this?" }, - "need": "local", - "preview": "no method record or golden test of the carrier and second-hit counts or the Fisher tests yet", "isNew": true, - "figureTitle": "Germline carriers and second hits — A vs B" + "maxCohorts": 6, + "menu": { + "label": "Treatment endpoints (TTNT, time on treatment, real-world PFS)", + "loading": false, + "why": "this study records no treatment timeline events" + }, + "menuAggregateOnly": { + "why": "this study records no treatment timeline events" + }, + "menuBareStudy": { + "loading": false, + "why": "this study records no treatment timeline events" + }, + "need": "local", + "preview": "no method record or golden test of the endpoint derivation (lines, stops, progression) or the median intervals yet", + "sub": "time to next treatment from first Treatment [SUBTYPE=Immuno], death an event", + "title": "Treatment endpoints (TTNT, time on treatment, real-world PFS)" } - } + }, + "menuOrder": [ + "compare", + "survival", + "cox", + "logistic", + "oncoprint", + "hotspots", + "plot", + "cooccurrence", + "clinical", + "coexpression", + "course", + "sequence", + "attrition", + "genome", + "pathways", + "enrichment", + "assays", + "composition", + "table", + "mutations", + "timeline", + "alterations", + "correlates", + "overlap", + "cancertypes", + "labs", + "incidence", + "hla", + "cutpoint", + "predictive", + "rmst", + "multistate", + "txendpoints", + "clonality", + "paired", + "acquired", + "instability", + "exclusivity", + "ctdna", + "riskmodel", + "drivernegative", + "organotropism", + "germline", + "molsubtype", + "actionability", + "adjusted", + "forest", + "diffexpr" + ] } diff --git a/test/view-kinds.test.ts b/test/view-kinds.test.ts index 9cdbb00..7bb4071 100644 --- a/test/view-kinds.test.ts +++ b/test/view-kinds.test.ts @@ -93,9 +93,13 @@ function facts() { return { menuOrder: rich.opts.VIEW_KINDS.map((k) => k.kind), cardOrder: Object.keys(kindCards.KIND_CARDS), kinds: out }; } +/** a value with every object's keys in order, so the file pins what is said, not the order it was written in */ +const sortKeys = (x: unknown): unknown => Array.isArray(x) ? x.map(sortKeys) + : x && typeof x === "object" ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, sortKeys((x as Record)[k])])) : x; + test("what every view kind tells the page before data arrives", () => { const file = join(import.meta.dirname, "snapshots", "kinds.json"); - const text = JSON.stringify(facts(), null, 2) + "\n"; + const text = JSON.stringify(sortKeys(facts()), null, 2) + "\n"; if (process.env.UPDATE_SNAPSHOTS || !existsSync(file)) writeFileSync(file, text); assert.equal(text, readFileSync(file, "utf8"), "kinds.json changed — review the diff; if intended, UPDATE_SNAPSHOTS=1 npm test"); }); From 0de1c0d116455c69343508ef8fd16b9d1d44ef9d Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Fri, 23 Oct 2026 22:19:48 +0000 Subject: [PATCH 6/7] Each view kind's page code moves into app/lab/kinds/.tsx, listed in one registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A view kind is now a folder (lib/views//) plus one page file and a line in each of three registries. The page file is the kind's KindUi (app/lab/kinds/kind-ui.ts): its add-menu entry and why it is disabled, the view a new one starts from, how many cohorts it draws, and its card — the shared card's subtitle, controls and body, or a card of its own for the twelve kinds drawn before the shared card existed. app/lab/kinds/index.ts lists them, typed by ViewKind, so a kind without one does not compile. What used to be per-kind lists and switches across the page reads the registry instead: the add menu (use-view-options.ts VIEW_KINDS), drawsOwnHead, the defaults in addView, maxCohortsOf and the empty view's hint (use-view-edits.ts), the card titles, KindBody's switch, the shared card's subtitle, controls and body chains, drawsOwnWaiting and figureView. KIND_CARDS, VIEW_HEAD_TITLES and the unused GENE_VIEWS are gone. kind-cards.tsx is now the shared card and its dispatchers; the bodies that were in portal-bodies.tsx move to their kinds, leaving the portal table and badges they share. What several kinds' cards share is app/lab/kinds/parts.tsx; local/kind-boundary treats it and kind-ui.ts as shared modules, and keeps kinds' page files from importing each other. Verified: every view card's HTML (test/snapshots/*.html) and every kind's page facts (test/snapshots/kinds.json — menu entry and order, defaults, limits, titles, subtitles, help) are byte-identical; npm test 1611/1611; tsc, eslint, knip clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CuBjiBEjYispDSEYDZDWM1 --- CLAUDE.md | 56 +- app/globals.css | 4 +- app/lab/kind-cards.tsx | 1341 ++---------------------------- app/lab/kinds/acquired.tsx | 75 ++ app/lab/kinds/actionability.tsx | 45 + app/lab/kinds/adjusted.tsx | 41 + app/lab/kinds/alterations.tsx | 18 + app/lab/kinds/assays.tsx | 85 ++ app/lab/kinds/attrition.tsx | 11 + app/lab/kinds/cancertypes.tsx | 97 +++ app/lab/kinds/clinical.tsx | 69 ++ app/lab/kinds/clonality.tsx | 35 + app/lab/kinds/coexpression.tsx | 68 ++ app/lab/kinds/compare.tsx | 60 ++ app/lab/kinds/composition.tsx | 69 ++ app/lab/kinds/cooccurrence.tsx | 63 ++ app/lab/kinds/correlates.tsx | 125 +++ app/lab/kinds/course.tsx | 129 +++ app/lab/kinds/cox.tsx | 85 ++ app/lab/kinds/ctdna.tsx | 55 ++ app/lab/kinds/cutpoint.tsx | 51 ++ app/lab/kinds/diffexpr.tsx | 46 + app/lab/kinds/drivernegative.tsx | 41 + app/lab/kinds/enrichment.tsx | 79 ++ app/lab/kinds/exclusivity.tsx | 44 + app/lab/kinds/forest.tsx | 76 ++ app/lab/kinds/genome.tsx | 38 + app/lab/kinds/germline.tsx | 40 + app/lab/kinds/hla.tsx | 44 + app/lab/kinds/hotspots.tsx | 33 + app/lab/kinds/incidence.tsx | 63 ++ app/lab/kinds/index.ts | 111 +++ app/lab/kinds/instability.tsx | 38 + app/lab/kinds/kind-ui.ts | 96 +++ app/lab/kinds/labs.tsx | 65 ++ app/lab/kinds/logistic.tsx | 77 ++ app/lab/kinds/molsubtype.tsx | 43 + app/lab/kinds/multistate.tsx | 55 ++ app/lab/kinds/mutations.tsx | 49 ++ app/lab/kinds/oncoprint.tsx | 49 ++ app/lab/kinds/organotropism.tsx | 55 ++ app/lab/kinds/overlap.tsx | 12 + app/lab/kinds/paired.tsx | 72 ++ app/lab/kinds/parts.tsx | 75 ++ app/lab/kinds/pathways.tsx | 34 + app/lab/kinds/plot.tsx | 48 ++ app/lab/kinds/predictive.tsx | 51 ++ app/lab/kinds/riskmodel.tsx | 129 +++ app/lab/kinds/rmst.tsx | 37 + app/lab/kinds/sequence.tsx | 97 +++ app/lab/kinds/survival.tsx | 88 ++ app/lab/kinds/table.tsx | 41 + app/lab/kinds/timeline.tsx | 19 + app/lab/kinds/txendpoints.tsx | 64 ++ app/lab/portal-bodies.tsx | 205 +---- app/lab/use-view-edits.ts | 63 +- app/lab/use-view-options.ts | 58 +- app/lab/view-cards.tsx | 722 +--------------- eslint.config.mjs | 4 +- lib/views/kinds.ts | 4 +- test/view-help.test.ts | 6 +- test/view-kinds.test.ts | 13 +- 62 files changed, 3242 insertions(+), 2324 deletions(-) create mode 100644 app/lab/kinds/acquired.tsx create mode 100644 app/lab/kinds/actionability.tsx create mode 100644 app/lab/kinds/adjusted.tsx create mode 100644 app/lab/kinds/alterations.tsx create mode 100644 app/lab/kinds/assays.tsx create mode 100644 app/lab/kinds/attrition.tsx create mode 100644 app/lab/kinds/cancertypes.tsx create mode 100644 app/lab/kinds/clinical.tsx create mode 100644 app/lab/kinds/clonality.tsx create mode 100644 app/lab/kinds/coexpression.tsx create mode 100644 app/lab/kinds/compare.tsx create mode 100644 app/lab/kinds/composition.tsx create mode 100644 app/lab/kinds/cooccurrence.tsx create mode 100644 app/lab/kinds/correlates.tsx create mode 100644 app/lab/kinds/course.tsx create mode 100644 app/lab/kinds/cox.tsx create mode 100644 app/lab/kinds/ctdna.tsx create mode 100644 app/lab/kinds/cutpoint.tsx create mode 100644 app/lab/kinds/diffexpr.tsx create mode 100644 app/lab/kinds/drivernegative.tsx create mode 100644 app/lab/kinds/enrichment.tsx create mode 100644 app/lab/kinds/exclusivity.tsx create mode 100644 app/lab/kinds/forest.tsx create mode 100644 app/lab/kinds/genome.tsx create mode 100644 app/lab/kinds/germline.tsx create mode 100644 app/lab/kinds/hla.tsx create mode 100644 app/lab/kinds/hotspots.tsx create mode 100644 app/lab/kinds/incidence.tsx create mode 100644 app/lab/kinds/index.ts create mode 100644 app/lab/kinds/instability.tsx create mode 100644 app/lab/kinds/kind-ui.ts create mode 100644 app/lab/kinds/labs.tsx create mode 100644 app/lab/kinds/logistic.tsx create mode 100644 app/lab/kinds/molsubtype.tsx create mode 100644 app/lab/kinds/multistate.tsx create mode 100644 app/lab/kinds/mutations.tsx create mode 100644 app/lab/kinds/oncoprint.tsx create mode 100644 app/lab/kinds/organotropism.tsx create mode 100644 app/lab/kinds/overlap.tsx create mode 100644 app/lab/kinds/paired.tsx create mode 100644 app/lab/kinds/parts.tsx create mode 100644 app/lab/kinds/pathways.tsx create mode 100644 app/lab/kinds/plot.tsx create mode 100644 app/lab/kinds/predictive.tsx create mode 100644 app/lab/kinds/riskmodel.tsx create mode 100644 app/lab/kinds/rmst.tsx create mode 100644 app/lab/kinds/sequence.tsx create mode 100644 app/lab/kinds/survival.tsx create mode 100644 app/lab/kinds/table.tsx create mode 100644 app/lab/kinds/timeline.tsx create mode 100644 app/lab/kinds/txendpoints.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 845708d..b487168 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,23 +33,33 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a observability guard, priorGuard, derived treatment lines). - **`lib/analysis.ts`** — `runAnalysis(doc)`: evaluates cohorts (per-study; cross-study federation via study-qualified member ids), then hands each view to its kind's runner. - **`lib/views/.ts`** holds one runner per view kind (compare, survival, cox, …), registered - in `lib/views/index.ts`; each gets the run's context (`ViewRun` in `lib/views/run.ts`: the - document, evaluated cohorts, resolvers, the view's cohorts and studies) and returns its result; - `lib/views/shared.ts` holds what several runners use (`overLimit`, `answering`, `refuse`). A new - view kind is a new runner there, registered in `index.ts` (`RUNNERS` is keyed by `ViewKind`, the - `View` union's kinds, so a kind without a runner does not compile). A result is a `ViewResult`, a - union over kinds: the common fields (`ViewResultCommon`) plus its kind's own (`ViewResultFields` in - `lib/types.ts`, where a new kind declares its fields), so checking `kind` narrows it and reading - another kind's field does not compile. A drawn result (no `error`, not `empty`) carries its kind's - main field (`ViewResultMain`; a runner's success path must fill it), and `isDrawn(v)` is the check - after which that field is read without `!`; a figure takes `DrawnResultOf<"">`. A refusal or - error for a kind known only at run time is `plainResult(kind, {…})`. **Kinds never import each other:** a - kind's runner, figure and `-.ts` modules import only one another and the shared - modules (`lib/views/`: run, shared, rows, covariates; `lib/figures/`: common, volcano, bars, - forest, km) — `local/kind-boundary` in `eslint.config.mjs` enforces it; what two kinds need moves - into a shared module, listed there. Kinds registered through `drawn()` - have their figure drawn from the result and a throw reported as the view's error. +- **View kinds** — **each kind is a folder, `lib/views//`, plus one page file, + `app/lab/kinds/.tsx`, and a line in each of three registries.** The folder holds `types.ts` + (the kind's spec and result fields: it adds its entry to `ViewSpecs`, `ViewResultFields` and + `ViewResultMain` in `lib/types.ts` by `declare module`), `run.ts` (the runner), `sql.ts` (its + queries; `test/boundary.test.ts` sweeps every kind's), `method.ts` (its method record, invariant + 12), `figure.ts` (its SVG) and `kind.ts` (its descriptor: figure, title, results text, help, + federation need, marks — `ViewKindDef` in `lib/views/kind-def.ts`). The page file is its + `KindUi` (`app/lab/kinds/kind-ui.ts`): its add-menu entry and why it is disabled, the view a new + one starts from, how many cohorts it draws, and its card — the shared card + (`app/lab/kind-cards.tsx`) with its subtitle, controls and body, or a `Card` of its own. The + registries are `lib/views/index.ts` (runners), `lib/views/kinds.ts` (descriptors) and + `app/lab/kinds/index.ts` (page files); each is typed by `ViewKind`, the `View` union's kinds, so + a kind missing from any of them does not compile, and everything else (`lib/view-svg.ts`, + `lib/result-text.ts`, `VIEW_HELP`, `viewNeed`, the add menu, the cards) reads them. A runner gets + the run's context (`ViewRun` in `lib/views/run.ts`: the document, evaluated cohorts, resolvers, + the view's cohorts and studies) and returns its result; `lib/views/shared.ts` holds what several + runners use (`overLimit`, `answering`, `refuse`, `askEachSite`). A result is a `ViewResult`, a + union over kinds: the common fields (`ViewResultCommon`) plus its kind's own, so checking `kind` + narrows it and reading another kind's field does not compile. A drawn result (no `error`, not + `empty`) carries its kind's main field (a runner's success path must fill it), and `isDrawn(v)` is + the check after which that field is read without `!`; a figure takes `DrawnResultOf<"">`. A + refusal or error for a kind known only at run time is `plainResult(kind, {…})`. Kinds registered + through `drawn()` have their figure drawn from the result and a throw reported as the view's + error. **Kinds never import each other:** a kind's files import only one another and the shared + modules (`lib/views/`: run, shared, rows, covariates, kind-def, text; `lib/figures/`; + `app/lab/kinds/`: kind-ui, parts) — `local/kind-boundary` in `eslint.config.mjs` enforces it, and + only the registries list every kind; what two kinds need moves into a shared module, listed there. - **`lib/oncoprint.ts`** — spec → rows (ClickHouse fetch); **`lib/oncoprint-assemble.ts`** — rows → render contract, pure (bitmask cells, NOT_PROFILED=-1, panel-aware denominators, staircase + declarative `sort`, clinical tracks incl. numeric heat, `heatmap` molecular tracks incl. synthetic @@ -63,8 +73,8 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a `scripts/oncoprint-cli.ts` and `python -m cohortlab` wrap all of this for local tables, and `/oncoprint-lab/import` does the same in the browser (`POST /api/oncoprint/local`). `lib/oncoprint-cluster.ts` orders columns by a Ward dendrogram for the `cluster:` - sort key; `lib/oncoprint-detail.ts` answers one column at a time for tooltips. **`lib/view-svg.ts`** — the same contract for every other view kind: the dispatcher over - `lib/figures/.ts`; used by the page (drawn in the browser) and by MCP `run_analysis` + sort key; `lib/oncoprint-detail.ts` answers one column at a time for tooltips. **`lib/view-svg.ts`** — the same contract for every other view kind: it draws + each kind's figure through its descriptor (`lib/views/kinds.ts`); used by the page (drawn in the browser) and by MCP `run_analysis` for its figures (the oncoprint's image included). - **View aggregates** — most view kinds after the first few (`genome`, `pathways`, `enrichment`, …) query through the resolvers' `viewAggregate`/`viewAggregateMany`/`viewMeta`, whose SQL lives in @@ -74,7 +84,7 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a Figures: **`lib/views//figure.ts`**, drawn through the kind's descriptor (`kind.ts`, listed in `lib/views/kinds.ts`) by **`lib/view-svg.ts`**; what several figures share is in `lib/figures/`; all browser-safe, laid out with **`lib/chart/`** (see "Figures" under UI conventions); cards: - `app/lab/kind-cards.tsx`. + `app/lab/kinds/.tsx`. - **Table views** (`table`, `mutations`, `timeline`'s patient list, `alterations`' list and three tables, and any later view that lists rows) — **every one must support the same five things: pagination, search over its text, a filter per column (text conditions or a value list for text, a range for numbers), sorting, and a CSV download of every @@ -271,8 +281,8 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a code that needs a new migration. The in-process database migrates itself on first use. - **`app/lab/page.tsx`** — the app: Explore (study checkboxes + facets) / Saved / Analysis / Examples. Tabs move out into their own components as they are untangled - (`saved-tab.tsx`, `examples-tab.tsx`), and each view kind's card is its own component in - `view-cards.tsx`, reading the page through one `ViewCtx`; Explore's sections (study picker, bar, + (`saved-tab.tsx`, `examples-tab.tsx`), and each view's card (`view-cards.tsx`: its kind's + own or the shared one, from `app/lab/kinds/`) reads the page through one `ViewCtx`; Explore's sections (study picker, bar, add-chart dialog, facet cards) are `explore-sections.tsx`, reading Explore's state and actions from `use-explore-state.ts` (the draft, the selection, add-cohort mode) through `ExploreCtx`; the Analysis tab's cohort column, view column, document and genes dialogs are `analysis-sections.tsx`, @@ -608,7 +618,7 @@ ask. These hold for every view kind, and for the chat/MCP figures that share its `.dt-wait` in `app/_table/DataTable.tsx`) and the count is dimmed with a spinner beside it; the bar, chips and headers stay visible and usable, and an action that would act on the stale answer (the download) waits, saying why. Such a view draws its own waiting state, so the page - must not cover its card (`drawsOwnWaiting` in `app/lab/kind-cards.tsx`, honoured by the lab and + must not cover its card (`drawsOwnWaiting` in its `app/lab/kinds/.tsx`, honoured by the lab and `PortalTabs`). Its controls show and edit the query **the document holds**, not the last result's, so a second edit made while the first is running keeps the first. A page not yet on hand is loading from the render that asks for it — never a flash of "no rows" first. diff --git a/app/globals.css b/app/globals.css index e9439f2..47aab28 100644 --- a/app/globals.css +++ b/app/globals.css @@ -610,7 +610,7 @@ main.analysis-main { max-width: min(132rem, 100%); } .row.g5 { gap: 0.5rem; } .facettitle { font-size: var(--t-md); font-weight: 600; } -/* ---------------- view-card tables (app/lab/view-cards.tsx) ---------------- */ +/* ---------------- view-card tables (app/lab/kinds/.tsx) ---------------- */ .vscroll { overflow-x: auto; } .vtable { border-collapse: collapse; font-size: var(--t-md); width: 100%; } .vtable th, .vtable td { padding: 0.3rem 0.6rem; } @@ -1559,7 +1559,7 @@ 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-style card bodies (app/lab/portal-bodies.tsx) */ +/* the portal-style card bodies (app/lab/kinds/.tsx, on app/lab/portal-bodies.tsx) */ .pb-scroll { overflow-x: auto; } .pb-row { display: flex; gap: var(--s4); align-items: flex-start; flex-wrap: wrap; } .pb-row > * { min-width: 0; } diff --git a/app/lab/kind-cards.tsx b/app/lab/kind-cards.tsx index f803e25..22f6898 100644 --- a/app/lab/kind-cards.tsx +++ b/app/lab/kind-cards.tsx @@ -1,1311 +1,80 @@ "use client"; -// Cards for the view kinds described by KIND_CARDS, rather than hand-built in view-cards.tsx. The -// page draws every one of them the same way — ViewHead, then this kind's controls, then the -// server-rendered figure or the kind's own body — so all that differs per kind lives here: its -// title, whether it takes a gene list, how many cohorts it draws, and its own settings. -import React, { useEffect, useState } from "react"; -import { DataTable, specQuery } from "../_table/DataTable.tsx"; -import { CancertypesBody, EnrichmentBody, CorrelatesBody, AssaysBody, MutationsBody } from "./portal-bodies"; -import { PickMenu } from "./view-chrome.tsx"; -import { TimelineBody } from "./timeline-body"; -import { AlterationsBody } from "./alterations-body"; -import { Checkbox } from "../_ui/checkbox"; -import { Input } from "../_ui/input"; -import { Choice, OptionSelect } from "../_ui/choice.tsx"; -import { FIELD } from "../_ui/toolbar.ts"; -import { RISK_LABEL_ABOVE } from "../../lib/views/riskmodel/figure.ts"; -import { RiskTable1Button } from "./risk-table1-dialog.tsx"; -import { RiskGroupsButton } from "./risk-groups-dialog.tsx"; -import { GearMenu } from "../_ui/gear-menu"; -import { Settings2 } from "lucide-react"; - -/** - * A text field that keeps what is being typed to itself and hands the page the value only on - * Enter or blur — the page is one large component, and a keystroke in page state re-renders - * every card. - */ -function DraftInput({ value, onCommit, disabled, width, placeholder, title }: { - value: string; onCommit: (v: string) => void; disabled?: boolean; width?: string; placeholder?: string; title?: string; -}) { - const [draft, setDraft] = useState(value); - useEffect(() => { setDraft(value); }, [value]); - const commit = () => { if (draft !== value) onCommit(draft); }; - return setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); }} />; -} - -export interface KindCardSpec { - label: string; - /** the card's title */ - title: string; - /** a gene list edited through the page's shared gene dialog */ - genes?: boolean; - /** the most cohorts this view can draw at once */ - maxCohorts?: number; - /** the header's subtitle names the one cohort drawn, so the header leaves out its "Showing" line */ - cohortInHeader?: boolean; - /** the fewest it compares; an empty view says so beside its "+ Add cohort" */ - minCohorts?: number; - /** why the add button is disabled for this study, or null when it can be added */ - why?: (known: Known) => string | null; -} - -/** What the page knows about the study before a view is added. */ -export interface Known { - genes: string[]; attrs: { attribute: string; kind: string }[]; profiles: string[]; - /** generic-assay profiles (mutational signatures, arm-level CNA, …) */ - assays: string[]; - events: { canonical: string; label: string; n: number }[]; cohorts: string[]; -} - -export const KIND_CARDS: Record = { - attrition: { label: "Cohort attrition (CONSORT)", title: "Cohort attrition", maxCohorts: 6 }, - genome: { label: "Genome-wide copy number", title: "Genome-wide copy number", maxCohorts: 4 }, - pathways: { label: "Oncogenic pathways", title: "Oncogenic signalling pathways", maxCohorts: 4 }, - enrichment: { label: "Alteration enrichment (all genes)", title: "Alteration enrichment", maxCohorts: 6, minCohorts: 2 }, - assays: { label: "Generic assays (signatures, arm-level CNA, …)", title: "Generic assays", maxCohorts: 6, minCohorts: 2, - why: (k) => !k.assays.length ? "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)" : null }, - composition: { label: "Composition (cell types, states, signatures as parts of a whole)", title: "Composition", maxCohorts: 8, minCohorts: 1, - why: (k) => !k.assays.length && !k.attrs.some((a) => a.kind === "num") ? "this study has no generic-assay profile and no numeric attribute to draw as parts" : null }, - table: { label: "Clinical data table (download)", title: "Clinical data", maxCohorts: 6 }, - mutations: { label: "Mutation table (one gene)", title: "Mutations", maxCohorts: 6 }, - timeline: { label: "Patient timeline (one patient at a time)", title: "Patient timeline", maxCohorts: 1, cohortInHeader: true, - why: (k) => k.events.length ? null : "this study records no timeline events" }, - alterations: { label: "Patient alterations (mutations, copy number, SVs; one patient at a time)", title: "Patient alterations", maxCohorts: 1, cohortInHeader: true }, - correlates: { label: "Co-expression with one gene (genome-wide)", title: "Co-expression", maxCohorts: 1, - why: (k) => !k.profiles.filter((p) => p !== "cna").length ? "this study has no continuous molecular profile (mRNA, protein, …) to correlate on" : null }, - overlap: { label: "Cohort overlap (UpSet)", title: "Cohort overlap", maxCohorts: 6, minCohorts: 2 }, - cancertypes: { label: "Cancer types summary", title: "Cancer types summary", genes: true, maxCohorts: 2 }, - labs: { label: "Lab course and response (e.g. PSA50)", title: "Lab course", maxCohorts: 4, - why: (k) => k.events.some((e) => /lab/i.test(e.canonical)) ? null : "this study records no lab-test timeline events" }, - incidence: { label: "Cumulative incidence (competing risks)", title: "Cumulative incidence", maxCohorts: 6, - why: (k) => k.events.length ? null : "this study records no timeline events to estimate the incidence of" }, - hla: { label: "HLA class I genotype (homozygosity, HLA LOH and OS)", title: "HLA class I genotype", maxCohorts: 3, - why: (k) => k.attrs.some((a) => /^hla[-_]?[abc]/i.test(a.attribute)) ? null : "this study records no HLA class I alleles" }, - cutpoint: { label: "Biomarker cut-point (best split of a number for OS)", title: "Cut-point", maxCohorts: 1, - why: (k) => k.attrs.some((a) => a.kind === "num") ? null : "this study has no numeric clinical attribute to find a cut-point of" }, - predictive: { label: "Predictive biomarkers (gene × treatment interaction)", title: "Predictive biomarkers", genes: true, maxCohorts: 2, - why: (k) => k.events.some((e) => /treat|therap|drug|medicat/i.test(e.canonical)) ? null : "this study records no treatment timeline events to define an exposure" }, - rmst: { label: "Restricted mean OS time (RMST, landmarks)", title: "Restricted mean OS time", maxCohorts: 6, - why: (k) => k.attrs.some((a) => /_MONTHS$/i.test(a.attribute)) ? null : "this study has no _MONTHS outcome column" }, - multistate: { label: "Multistate model (progression, then death)", title: "Multistate model", maxCohorts: 4, - why: (k) => k.events.some((e) => /diagnos|progress/i.test(e.canonical)) ? null : "this study records no diagnosis or progression timeline events" }, - txendpoints: { label: "Treatment endpoints (TTNT, time on treatment, real-world PFS)", title: "Treatment endpoints", maxCohorts: 6, - why: (k) => k.events.some((e) => /treat|therap|drug|medicat/i.test(e.canonical)) ? null : "this study records no treatment timeline events" }, - clonality: { label: "Clonality (FACETS)", title: "Clonality", genes: true, maxCohorts: 3 }, - paired: { label: "Paired samples (primary vs metastasis, before vs after)", title: "Paired samples", genes: true, maxCohorts: 3 }, - acquired: { label: "Acquired under therapy (samples before vs after a treatment start)", title: "Acquired under therapy", genes: true, maxCohorts: 3, - why: (k) => k.events.some((e) => /treat|therap|drug|medicat/i.test(e.canonical)) ? null : "this study records no treatment timeline events to anchor on" }, - instability: { label: "Genomic instability (aneuploidy score, FGA, arm-level calls)", title: "Genomic instability", maxCohorts: 6 }, - exclusivity: { label: "Mutual exclusivity (DISCOVER, burden-aware)", title: "Mutual exclusivity", genes: true, maxCohorts: 1, cohortInHeader: true }, - ctdna: { label: "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", title: "ctDNA vs tissue", genes: true, maxCohorts: 3 }, - riskmodel: { label: "Risk model (elastic-net Cox, OncoCast-style)", title: "Risk model", maxCohorts: 1 }, - drivernegative: { label: "Driver-negative analysis (genes enriched without a canonical driver)", title: "Driver-negative analysis", maxCohorts: 1 }, - organotropism: { label: "Metastatic organotropism (genes × metastatic sites, within cancer types)", title: "Metastatic organotropism", maxCohorts: 1, cohortInHeader: true, - why: (k) => k.attrs.some((a) => /^DMETS_DX_|^METASTATIC_SITE$/i.test(a.attribute)) || k.events.some((e) => /diagnosis/i.test(e.canonical)) ? null - : "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)" }, - germline: { label: "Germline carriers (and their somatic second hits)", title: "Germline carriers", maxCohorts: 4 }, - molsubtype: { label: "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", title: "Molecular subtypes", maxCohorts: 6 }, - actionability: { label: "Clinical actionability (OncoKB levels, primary vs metastasis)", title: "Clinical actionability", maxCohorts: 4 }, - adjusted: { label: "Adjusted enrichment (every gene, logistic on covariates)", title: "Adjusted enrichment", maxCohorts: 2, minCohorts: 2 }, - forest: { label: "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", title: "Forest plot", maxCohorts: 6 }, - diffexpr: { label: "Differential expression + GSEA", title: "Differential expression", maxCohorts: 2, minCohorts: 2, - why: (k) => !k.profiles.length ? "this study has no molecular profile to compare expression on" : null } -}; - -const PAIRED_DEFAULT_GENES = ["TP53", "KRAS", "PIK3CA", "ESR1", "AR", "CDKN2A", "MYC", "TERT"]; - -export const hasKindCard = (k: unknown): boolean => Object.prototype.hasOwnProperty.call(KIND_CARDS, String(k)); +// The shared card, for the view kinds that do not draw a card of their own (app/lab/kinds/.tsx +// without a `Card`). The page draws every one of them the same way — ViewHead, then this kind's +// controls, then the figure or the kind's own body — so all that differs per kind is its description: +// its title, whether it takes a gene list, how many cohorts it draws, its subtitle, settings and body. +import React from "react"; +import { kindUi } from "./kinds/index.ts"; +import type { AddPage, KindCardCtx, Known } from "./kinds/kind-ui.ts"; +import { placement } from "./kinds/parts.tsx"; +import type { CardProps } from "./view-cards.tsx"; +import { ViewHead } from "./view-chrome.tsx"; +import { ViewFigure } from "./figure.tsx"; +import { Button } from "../_ui/button"; + +/** whether a kind is drawn by the shared card */ +export const hasKindCard = (k: unknown): boolean => { const ui = kindUi(k); return !!ui && !ui.Card; }; /** * Views that show their own waiting state (the table views: their search, filters and headers stay * usable while the rows are covered), so the page must not cover the card with its own. */ -export const drawsOwnWaiting = (k: unknown): boolean => k === "table" || k === "mutations" || k === "timeline" || k === "alterations"; - -/** What a card needs from the page: the view as written, its result, and a way to edit it. */ -export interface KindCardCtx { - vi: number; - /** the view as the DOCUMENT holds it (never the result, which has different fields) */ - spec: any; - /** the view's result */ - v: any; - busy: boolean; - /** a request for this view is running (its own re-run, or the whole document's) — a view that draws its own - * waiting state (drawsOwnWaiting) shows it; for the rest the page covers the card */ - updating?: boolean; - patch: (p: Record) => void; - attrs: { attribute: string; kind: string }[]; - profiles: string[]; - assays: string[]; - events: { canonical: string; label: string; n: number }[]; - /** the document as it stands, for a table's download */ - docJson?: () => string; - /** settings the surrounding page controls for every view at once (a portal tab's gene chips), so not offered here */ - locked?: string[]; -} - -/** the risk model's covariate menus, open or shut, by view (a viewport state, not the document's) */ -const RISK_MENUS = new Map(); -/** the runs the model settings offer (OncoCast recommends 50 or more) */ -const RISK_RUN_STEPS = [5, 10, 20, 50]; -const RISK_PENALTIES: [string, string][] = [["1", "lasso"], ["0.5", "elastic net"], ["0.1", "mostly ridge"]]; - -/** - * The risk model's fitting settings, set once and rarely changed, behind the view's settings menu - * (the sequence and course views' idiom) so the toolbar keeps to what a reader works with. - */ -function RiskModelSettings({ vi, spec, busy, patch }: { vi: number; spec: any; busy: boolean; patch: (p: Record) => void }) { - const alpha = String(spec.alpha ?? 0.5), runs = String(spec.runs ?? 10); - const penalties = RISK_PENALTIES.some(([v]) => v === alpha) ? RISK_PENALTIES : [[alpha, "alpha " + alpha] as [string, string], ...RISK_PENALTIES]; - const runSteps = RISK_RUN_STEPS.map(String); - return ( - settings}> -
- penalty - patch({ alpha: Number(v) })} options={penalties} /> -
-
- runs - patch({ runs: Number(v) })} - options={(runSteps.includes(runs) ? runSteps : [runs, ...runSteps]).map((r) => [r, r] as const)} /> -
- -
- validate on - patch({ validateOn: t.trim() || undefined })} /> -
-
- ); -} - -/** the volcano's label thresholds the card offers */ -const RISK_LABEL_STEPS = [0.1, 0.25, 0.5, 0.75, 0.9, 1]; +export const drawsOwnWaiting = (k: unknown): boolean => !!kindUi(k)?.drawsOwnWaiting; /** The result as the figure draws it: settings applied in the browser (dataPart) read from the document, not the last run. */ export function figureView(spec: any, v: any): any { - if (v?.kind === "riskmodel" && v.riskmodel) return { ...v, riskmodel: { ...v.riskmodel, labelAbove: Number(spec.labelAbove ?? RISK_LABEL_ABOVE) } }; - return v; + const f = kindUi(v?.kind)?.figureView; + return f ? f(spec, v) : v; } /** A one-line subtitle for the card header. */ export function kindSub(kind: string, spec: any, v: any): React.ReactNode { - if (kind === "attrition") return "patients left after each clause, in order"; - if (kind === "enrichment") return ({ mutation: "mutations", cna: "copy-number alterations", amp: "amplifications", homdel: "deep deletions", sv: "structural variants" } as Record)[spec.alteration || "mutation"] + - (spec.measure === "driver" ? " (drivers)" : "") + " in every gene, per sample"; - if (kind === "overlap") return "patients in exactly each combination of cohorts"; - if (kind === "mutations") return (spec.gene || "no gene") + " · every mutation call" + (v.mutations ? " · " + v.mutations.total.toLocaleString() + " rows" : ""); - if (kind === "timeline") { - const cohort = Array.isArray(v.cohorts) && v.cohorts[0] ? String(v.cohorts[0]).split(" / ").join(" › ") : null; - return <>{cohort && <>{cohort} · }{v.timeline ? v.timeline.total.toLocaleString() + " patients · " : ""}clinical events from diagnosis; - } - if (kind === "alterations") { - const cohort = Array.isArray(v.cohorts) && v.cohorts[0] ? String(v.cohorts[0]).split(" / ").join(" › ") : null; - return <>{cohort && <>{cohort} · }{v.alterations ? v.alterations.total.toLocaleString() + " patients · " : ""}mutations, copy-number alterations and structural variants; - } - if (kind === "table") return "one row per " + (spec.unit === "patient" ? "patient" : "sample") + (v.table ? " · " + v.table.total.toLocaleString() + " rows, " + v.table.columns.length + " attributes" : ""); - if (kind === "correlates") return (spec.gene || "no gene") + " on " + (spec.profileType || "no profile") + (spec.otherProfile && spec.otherProfile !== spec.profileType ? " against " + spec.otherProfile : "") + " · Spearman ρ, every gene"; - if (kind === "assays") return (v.assays ? v.assays.profileName + " · " + v.assays.test : spec.profileType || "no profile chosen") + ", per " + (v.assays ? v.assays.unit.replace(/s$/, "") : "sample"); - if (kind === "composition") { - const c = v.composition; - const what = c ? c.sourceName : spec.profileType || (spec.attributes?.length ? spec.attributes.length + " attributes" : "no parts chosen"); - return what + (c ? " · " + c.parts.length + " parts per " + c.unit.replace(/s$/, "") : "") + ((spec.normalize ?? true) ? ", as shares" : ", as recorded") + - (c ? " · sorted by " + (c.parts.find((x: { id: string }) => x.id === c.sort)?.name || c.sort) : ""); - } - if (kind === "cancertypes") return "by " + (spec.groupBy || "CANCER_TYPE") + (spec.measure === "driver" ? ", drivers" : ", any alteration") + ", % of profiled samples"; - if (kind === "riskmodel") return (spec.endpoint || "OS") + (spec.anchor?.eventType ? " from first " + spec.anchor.eventType + (spec.anchor.key ? " [" + spec.anchor.key + "=" + spec.anchor.value + "]" : "") : "") + - " on " + (spec.measure === "driver" ? "driver mutations" : "mutated genes") + - (spec.covariates?.length ? " + " + spec.covariates.length + " covariate" + (spec.covariates.length === 1 ? "" : "s") : "") + - (spec.strata ? ", stratified by " + spec.strata : "") + " · " + (spec.runs ?? 10) + " held-out splits" + (spec.validate ? ", validated on the held-out half" : "") + - (spec.validateOn ? ", scored on “" + spec.validateOn + "”" : ""); - if (kind === "drivernegative") return (spec.alteration === "sv" ? "structural variants" : spec.alteration === "cna" ? "copy-number alterations" : "mutations") + - " without vs with a driver in " + (spec.drivers?.length ? spec.drivers.length + " genes" : "the NSCLC set") + ", per patient"; - if (kind === "organotropism") return (spec.measure === "any" ? "" : "driver ") + (spec.alteration || "mutation") + "s in " + - (spec.genes?.length ? spec.genes.length + " genes" : "the " + (spec.top ?? 12) + " most altered genes") + " × metastatic sites from " + (spec.source && spec.source !== "auto" ? spec.source : "the study's site source") + - (spec.stratifyBy === null ? ", unstratified" : ", within " + (spec.stratifyBy || "CANCER_TYPE")); - if (kind === "germline") return "germline carriers per patient sequenced, " + (spec.genes?.length ? spec.genes.length + " genes" : "every gene with ≥" + (spec.minCarriers ?? 3) + " carriers") + - " · somatic second hit vs non-carriers" + (spec.survival?.gene ? " · OS by " + spec.survival.gene + " carrier status" : ""); - if (kind === "molsubtype") return "endometrial molecular classification per patient (POLE, MMRd, p53abn, NSMP)" + (spec.by ? " · by " + spec.by : "") + - (spec.survival === false ? "" : " · OS by subtype"); - if (kind === "actionability") return "highest OncoKB level per patient, any cancer type" + (spec.groupBy ? " · by " + spec.groupBy : "") + - (spec.pairBy === null ? "" : " · " + (spec.pairBy?.attribute || "SAMPLE_TYPE") + " concordance"); - if (kind === "adjusted") return "mutations in every gene mutated in ≥" + (100 * (spec.minShare ?? 0.05)).toFixed(0) + "% · adjusted for " + (spec.covariates?.length ? spec.covariates.join(", ") : "nothing"); - if (kind === "forest") { - const e = spec.exposure || {}; - const what = e.source === "alteration" ? (e.oql || (e.genes || [e.gene]).join(" or ") + " mutated") : e.attribute ? e.attribute + (spec.threshold != null ? " ≥ " + spec.threshold : spec.exposed ? " = " + spec.exposed : "") : "no exposure chosen"; - return what + " · " + (spec.endpoint || "OS") + " hazard ratio per " + (spec.strata || "CANCER_TYPE") + ", random-effects pooled"; - } - if (kind === "labs") { - const an = spec.anchor || {}; - return (spec.test || "no test chosen") + " from first " + (an.eventType || "?") + (an.key ? " [" + an.key + "=" + an.value + "]" : "") + - " · responders fell ≥" + (spec.responseDrop ?? 50) + "%"; - } - if (kind === "incidence") { - const e = spec.event || {}; - const an = spec.anchor; - return (e.label || e.eventType || "no event chosen") + - (an && an.eventType ? " from first " + an.eventType + (an.key ? " [" + an.key + "=" + an.value + "]" : "") : " from day 0") + ", death competing"; - } - if (kind === "hla") return "alleles at " + (spec.resolution === 1 ? "1 field" : spec.resolution === "full" ? "full resolution" : "2 fields") + " · " + (spec.endpoint || "OS") + - " by homozygosity and by HLA LOH" + (spec.covariates && spec.covariates.length ? " · adjusted for " + spec.covariates.join(", ") : ""); - if (kind === "cutpoint") return (spec.attribute || "TMB_NONSYNONYMOUS") + " on " + (spec.endpoint || "OS") + " · best log-rank cut, " + (spec.permutations ?? 1000) + " permutations" + - (spec.stratifyBy ? " · per " + spec.stratifyBy : ""); - if (kind === "predictive") { - const ex = spec.exposure || {}; - return "gene × " + (ex.eventType || "?") + (ex.key ? " [" + ex.key + "=" + ex.value + "]" : "") + " on " + (spec.endpoint || "OS") + - (spec.covariates?.length ? " + " + spec.covariates.length + " covariate" + (spec.covariates.length === 1 ? "" : "s") : "") + " · Cox per gene"; - } - if (kind === "rmst") { - const lm = Array.isArray(spec.landmarkMonths) && spec.landmarkMonths.some((x: number) => x > 0) ? " · landmarks " + spec.landmarkMonths.join(", ") + " mo" : ""; - return "area under the KM curve to " + (spec.tau != null ? "τ = " + spec.tau + " months" : "τ where every cohort has 10 at risk") + " · " + (spec.anchor?.eventType ? "OS from first " + spec.anchor.eventType + (spec.anchor.key ? " [" + spec.anchor.key + "=" + spec.anchor.value + "]" : "") : (spec.endpoint || "OS")) + lm; - } - if (kind === "multistate") { - const pr = spec.progression || {}; - return "start → progression → death, months from day 0 · progression = " + (pr.eventType || "Diagnosis") + " " + (pr.key || "PROGRESSION") + - (spec.prevalent === "exclude" ? " · progressed by day 0 left out" : ""); - } - if (kind === "txendpoints") { - const an = spec.anchor || {}; - const ep = spec.endpoint === "tot" ? "time on treatment" : spec.endpoint === "rwpfs" ? "real-world PFS" : "time to next treatment"; - return ep + " from " + (an.occurrence === "LAST" ? "last " : "first ") + (an.eventType || "?") + (an.key ? " [" + an.key + "=" + an.value + "]" : "") + ", death an event"; - } - if (kind === "clonality") return "FACETS allele-specific copy number" + (spec.measure === "any" ? ", every mutation" : ", driver mutations") + (spec.qc === false ? ", all samples" : ", QC-passing samples"); - if (kind === "paired") { - const pb = spec.pairBy || { attribute: "SAMPLE_TYPE", a: "Primary", b: "Metastasis" }; - const j = (x: any) => Array.isArray(x) ? x.join("/") : String(x ?? ""); - const sides = v.paired ? v.paired.labels.a + " vs " + v.paired.labels.b - : pb.studies ? "sides set per study" : pb.attribute + ": " + j(pb.a) + " vs " + j(pb.b); - return sides + (spec.measure === "any" ? ", any alteration" : ", drivers") + (spec.genes && spec.genes.length ? "" : ", every gene scanned"); - } - if (kind === "acquired") { - const an = spec.anchor || { eventType: "Treatment" }; - return "last sample before the first " + (an.eventType || "?") + (an.key ? " [" + an.key + "=" + an.value + "]" : "") + " vs the first ≥" + (spec.minGapDays ?? 30) + " days after" + - (spec.measure === "driver" ? ", oncogenic only" : ", any alteration") + (spec.genes && spec.genes.length ? "" : ", every gene scanned"); - } - if (kind === "instability") return "from hg19 segments, ±" + (spec.threshold ?? 0.2) + ", arm called past " + Math.round(100 * (spec.armShare ?? 0.5)) + "% of its length" + (spec.includeX ? ", with chrX" : ""); - if (kind === "exclusivity") { - const cohort = Array.isArray(v.cohorts) && v.cohorts[0] ? String(v.cohorts[0]).split(" / ").join(" › ") : null; - return <>{cohort && <>{cohort} · }{(spec.measure === "driver" ? "oncogenic alterations" : "any alteration") + ", background from each tumour's burden" + - (spec.stratifyBy ? ", within " + spec.stratifyBy : "")}; - } - if (kind === "ctdna") return "plasma " + (spec.attribute || "SAMPLE_CLASS") + " = " + (spec.plasma || ["cfDNA", "ctDNA"]).join("/") + " vs tissue " + (spec.tissue || ["Tumor"]).join("/") + - (spec.measure === "driver" ? ", oncogenic only" : ", any alteration") + (spec.withinDays != null ? ", drawn within " + spec.withinDays + " days" : "") + - (spec.chFilter ? ", clonal haematopoiesis filtered" : "") + - (spec.genes && spec.genes.length ? "" : ", the most altered genes"); - if (kind === "diffexpr") return (spec.profileType || "no profile") + (v.diffexpr ? (v.diffexpr.log2 ? ", log2(v+1)" : "") + " · volcano and GSEA" : ""); - if (kind === "pathways") return (spec.measure === "any" ? "any alteration" : "OncoKB drivers") + ", Sanchez-Vega et al. 2018"; - if (kind === "genome") return v.genome ? v.genome.build + ", " + v.genome.binMb + " Mb bins, from segments" : "from copy-number segments"; - void spec; void v; - return null; + const sub = kindUi(kind)?.sub; + return sub ? sub(spec, v) : null; } /** This kind's settings row, or nothing when it has none. */ export function KindCardControls({ c }: { c: KindCardCtx }) { - const { spec, busy, patch } = c; - if (spec.kind === "genome") return ( -
-
data - - - -
-
- ); - if (spec.kind === "diffexpr") return ( -
-
data - - - -
-
- ); - // the gene is its only setting; a portal tab whose chips set it leaves no bar at all - if (spec.kind === "mutations") return (c.locked || []).includes("gene") ? null : ( -
-
data - {!(c.locked || []).includes("gene") && } - -
-
- ); - // its drawing settings sit in the card, over the timeline (app/lab/timeline-body.tsx) - if (spec.kind === "timeline") return null; - // every setting is a table's own, in the card (app/lab/alterations-body.tsx) - if (spec.kind === "alterations") return null; - // the search, filters, sort and columns are the table's own (app/_table/DataTable.tsx); a change of unit starts them over - if (spec.kind === "table") return ( -
-
data - -
-
- ); - if (spec.kind === "correlates") { - const profs = c.profiles.filter((p) => p !== "cna"); - const pick = (value: string, set: (v: string) => void, title: string, label: string) => ( - - ); - return ( -
-
data - - {pick(spec.profileType || "", (v) => patch({ profileType: v }), "the query gene's profile", "on")} - {pick(spec.otherProfile || spec.profileType || "", (v) => patch({ otherProfile: v === spec.profileType ? undefined : v }), "the profile every other gene is read from", "against")} - -
-
- ); - } - if (spec.kind === "assays") return ( -
-
data - - -
-
- ); - if (spec.kind === "composition") { - const parts: string[] = c.v?.composition ? c.v.composition.parts.map((x: { id: string }) => x.id) : spec.attributes || spec.entities || []; - const names = new Map(c.v?.composition ? c.v.composition.parts.map((x: { id: string; name: string }) => [x.id, x.name]) : []); - const fromProfile = !spec.attributes?.length; - return ( -
-
data - - {!fromProfile && } - {!fromProfile && } -
-
view - {parts.length > 0 && } - -
-
- ); - } - if (spec.kind === "enrichment") return ( -
-
data - - - -
-
- ); - if (spec.kind === "cancertypes") { - const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); - const by = spec.groupBy || "CANCER_TYPE"; - return ( -
-
data - - - - -
-
view - -
-
- ); - } - if (spec.kind === "riskmodel") { - const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); - const labelAbove = Number(spec.labelAbove ?? RISK_LABEL_ABOVE); - return ( -
-
data - - - a.attribute !== spec.strata).map((a) => ({ value: a.attribute, label: a.attribute, hint: a.kind === "num" ? "number" : "category" }))} - chosen={spec.covariates || []} max={6} busy={busy} menuId={"risk-cov:" + c.vi} menuStore={RISK_MENUS} - onChange={(next) => patch({ covariates: next.length ? next : undefined })} /> - - {spec.anchor?.eventType && <> - - - } -
-
model - - - -
-
view - -
-
- ); - } - if (spec.kind === "germline") { - const genes: string[] = Array.isArray(spec.genes) ? spec.genes : []; - return ( -
-
data - - - -
-
- ); - } - if (spec.kind === "molsubtype") { - const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); - const by = spec.by || ""; - return ( -
-
data - - - -
-
- ); - } - if (spec.kind === "organotropism") { - const genes: string[] = Array.isArray(spec.genes) ? spec.genes : []; - const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); - const strat = spec.stratifyBy === null ? "" : spec.stratifyBy || "CANCER_TYPE"; - return ( -
-
data - - - - -
-
- ); - } - if (spec.kind === "drivernegative") { - const drv: string[] = Array.isArray(spec.drivers) ? spec.drivers : []; - return ( -
-
data - - - -
-
- ); - } - if (spec.kind === "actionability") { - const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); - const by = spec.groupBy || ""; - return ( -
-
data - - - -
-
- ); - } - if (spec.kind === "adjusted") { - const covs: string[] = Array.isArray(spec.covariates) ? spec.covariates : []; - return ( -
-
data - - - -
-
- ); - } - if (spec.kind === "forest") { - const e = spec.exposure || { source: "alteration", gene: "" }; - const isGene = e.source === "alteration"; - const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); - const strata = spec.strata || "CANCER_TYPE"; - return ( -
-
exposure - - {isGene - ? patch({ exposure: { source: "alteration", gene: t.trim().toUpperCase() } })} /> - : <> - patch({ exposure: { source: "clinical", attribute: t.trim() } })} /> - - - } -
-
rows - - - -
-
- ); - } - if (spec.kind === "labs") { - const an = spec.anchor || { eventType: "" }; - const types = c.events.map((x) => x.label); - return ( -
-
test - - patch({ test: t.trim() })} /> - - -
-
clock - - - - -
-
- ); - } - if (spec.kind === "hla") { - const nums = c.attrs.filter((a) => a.kind === "num").map((a) => a.attribute); - const cov = (spec.covariates || [])[0] || ""; - return ( -
-
data - - -
-
- ); - } - if (spec.kind === "cutpoint") { - const nums = c.attrs.filter((a) => a.kind === "num").map((a) => a.attribute); - const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); - const attr = spec.attribute || "TMB_NONSYNONYMOUS"; - return ( -
-
data - - - -
-
- ); - } - if (spec.kind === "predictive") { - const ex = spec.exposure || { eventType: "Treatment" }; - const types = c.events.map((x) => x.label); - return ( -
-
exposure - - - -
-
- ); - } - if (spec.kind === "rmst") { - const lms = Array.isArray(spec.landmarkMonths) ? spec.landmarkMonths.join(", ") : "0"; - return ( -
-
restrict - - -
-
- ); - } - if (spec.kind === "multistate") { - const pr = spec.progression || {}; - const types = c.events.map((x) => x.label); - const et = pr.eventType || "Diagnosis"; - return ( -
-
progression - - -
-
day 0 - -
-
- ); - } - if (spec.kind === "txendpoints") { - const an = spec.anchor || { eventType: "Treatment" }; - const types = c.events.map((x) => x.label); - return ( -
-
endpoint - - -
-
clock - - - -
-
- ); - } - if (spec.kind === "incidence") { - const e = spec.event || { eventType: "" }, a0 = (e.attrs || [])[0] || { key: "", value: "" }; - const an = spec.anchor || null; - const setEvent = (next: any) => patch({ event: next }); - const types = c.events.map((x) => x.label); - return ( -
-
event - - - -
-
clock - - {an && an.key && where {an.key} = {String(an.value ?? "")}} -
-
- ); - } - if (spec.kind === "clonality") return ( -
-
data - - -
-
- ); - if (spec.kind === "paired") { - const pb = spec.pairBy || { attribute: "SAMPLE_TYPE", a: "Primary", b: "Metastasis" }; - const csv = (x: any) => (Array.isArray(x) ? x : [x]).filter((y: any) => y != null && y !== "").join(", "); - const parse = (t: string) => { const xs = t.split(",").map((y) => y.trim()).filter(Boolean); return xs.length === 1 ? xs[0] : xs; }; - const setBy = (k: "attribute" | "a" | "b", t: string) => patch({ pairBy: { ...pb, [k]: k === "attribute" ? t.trim() : parse(t) } }); - const scanning = !(spec.genes && spec.genes.length); - const perStudy = pb.studies ? Object.keys(pb.studies).length : 0; - return ( -
-
data - {perStudy ? ( - st + ": " + sp.attribute + " " + csv(sp.a) + " vs " + csv(sp.b)).join("\n")}> - sides set per study in the document ({perStudy} {perStudy === 1 ? "study" : "studies"}) - - ) : <> - - - setBy("a", t)} /> - - - } - - {scanning && ( - - )} - -
-
- ); - } - if (spec.kind === "acquired") { - const an = spec.anchor || { eventType: "Treatment" }; - const types = c.events.map((x) => x.label); - const scanning = !(spec.genes && spec.genes.length); - return ( -
-
clock - - - - -
-
data - - - -
-
- ); - } - if (spec.kind === "instability") return ( -
-
data - - - -
-
- ); - if (spec.kind === "exclusivity") { - const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); - return ( -
-
data - - -
-
- ); - } - if (spec.kind === "ctdna") { - const choosing = !!(spec.genes && spec.genes.length); - const csv = (x: any, d: string[]) => (Array.isArray(x) && x.length ? x : d).join(", "); - const parse = (t: string) => t.split(",").map((y) => y.trim()).filter(Boolean); - return ( -
-
data - - - - - -
-
- ); - } - if (spec.kind === "pathways") return ( -
-
data - - -
-
- ); - return null; -} - -/** The view a fresh "add view" click starts from, built from what the study is known to have. */ -export function defaultKindView(kind: string, known: Known): Record { - if (kind === "cancertypes") return { kind, genes: known.genes.length ? known.genes.slice(0, 5) : ["TP53", "KRAS"] }; - if (kind === "adjusted") return { kind }; - if (kind === "actionability") return { kind, groupBy: "CANCER_TYPE" }; - if (kind === "drivernegative") return { kind }; - if (kind === "organotropism") return { kind }; - if (kind === "molsubtype") return { kind }; - if (kind === "germline") return { kind }; - if (kind === "forest") return { kind, exposure: { source: "alteration", gene: known.genes[0] || "TP53" } }; - if (kind === "labs") { - const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); - return { kind, test: "PSA", anchor: { eventType: tx ? tx.label : "Treatment" } }; - } - if (kind === "hla") { - const tmb = known.attrs.find((a) => a.attribute === "TMB_NONSYNONYMOUS" || a.attribute === "TMB_SCORE"); - return tmb ? { kind, covariates: [tmb.attribute] } : { kind }; - } - if (kind === "cutpoint") { - const num = known.attrs.find((a) => a.attribute === "TMB_NONSYNONYMOUS") || known.attrs.find((a) => a.kind === "num"); - return { kind, attribute: num ? num.attribute : "TMB_NONSYNONYMOUS" }; - } - if (kind === "predictive") { - const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); - return { kind, exposure: { eventType: tx ? tx.label : "Treatment", key: "SUBTYPE", value: "Immuno" }, genes: known.genes.length ? known.genes.slice(0, 8) : ["STK11", "KEAP1", "KRAS", "TP53", "EGFR"] }; - } - if (kind === "multistate") { - const dx = known.events.find((e) => /diagnos/i.test(e.canonical)); - return { kind, progression: { eventType: dx ? dx.label : "Diagnosis", key: "PROGRESSION", values: ["Y", "Yes"] } }; - } - if (kind === "txendpoints") { - const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); - return { kind, endpoint: "ttnt", anchor: { eventType: tx ? tx.label : "Treatment", key: "SUBTYPE", value: "Immuno" } }; - } - if (kind === "incidence") { - const ev = known.events.find((e) => /diagnos|progress|status/i.test(e.canonical)) || known.events[0]; - return { kind, event: { eventType: ev ? ev.label : "" } }; - } - if (kind === "clonality") return { kind, genes: known.genes.length ? known.genes.slice(0, 8) : ["TP53", "KRAS", "APC", "PIK3CA", "SMAD4", "CDH1"] }; - if (kind === "paired") return { kind, genes: known.genes.length ? known.genes.slice(0, 8) : PAIRED_DEFAULT_GENES }; - if (kind === "acquired") { - const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); - return { kind, anchor: { eventType: tx ? tx.label : "Treatment" } }; - } - if (kind === "exclusivity") return { kind, genes: known.genes.length >= 2 ? known.genes.slice(0, 8) : ["KRAS", "EGFR", "BRAF", "STK11", "KEAP1", "TP53"] }; - if (kind === "ctdna") return known.genes.length ? { kind, genes: known.genes.slice(0, 10) } : { kind }; - if (kind === "mutations") return { kind, gene: known.genes[0] || "TP53" }; - if (kind === "table") return { kind }; - if (kind === "timeline" || kind === "alterations") return { kind }; - if (kind === "correlates") { - const profs = known.profiles.filter((p) => p !== "cna"); - const profile = profs.find((p) => /mrna|rna_seq/i.test(p) && !/zscore/i.test(p)) || profs.find((p) => /mrna|rna_seq/i.test(p)) || profs[0] || ""; - return { kind, gene: known.genes[0] || "TP53", profileType: profile }; - } - if (kind === "assays") { - const profile = known.assays.find((p) => /signature/i.test(p) && /contribution/i.test(p)) || known.assays.find((p) => !/methylation/i.test(p)) || known.assays[0] || ""; - return { kind, profileType: profile }; - } - if (kind === "composition") { - const profile = known.assays.find((p) => /cell_type.*relative|fraction|proportion/i.test(p)) || known.assays.find((p) => /signature/i.test(p) && /contribution/i.test(p)) || known.assays[0]; - return profile ? { kind, profileType: profile } : { kind, attributes: [] }; - } - if (kind === "diffexpr") { - const profile = known.profiles.find((p) => /zscore/i.test(p) && /mrna|rna_seq/i.test(p)) - || known.profiles.find((p) => /mrna|rna_seq/i.test(p)) || known.profiles[0] || ""; - return { kind, profileType: profile }; - } - return { kind }; + const Controls = kindUi(c.spec.kind)?.Controls; + return Controls ? : null; } /** A body of the kind's own in place of the server figure, or null to draw the figure. */ export function KindCardBody({ c }: { c: KindCardCtx }): React.ReactNode | null { - if (c.spec.kind === "cancertypes" && c.v.cancertypes) return ; - if (c.spec.kind === "enrichment" && c.v.enrichment) return ; - if (c.spec.kind === "correlates" && c.v.correlates) return ; - if (c.spec.kind === "assays" && c.v.assays) return ; - if (c.spec.kind === "mutations" && c.v.mutations) return c.patch({ ...q })} />; - if (c.spec.kind === "table" && c.v.table) return ; - if (c.spec.kind === "alterations" && c.v.alterations) return ; - if (c.spec.kind === "timeline" && c.v.timeline) return ; + const body = kindUi(c.spec.kind)?.body; + return body ? body(c) ?? null : null; +} - return null; +/** One view on the shared card: its header, its settings, then its body or its figure. */ +export function KindCard({ ctx, v, vi }: CardProps) { + const { assayOptions, busy, catalogAttrs, cohortChoice, doc, docView, menuOpenRef, openGenes, patchView, pendingKeys, profileOptions, viewEvents, viewIds } = ctx; + const ui = kindUi(v.kind); + return <> + {ui && !ui.Card && <> + openGenes(vi)}>genes + : undefined} /> + {(() => { + const mc = { vi, spec: docView(vi), v, busy, updating: busy || pendingKeys.has(viewIds[vi]), patch: (p: Record) => patchView(vi, p), + attrs: catalogAttrs(), profiles: profileOptions(), assays: assayOptions(), events: viewEvents(), docJson: () => JSON.stringify(doc) }; + const body = KindCardBody({ c: mc }); + return <> + + {v.note &&

{v.note}

} + {body || } + {body && v.caveat &&

{v.caveat}

} + ; + })()} + } + ; } -/** The clinical data table's body, on the shared table (app/_table/DataTable.tsx). */ -function ClinicalTableBody({ c }: { c: KindCardCtx }) { - const t = c.v.table as import("../../lib/views/table/types.ts").TableResult; - return c.patch({ ...q, attributes: undefined })} - cell={(id, x) => (id === "patient" || id === "sample") ? {x} : undefined} />; +/** The view a fresh "add view" click starts from, built from what the study is known to have. */ +export function defaultView(kind: string, known: Known, page: AddPage): Record { + const defaults = kindUi(kind)?.defaults; + return defaults ? defaults(known, page) : { kind }; } diff --git a/app/lab/kinds/acquired.tsx b/app/lab/kinds/acquired.tsx new file mode 100644 index 0000000..8528f08 --- /dev/null +++ b/app/lab/kinds/acquired.tsx @@ -0,0 +1,75 @@ +"use client"; +// The acquired view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput, PAIRED_DEFAULT_GENES } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the acquired view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const an = spec.anchor || { eventType: "Treatment" }; + const types = c.events.map((x) => x.label); + const scanning = !(spec.genes && spec.genes.length); + return ( +
+
clock + + + + +
+
data + + + +
+
+ ); +} + +export const acquiredUi: KindUi<"acquired"> = { + kind: "acquired", + label: "Acquired under therapy (samples before vs after a treatment start)", + title: "Acquired under therapy", + genes: true, + maxCohorts: 3, + why: (k) => k.events.some((e) => /treat|therap|drug|medicat/i.test(e.canonical)) ? null : "this study records no treatment timeline events to anchor on", + defaults: (known) => { + const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); + return { kind: "acquired", anchor: { eventType: tx ? tx.label : "Treatment" } }; + }, + sub: (spec) => { + const an = spec.anchor || { eventType: "Treatment" }; + return "last sample before the first " + (an.eventType || "?") + (an.key ? " [" + an.key + "=" + an.value + "]" : "") + " vs the first ≥" + (spec.minGapDays ?? 30) + " days after" + + (spec.measure === "driver" ? ", oncogenic only" : ", any alteration") + (spec.genes && spec.genes.length ? "" : ", every gene scanned"); + }, + Controls, +}; diff --git a/app/lab/kinds/actionability.tsx b/app/lab/kinds/actionability.tsx new file mode 100644 index 0000000..25c426d --- /dev/null +++ b/app/lab/kinds/actionability.tsx @@ -0,0 +1,45 @@ +"use client"; +// The actionability view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Checkbox } from "../../_ui/checkbox"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the actionability view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const by = spec.groupBy || ""; + return ( +
+
data + + + +
+
+ ); +} + +export const actionabilityUi: KindUi<"actionability"> = { + kind: "actionability", + label: "Clinical actionability (OncoKB levels, primary vs metastasis)", + title: "Clinical actionability", + maxCohorts: 4, + defaults: () => ({ kind: "actionability", groupBy: "CANCER_TYPE" }), + sub: (spec) => "highest OncoKB level per patient, any cancer type" + (spec.groupBy ? " · by " + spec.groupBy : "") + + (spec.pairBy === null ? "" : " · " + (spec.pairBy?.attribute || "SAMPLE_TYPE") + " concordance"), + Controls, +}; diff --git a/app/lab/kinds/adjusted.tsx b/app/lab/kinds/adjusted.tsx new file mode 100644 index 0000000..ae22888 --- /dev/null +++ b/app/lab/kinds/adjusted.tsx @@ -0,0 +1,41 @@ +"use client"; +// The adjusted view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the adjusted view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const covs: string[] = Array.isArray(spec.covariates) ? spec.covariates : []; + return ( +
+
data + + + +
+
+ ); +} + +export const adjustedUi: KindUi<"adjusted"> = { + kind: "adjusted", + label: "Adjusted enrichment (every gene, logistic on covariates)", + title: "Adjusted enrichment", + maxCohorts: 2, + minCohorts: 2, + sub: (spec) => "mutations in every gene mutated in ≥" + (100 * (spec.minShare ?? 0.05)).toFixed(0) + "% · adjusted for " + (spec.covariates?.length ? spec.covariates.join(", ") : "nothing"), + Controls, +}; diff --git a/app/lab/kinds/alterations.tsx b/app/lab/kinds/alterations.tsx new file mode 100644 index 0000000..0841f36 --- /dev/null +++ b/app/lab/kinds/alterations.tsx @@ -0,0 +1,18 @@ +"use client"; +// The alterations view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { AlterationsBody } from "../alterations-body"; +import type { KindUi } from "./kind-ui.ts"; + +export const alterationsUi: KindUi<"alterations"> = { + kind: "alterations", + label: "Patient alterations (mutations, copy number, SVs; one patient at a time)", + title: "Patient alterations", + maxCohorts: 1, + cohortInHeader: true, + sub: (_spec, v) => { + const cohort = Array.isArray(v.cohorts) && v.cohorts[0] ? String(v.cohorts[0]).split(" / ").join(" › ") : null; + return <>{cohort && <>{cohort} · }{v.alterations ? v.alterations.total.toLocaleString() + " patients · " : ""}mutations, copy-number alterations and structural variants; + }, + body: (c) => c.v.alterations ? : null, + drawsOwnWaiting: true, +}; diff --git a/app/lab/kinds/assays.tsx b/app/lab/kinds/assays.tsx new file mode 100644 index 0000000..36039fa --- /dev/null +++ b/app/lab/kinds/assays.tsx @@ -0,0 +1,85 @@ +"use client"; +// The assays view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Badge, type Col, PortalTable } from "../portal-bodies.tsx"; +import { DrawAtWidth } from "../figure.tsx"; +import type { DrawnResultOf, ViewResultOf } from "../../../lib/types"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; +import { assayEntitySvg } from "../../../lib/views/assays/figure.ts"; +import { fmtNum } from "../../../lib/chart/format"; +import { pDigits } from "../../../lib/chart/format.ts"; +import { seriesStyle } from "../../../lib/chart/style"; +import { useState } from "react"; + +// ---- Generic assays: one row per entity, and the picked entity per group -------------------- + +type AsRow = NonNullable["assays"]>["rows"][number]; + +function AssaysBody({ v }: { v: DrawnResultOf<"assays"> }) { + const r = v.assays; + const [sel, setSel] = useState(null); + const picked = sel && r.rows.includes(sel) ? sel : r.rows[0] || null; + const color = (g: string) => seriesStyle(Math.max(0, r.groups.indexOf(g))).color; + const numeric = r.datatype === "numeric"; + const cols: Col[] = [ + { key: "name", label: "Entity", value: (e) => e.name, render: (e) => {e.name} }, + ...r.groups.flatMap((name, i): Col[] => numeric + ? [{ key: "m" + i, label: name + " mean", num: true, value: (e) => e.cells[i].mean ?? null, render: (e) => e.cells[i].mean == null ? "—" : fmtNum(e.cells[i].mean!) }, + { key: "s" + i, label: name + " SD", num: true, value: (e) => e.cells[i].sd ?? null, render: (e) => e.cells[i].sd == null ? "—" : fmtNum(e.cells[i].sd!) }] + : [{ key: "n" + i, label: name + " n", num: true, value: (e) => e.cells[i].n }]), + { key: "p", label: "p-Value", num: true, value: (e) => e.p, render: (e) => pDigits(e.p) }, + { key: "q", label: "q-Value", num: true, value: (e) => e.q, render: (e) => {pDigits(e.q)} }, + { key: "in", label: "Higher in", value: (e) => e.higherIn, render: (e) => }, + ]; + return ( +
+

{r.profileName} · {r.test} · {r.tested.toLocaleString()} entities tested · {r.significant.toLocaleString()} at q < 0.05

+
+
+ e.name + " " + e.entity} selected={picked} onSelect={setSel} + filters={[{ label: "Significant only", test: (e) => e.q < 0.05 }]} + note={r.tested > r.rows.length ? "the " + r.rows.length + " most significant of " + r.tested.toLocaleString() : undefined} /> +
+ {picked &&
assayEntitySvg(r, picked, { width })} deps={[r, picked]} />
} +
+
+ ); +} + +/** the assays view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + return ( +
+
data + + +
+
+ ); +} + +export const assaysUi: KindUi<"assays"> = { + kind: "assays", + label: "Generic assays (signatures, arm-level CNA, …)", + title: "Generic assays", + maxCohorts: 6, + minCohorts: 2, + why: (k) => !k.assays.length ? "this study has no generic-assay profiles (mutational signatures, arm-level copy number, …)" : null, + defaults: (known) => { + const profile = known.assays.find((p) => /signature/i.test(p) && /contribution/i.test(p)) || known.assays.find((p) => !/methylation/i.test(p)) || known.assays[0] || ""; + return { kind: "assays", profileType: profile }; + }, + sub: (spec, v) => (v.assays ? v.assays.profileName + " · " + v.assays.test : spec.profileType || "no profile chosen") + ", per " + (v.assays ? v.assays.unit.replace(/s$/, "") : "sample"), + Controls, + body: (c) => c.v.assays ? : null, +}; diff --git a/app/lab/kinds/attrition.tsx b/app/lab/kinds/attrition.tsx new file mode 100644 index 0000000..33a7154 --- /dev/null +++ b/app/lab/kinds/attrition.tsx @@ -0,0 +1,11 @@ +"use client"; +// The attrition view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { KindUi } from "./kind-ui.ts"; + +export const attritionUi: KindUi<"attrition"> = { + kind: "attrition", + label: "Cohort attrition (CONSORT)", + title: "Cohort attrition", + maxCohorts: 6, + sub: () => "patients left after each clause, in order", +}; diff --git a/app/lab/kinds/cancertypes.tsx b/app/lab/kinds/cancertypes.tsx new file mode 100644 index 0000000..9d9ed0b --- /dev/null +++ b/app/lab/kinds/cancertypes.tsx @@ -0,0 +1,97 @@ +"use client"; +// The cancertypes view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DrawAtWidth } from "../figure.tsx"; +import type { DrawnResultOf } from "../../../lib/types"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; +import { SEG_TAB, SEG_TABS } from "../../_ui/toolbar.ts"; +import { Tabs, TabsList, TabsTrigger } from "../../_ui/tabs"; +import { cancertypesPanelSvg } from "../../../lib/views/cancertypes/figure.ts"; +import { frame, palette } from "../../../lib/svg-kit"; +import { useState } from "react"; + +/** Tabs over a body, the portal's gene / group selectors. */ +function Pills({ items, on, set }: { items: string[]; on: string; set: (x: string) => void }) { + return ( + + + {items.map((x) => {x})} + + + ); +} + +// ---- Cancer Types Summary: "All Queried Genes" and one tab per gene, a bar chart per cohort ---- + +function CancertypesBody({ v }: { v: DrawnResultOf<"cancertypes"> }) { + const r = v.cancertypes; + const ALL = "All Queried Genes"; + const [gene, setGene] = useState(ALL); + // each cohort's chart laid out for the card's width; one with more groups than fit grows and scrolls + const draw = (i: number) => (width: number) => { + const c = cancertypesPanelSvg(r, i, { width, gene: gene === ALL ? null : gene, title: r.panels.length > 1 || gene !== ALL }); + return frame(c.width, c.height, c.body, palette("auto").bg, c.grown, "Cancer types summary — " + r.panels[i].cohort); + }; + return ( +
+ {r.genes.length > 1 && } + {r.panels.map((_, i) => )} +
+ ); +} + +/** the cancertypes view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const by = spec.groupBy || "CANCER_TYPE"; + return ( +
+
data + + + + +
+
view + +
+
+ ); +} + +export const cancertypesUi: KindUi<"cancertypes"> = { + kind: "cancertypes", + label: "Cancer types summary", + title: "Cancer types summary", + genes: true, + maxCohorts: 2, + defaults: (known) => ({ kind: "cancertypes", genes: known.genes.length ? known.genes.slice(0, 5) : ["TP53", "KRAS"] }), + sub: (spec) => "by " + (spec.groupBy || "CANCER_TYPE") + (spec.measure === "driver" ? ", drivers" : ", any alteration") + ", % of profiled samples", + Controls, + body: (c) => c.v.cancertypes ? : null, +}; diff --git a/app/lab/kinds/clinical.tsx b/app/lab/kinds/clinical.tsx new file mode 100644 index 0000000..83b2947 --- /dev/null +++ b/app/lab/kinds/clinical.tsx @@ -0,0 +1,69 @@ +"use client"; +// The clinical view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { CardProps } from "../view-cards.tsx"; +import type { KindUi } from "./kind-ui.ts"; +import { PickMenu, SeriesSwatch, ViewHead } from "../view-chrome.tsx"; +import { pCell, placement } from "./parts.tsx"; + +function ClinicalCard({ ctx, v, vi }: CardProps) { + const { attrsLoading, busy, catalogAttrs, cohortChoice, docView, menuOpenRef, setViewAttrs, viewIds } = ctx; + return <> + {v.kind === "clinical" && v.clinical && <> + ({ value: a.attribute, label: a.attribute, hint: a.kind }))} + chosen={(docView(vi).attributes || []).map(String)} min={1} max={20} + onChange={(next) => setViewAttrs(vi, next)} />} /> + {v.note &&

{v.note}

} +
+ + + + {v.cohorts!.map((n, i: number) => )} + + + + + {v.clinical.map(r => ( + + + {r.skipped + ? + : <> + {r.cells.map(c => ( + + ))} + + + + } + ))} + +
attribute {n}testpq
+ {r.attribute}
{r.per}-level · {r.kind === "num" ? "numeric" : (r.distinct ?? 0) + " values"} +
not compared — {r.skipped} + {r.kind === "num" + ? {c.mean} ± {c.sd} (n={c.n.toLocaleString()}) + {c.median != null && <>
median {c.median} ({c.q1}–{c.q3})}
+ : {(c.top || []).slice(0, 3).map(t2 => t2.v + " " + Math.round(1000 * t2.n / (c.n || 1)) / 10 + "%").join(" · ")}} +
+ {r.test}{r.rankP != null && "*"} + {r.p != null ? pCell(r.p) : "—"}{r.q != null ? pCell(r.q) : "—"}
+
+ } + ; +} + +export const clinicalUi: KindUi<"clinical"> = { + kind: "clinical", + label: "Clinical comparison", + title: "Clinical comparison", + why: (_k, p) => p.viewAttrs().length ? null : "this study declares no clinical attributes to compare", + loading: (_k, p) => p.attrsLoading(), + defaults: (_k, p) => ({ kind: "clinical", attributes: p.viewAttrs() }), + Card: ClinicalCard, + drawsOwnHead: (v) => !!v.clinical, +}; diff --git a/app/lab/kinds/clonality.tsx b/app/lab/kinds/clonality.tsx new file mode 100644 index 0000000..ba154db --- /dev/null +++ b/app/lab/kinds/clonality.tsx @@ -0,0 +1,35 @@ +"use client"; +// The clonality view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Checkbox } from "../../_ui/checkbox"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the clonality view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + return ( +
+
data + + +
+
+ ); +} + +export const clonalityUi: KindUi<"clonality"> = { + kind: "clonality", + label: "Clonality (FACETS)", + title: "Clonality", + genes: true, + maxCohorts: 3, + defaults: (known) => ({ kind: "clonality", genes: known.genes.length ? known.genes.slice(0, 8) : ["TP53", "KRAS", "APC", "PIK3CA", "SMAD4", "CDH1"] }), + sub: (spec) => "FACETS allele-specific copy number" + (spec.measure === "any" ? ", every mutation" : ", driver mutations") + (spec.qc === false ? ", all samples" : ", QC-passing samples"), + Controls, +}; diff --git a/app/lab/kinds/coexpression.tsx b/app/lab/kinds/coexpression.tsx new file mode 100644 index 0000000..f6e5245 --- /dev/null +++ b/app/lab/kinds/coexpression.tsx @@ -0,0 +1,68 @@ +"use client"; +// The coexpression view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Button } from "../../_ui/button"; +import type { CardProps } from "../view-cards.tsx"; +import { Choice } from "../../_ui/choice.tsx"; +import type { KindUi } from "./kind-ui.ts"; +import { ViewHead } from "../view-chrome.tsx"; +import { isDrawn } from "../../../lib/types.ts"; +import { pCell, placement } from "./parts.tsx"; + +function CoexpressionCard({ ctx, v, vi }: CardProps) { + const { busy, cohortChoice, docView, explore, menuOpenRef, openGenes, updateView } = ctx; + return <> + {v.kind === "coexpression" && isDrawn(v) && <> + + + + } /> + {v.note &&

{v.note}

} +
+ + + + + + {v.coexpression.some(x => x.rMeta != null) && } + + + + {v.coexpression.map(r => ( + s2.study + ": r=" + s2.r + " (n=" + s2.n + ")").join("\n") || undefined}> + + + + + {v.coexpression.some(x => x.rMeta != null) && } + + + ))} + +
pairn + rr (meta)pq
{r.a} / {r.b}{r.n.toLocaleString()} + {r.r != null && } + 0.5 ? " bold" : "")}>{r.r ?? "—"}{r.rMeta ?? "—"}{pCell(r.pMeta ?? r.p)}{pCell(r.q)}
+
+ } + ; +} + +export const coexpressionUi: KindUi<"coexpression"> = { + kind: "coexpression", + label: "Co-expression", + title: "Co-expression", + maxCohorts: 1, + why: (_k, p) => p.contProfile() ? null : "this study has no continuous molecular profile (mRNA z-scores) to correlate", + loading: (_k, p) => !p.contProfile() && (p.trackAttrsLoading || p.exploreBusy), + defaults: (k, p) => ({ kind: "coexpression", genes: k.genes.slice(0, 5), profileType: p.contProfile() }), + Card: CoexpressionCard, + drawsOwnHead: (v) => !!v.coexpression, +}; diff --git a/app/lab/kinds/compare.tsx b/app/lab/kinds/compare.tsx new file mode 100644 index 0000000..92260da --- /dev/null +++ b/app/lab/kinds/compare.tsx @@ -0,0 +1,60 @@ +"use client"; +// The compare view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Button } from "../../_ui/button"; +import type { CardProps } from "../view-cards.tsx"; +import type { KindUi } from "./kind-ui.ts"; +import { SeriesSwatch, ViewHead } from "../view-chrome.tsx"; +import { placement } from "./parts.tsx"; + +function CompareCard({ ctx, v, vi }: CardProps) { + const { busy, cohortChoice, docView, menuOpenRef, openGenes } = ctx; + return <> + {v.kind === "compare" && v.rows && <> + alteration frequencies · {v.annotation}} {...placement(ctx, vi)} + actions={} /> + {v.note &&

2 ? { cursor: "help", textDecorationLine: "underline", textDecorationStyle: "dotted" } : undefined}>{v.note}

} +
+ + + + {v.cohorts!.map((n, i: number) => )} + {v.rows[0] && v.rows[0].pAny != null && } + {v.rows[0] && v.rows[0].qAny != null && } + + {v.rows.map(r => ( + + + {r.cells.map(c => { + const bt = c.byType; + const missing = bt && (["mutation", "cna", "structuralVariant"] as const) + .filter(t => bt[t].profiled === 0 && (bt.mutation.profiled > 0 || bt.cna.profiled > 0 || bt.structuralVariant.profiled > 0)); + const label = { mutation: "mutation", cna: "copy-number", structuralVariant: "structural-variant" }; + const title = bt ? "mutation " + bt.mutation.altered + "/" + bt.mutation.profiled + + " · copy-number " + bt.cna.altered + "/" + bt.cna.profiled + + " · structural-variant " + bt.structuralVariant.altered + "/" + bt.structuralVariant.profiled + + (missing && missing.length ? " — no " + missing.map(t => label[t]).join("/") + " data for this cohort's study" : "") : undefined; + return ( + + ); + })} + {r.pAny != null && } + {r.qAny != null && } + ))} + +
gene {n}
driver / any
p (any)q (any)
{r.gene} + {c.driverPct}% / {c.anyPct}%{missing && missing.length ? * : null} + {r.pAny < 1e-4 ? r.pAny.toExponential(1) : r.pAny.toFixed(3)}{r.qAny < 1e-4 ? r.qAny.toExponential(1) : r.qAny.toFixed(3)}
+
+ } + ; +} + +export const compareUi: KindUi<"compare"> = { + kind: "compare", + label: "Compare genes", + title: "Compare", + defaults: (k) => ({ kind: "compare", genes: k.genes }), + Card: CompareCard, + drawsOwnHead: (v) => !!v.rows, +}; diff --git a/app/lab/kinds/composition.tsx b/app/lab/kinds/composition.tsx new file mode 100644 index 0000000..adc32eb --- /dev/null +++ b/app/lab/kinds/composition.tsx @@ -0,0 +1,69 @@ +"use client"; +// The composition view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Checkbox } from "../../_ui/checkbox"; +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the composition view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const parts: string[] = c.v?.composition ? c.v.composition.parts.map((x: { id: string }) => x.id) : spec.attributes || spec.entities || []; + const names = new Map(c.v?.composition ? c.v.composition.parts.map((x: { id: string; name: string }) => [x.id, x.name]) : []); + const fromProfile = !spec.attributes?.length; + return ( +
+
data + + {!fromProfile && } + {!fromProfile && } +
+
view + {parts.length > 0 && } + +
+
+ ); +} + +export const compositionUi: KindUi<"composition"> = { + kind: "composition", + label: "Composition (cell types, states, signatures as parts of a whole)", + title: "Composition", + maxCohorts: 8, + minCohorts: 1, + why: (k) => !k.assays.length && !k.attrs.some((a) => a.kind === "num") ? "this study has no generic-assay profile and no numeric attribute to draw as parts" : null, + defaults: (known) => { + const profile = known.assays.find((p) => /cell_type.*relative|fraction|proportion/i.test(p)) || known.assays.find((p) => /signature/i.test(p) && /contribution/i.test(p)) || known.assays[0]; + return profile ? { kind: "composition", profileType: profile } : { kind: "composition", attributes: [] }; + }, + sub: (spec, v) => { + const c = v.composition; + const what = c ? c.sourceName : spec.profileType || (spec.attributes?.length ? spec.attributes.length + " attributes" : "no parts chosen"); + return what + (c ? " · " + c.parts.length + " parts per " + c.unit.replace(/s$/, "") : "") + ((spec.normalize ?? true) ? ", as shares" : ", as recorded") + + (c ? " · sorted by " + (c.parts.find((x: { id: string }) => x.id === c.sort)?.name || c.sort) : ""); + }, + Controls, +}; diff --git a/app/lab/kinds/cooccurrence.tsx b/app/lab/kinds/cooccurrence.tsx new file mode 100644 index 0000000..d6fbcc2 --- /dev/null +++ b/app/lab/kinds/cooccurrence.tsx @@ -0,0 +1,63 @@ +"use client"; +// The cooccurrence view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Button } from "../../_ui/button"; +import type { CardProps } from "../view-cards.tsx"; +import type { KindUi } from "./kind-ui.ts"; +import { ViewHead } from "../view-chrome.tsx"; +import { pCell, placement } from "./parts.tsx"; + +function CooccurrenceCard({ ctx, v, vi }: CardProps) { + const { busy, cohortChoice, docView, menuOpenRef, openGenes } = ctx; + return <> + {v.kind === "cooccurrence" && v.pairs && <> + openGenes(vi)}>genes} /> + {v.note &&

{v.note}

} +
+ + + + + + + + + + + + + {v.pairs.map(r => { + const lor = r.oddsRatioMH != null ? Math.log2(r.oddsRatioMH) : r.log2Odds; + const pShown = r.pStratified ?? r.p; + return ( + + + + + + + + + + + ); })} + +
pairbothA onlyB onlyneitherlog2 ORpqtendency
{r.a} / {r.b}{r.both.toLocaleString()}{r.onlyA.toLocaleString()}{r.onlyB.toLocaleString()}{r.neither.toLocaleString()}{lor == null ? "—" : lor.toFixed(2)}{pCell(pShown)}{pCell(r.q)} + {r.tendency && {r.tendency}} +
+
+ } + ; +} + +export const cooccurrenceUi: KindUi<"cooccurrence"> = { + kind: "cooccurrence", + label: "Co-occurrence", + title: "Co-occurrence", + maxCohorts: 1, + defaults: (k) => ({ kind: "cooccurrence", genes: k.genes.slice(0, 6) }), + Card: CooccurrenceCard, + drawsOwnHead: (v) => !!v.pairs, +}; diff --git a/app/lab/kinds/correlates.tsx b/app/lab/kinds/correlates.tsx new file mode 100644 index 0000000..36a8ea8 --- /dev/null +++ b/app/lab/kinds/correlates.tsx @@ -0,0 +1,125 @@ +"use client"; +// The correlates view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { CohortDef, DrawnResultOf, View, ViewResult, ViewResultOf } from "../../../lib/types"; +import { type Col, PortalTable } from "../portal-bodies.tsx"; +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; +import { SERIES } from "../../../lib/svg-kit"; +import { ViewFigure } from "../figure.tsx"; +import { api } from "../api.ts"; +import { fmtP } from "../../../lib/chart/format"; +import { pDigits } from "../../../lib/chart/format.ts"; +import { useEffect, useMemo, useRef, useState } from "react"; + +// ---- Co-expression: the correlated genes, and the query gene against the one picked ---------- + +type CoRow = NonNullable["correlates"]>["positive"][number]; + +/** + * A plot of two values over one cohort, computed when asked: an ordinary plot view sent for the + * pair, drawn here by the shared renderer. A newer pick supersedes one still in flight. + */ +function usePairPlot(docJson: (() => string) | undefined, cohort: string, view: Record | null) { + const [state, setState] = useState<{ key: string; view?: ViewResult; error?: string } | null>(null); + const want = useRef(""); + const key = JSON.stringify(view); + useEffect(() => { + if (!view || !docJson) return; + want.current = key; + setState(null); + let doc: { study?: string; cohorts?: Record }; + try { doc = JSON.parse(docJson()); } catch { setState({ key, error: "no document to plot from" }); return; } + const def = doc.cohorts && doc.cohorts[cohort]; + if (!def) { setState({ key, error: "cohort “" + cohort + "” is not in the document" }); return; } + api.analysis({ study: doc.study, cohorts: { [cohort]: def }, views: [{ ...view, cohorts: [cohort] } as View] }, { query: "?oncoprint=spec" }) + .then((r) => r.json()) + .then((j) => { if (want.current !== key) return; const pv = j.views && j.views[0]; setState({ key, ...(j.error || pv?.error ? { error: j.error || pv.error } : { view: pv }) }); }) + .catch((e) => { if (want.current === key) setState({ key, error: String(e) }); }); + }, [key]); // eslint-disable-line react-hooks/exhaustive-deps + return state && state.key === key ? state : null; +} + +function CorrelatesBody({ v, docJson }: { v: DrawnResultOf<"correlates">; docJson?: () => string }) { + const r = v.correlates; + const [which, setWhich] = useState<"any" | "positive" | "negative">("any"); + const rows = useMemo(() => which === "positive" ? r.positive : which === "negative" ? r.negative : [...r.positive, ...r.negative], [r, which]); + 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: "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 }, + { key: "rho", label: "Spearman's Correlation", num: true, value: (g) => g.rho, + render: (g) => 0 ? SERIES[2] : SERIES[4] }}>{g.rho?.toFixed(3)} }, + { key: "p", label: "p-Value", num: true, value: (g) => g.rhoP, render: (g) => pDigits(g.rhoP) }, + { key: "q", label: "q-Value", num: true, value: (g) => g.q, render: (g) => {pDigits(g.q)} }, + ]; + return ( +
+

Genes on {r.otherProfile} correlated with {r.gene} on {r.profileType}, over {r.cohort} · {r.tested.toLocaleString()} genes tested · {r.significant.toLocaleString()} at q < 0.05

+
+
+ setWhich(v as typeof which)}> + + + g.gene + " " + (g.cytoband || "")} selected={picked} onSelect={setSel} + note={"the " + r.positive.length + " most positive and " + r.negative.length + " most negative of " + r.tested.toLocaleString()} /> +
+
+ {picked &&

{r.gene} vs {picked.gene} · Spearman {picked.rho?.toFixed(2)} ({fmtP(picked.rhoP)}) · Pearson {picked.r?.toFixed(2)} ({fmtP(picked.p)})

} + {!plot && picked &&
drawing {r.gene} vs {picked.gene}…
} + {plot?.error &&

{plot.error}

} + {plot?.view && } +
+
+
+ ); +} + +/** the correlates view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const profs = c.profiles.filter((p) => p !== "cna"); + const pick = (value: string, set: (v: string) => void, title: string, label: string) => ( + + ); + return ( +
+
data + + {pick(spec.profileType || "", (v) => patch({ profileType: v }), "the query gene's profile", "on")} + {pick(spec.otherProfile || spec.profileType || "", (v) => patch({ otherProfile: v === spec.profileType ? undefined : v }), "the profile every other gene is read from", "against")} + +
+
+ ); +} + +export const correlatesUi: KindUi<"correlates"> = { + kind: "correlates", + label: "Co-expression with one gene (genome-wide)", + title: "Co-expression", + maxCohorts: 1, + why: (k) => !k.profiles.filter((p) => p !== "cna").length ? "this study has no continuous molecular profile (mRNA, protein, …) to correlate on" : null, + defaults: (known) => { + const profs = known.profiles.filter((p) => p !== "cna"); + const profile = profs.find((p) => /mrna|rna_seq/i.test(p) && !/zscore/i.test(p)) || profs.find((p) => /mrna|rna_seq/i.test(p)) || profs[0] || ""; + return { kind: "correlates", gene: known.genes[0] || "TP53", profileType: profile }; + }, + sub: (spec) => (spec.gene || "no gene") + " on " + (spec.profileType || "no profile") + (spec.otherProfile && spec.otherProfile !== spec.profileType ? " against " + spec.otherProfile : "") + " · Spearman ρ, every gene", + Controls, + body: (c) => c.v.correlates ? : null, +}; diff --git a/app/lab/kinds/course.tsx b/app/lab/kinds/course.tsx new file mode 100644 index 0000000..71c8881 --- /dev/null +++ b/app/lab/kinds/course.tsx @@ -0,0 +1,129 @@ +"use client"; +// The course view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Button } from "../../_ui/button"; +import { CHIP } from "../../_ui/toolbar.ts"; +import type { CardProps } from "../view-cards.tsx"; +import { Choice } from "../../_ui/choice.tsx"; +import { CohortPicker, DrawnCohorts, Latency, Participation, SplitByNote, ViewHelpButton, ViewMenu } from "../view-chrome.tsx"; +import { GearMenu } from "../../_ui/gear-menu"; +import type { KindUi } from "./kind-ui.ts"; +import { MenuSeparator } from "../../_ui/gear-menu.tsx"; +import { Settings2 } from "lucide-react"; +import { ViewFigure } from "../figure.tsx"; +import { placement } from "./parts.tsx"; + +function CourseCard({ ctx, v, vi }: CardProps) { + const { busy, cohortChoice, doc, docView, menuOpenRef, openCohortChooser, patchView, setViewCohorts, viewIds } = ctx; + return <> + {v.kind === "course" && !v.error && <> +
+

Clinical course + — {[...new Set((v.course || []).map(c => c.eventType))].join(", ")} + {" · "}{v.courseAxis ? v.courseAxis.binMonths + "-month bins" : ""}

+
+ + openCohortChooser(vi)} + onChange={(next) => setViewCohorts(vi, next, cohortChoice(vi, docView(vi)).all)} /> + settings} title="clinical course options"> + {/* Every parameter of this chart is a choice, and the defaults are only a + guess at a good one — so all of them are settable. They live behind + the gear rather than above the figure because they are read when + setting a chart up and rarely after. Each event type shows the two + structural facts that decide whether it is worth plotting: how often + it recurs, and whether it carries a duration. */} + {v.eventOptions && v.eventOptions.length > 0 && (() => { + const cv: any = (doc.views || [])[vi] || {}; + const chosen: string[] = (cv.eventTypes || []).map((x: string) => String(x).trim().toLowerCase()); + const anchor = cv.anchor || null; + const toggle = (canon: string) => { + const next = chosen.includes(canon) ? chosen.filter(x => x !== canon) : [...chosen, canon]; + if (next.length && next.length <= 6) patchView(vi, { eventTypes: next }); + }; + return ( + <> +
measuring
+ {v.eventOptions.map(e => ( + + ))} +
+ +
aligned on + patchView(vi, { anchor: ev + ? { eventType: ev, occurrence: anchor ? anchor.occurrence || "FIRST" : "FIRST" } : null })} + options={[["", "study day 0 (single study only)"], ...v.eventOptions.map(e => [e.canonical, e.label] as const)]} /> + {anchor && ( + patchView(vi, { anchor: { ...anchor, occurrence: o } })} options={[["FIRST", "first"], ["LAST", "last"]]} /> + )} +
+
bins + patchView(vi, { binMonths: Number(b) })} + options={[1, 3, 6, 12].map(b => [String(b), b + " month" + (b === 1 ? "" : "s")] as const)} /> +
+
window + patchView(vi, { fromMonths: Number(f) })} + options={[0, -6, -12, -24, -60].map(f => [String(f), f === 0 ? "the anchor" : f + "mo"] as const)} /> + to + patchView(vi, { toMonths: Number(t) })} + options={[12, 24, 60, 120, 240].map(t => [String(t), "+" + t + "mo"] as const)} /> +
+ + ); + })()} +
+ + +
+
+ + {v.annotationNote &&

{v.annotationNote}

} + + + } + ; +} + +export const courseUi: KindUi<"course"> = { + kind: "course", + label: "Clinical course", + title: "Clinical course", + why: (k) => k.events.length ? null + : "this study records no timeline events (only 131 of 545 studies have any)", + loading: (k, p) => !k.events.length && (p.catLoading || p.exploreBusy), + defaults: (k) => { + const evs = k.events; + // Rank by STRUCTURE, not by how common the type is. Picking the two + // commonest gives Sequencing and Sample acquisition on MSK-CHORD — + // exactly one event per patient and no duration, i.e. filing records + // that draw a spike and nothing else. A type that carries durations is + // a state worth plotting; one that recurs is an event worth counting; + // one that happens once per patient is neither. + const tier = (e: any) => e.hasDurations ? 2 : (e.perPatient || 0) >= 1.5 ? 1 : 0; + const ranked = [...evs].sort((a, b) => tier(b) - tier(a) || b.n - a.n); + // The anchor wants the opposite property: a landmark nearly everyone + // has. A therapy start is the classic one; failing that, the most + // universal event. + const anchorOn = evs.find(e => e.hasDurations) + || [...evs].sort((a, b) => b.n - a.n)[0]; + return { kind: "course", eventTypes: ranked.slice(0, 2).map(e => e.canonical), + ...(anchorOn ? { anchor: { eventType: anchorOn.label, occurrence: "FIRST" } } : {}), + binMonths: 3, fromMonths: -12, toMonths: 60 }; + }, + Card: CourseCard, + drawsOwnHead: (v) => !!v.svg, +}; diff --git a/app/lab/kinds/cox.tsx b/app/lab/kinds/cox.tsx new file mode 100644 index 0000000..48c53c9 --- /dev/null +++ b/app/lab/kinds/cox.tsx @@ -0,0 +1,85 @@ +"use client"; +// The cox view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { CardProps } from "../view-cards.tsx"; +import { Checkbox } from "../../_ui/checkbox"; +import { Choice } from "../../_ui/choice.tsx"; +import { CovariateList, placement } from "./parts.tsx"; +import { Input } from "../../_ui/input"; +import type { KindUi } from "./kind-ui.ts"; +import { ViewFigure } from "../figure.tsx"; +import { ViewHead } from "../view-chrome.tsx"; +import { Waiting } from "../../_oncoprint/OncoprintCard.tsx"; + +function CoxCard({ ctx, v, vi }: CardProps) { + const { attrsLoading, busy, cat, catLoading, catalogAttrs, cohortChoice, doc, docView, menuOpenRef, patchView, profileCatalog, trackAttrs } = ctx; + return <> + {v.kind === "cox" && <> + Multivariate Cox, {v.endpoint && v.endpoint !== "OS" ? v.endpoint : "overall survival"}} /> + {(() => { + const sv: any = (doc.views || [])[vi] || {}; + const covariates: any[] = Array.isArray(sv.covariates) ? sv.covariates : []; + const eps: string[] = (cat && cat.survivalEndpoints) || []; + const setCovariate = (ci: number, next: any) => { + const nc = covariates.map((c, j) => j === ci ? next : c); + patchView(vi, { covariates: nc }); + }; + const removeCovariate = (ci: number) => patchView(vi, { covariates: covariates.filter((_, j) => j !== ci) }); + const addCovariate = () => patchView(vi, { covariates: [...covariates, { source: "clinical", attribute: (trackAttrs.attrs[0] || {}).attribute || "" }] }); + // The reference level for a categorical covariate is picked from the levels + // this run actually observed (v.cox.coefficients), not re-derived client-side — + // a covariate the fit dropped (only one level, or no result yet) simply gets no + // picker rather than a guess at what the data might contain. + const observedLevels = (ci: number): string[] => { + // when stratified by cohort there's no single pooled coefficients array — + // union the levels each cohort's own fit happened to observe instead + const coefs = (v.cox && v.cox.coefficients.length) ? v.cox.coefficients + : (v.cox?.byCohort || []).flatMap((c: any) => c.coefficients); + const mine = coefs.filter((c: any) => c.axisIndex === ci); + if (!mine.length) return []; + const levels = new Set(); + for (const c of mine) { if (c.reference) levels.add(c.reference); if (c.level) levels.add(c.level); } + return [...levels]; + }; + return ( +
+ endpoint + patchView(vi, { endpoint: v })} + options={(eps.length ? eps : [sv.endpoint || "OS"]).map(x => [x, x] as const)} pending={!cat ? "loading…" : undefined} /> + {catLoading && !cat && } + cutoff + patchView(vi, { cutoffMonths: e.target.value ? Number(e.target.value) : undefined })} /> + mo + + + +
+ ); + })()} + + } + ; +} + +export const coxUi: KindUi<"cox"> = { + kind: "cox", + label: "Cox regression (forest plot)", + title: "Cox regression", + why: (_k, p) => p.noSurvivalWhy(), + loading: (_k, p) => p.catLoading, + defaults: (k) => ({ kind: "cox", covariates: [{ source: "alteration", gene: k.genes[0] || "TP53", by: "mutated" }] }), + Card: CoxCard, + drawsOwnHead: () => true, +}; diff --git a/app/lab/kinds/ctdna.tsx b/app/lab/kinds/ctdna.tsx new file mode 100644 index 0000000..0dfa82e --- /dev/null +++ b/app/lab/kinds/ctdna.tsx @@ -0,0 +1,55 @@ +"use client"; +// The ctdna view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the ctdna view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const choosing = !!(spec.genes && spec.genes.length); + const csv = (x: any, d: string[]) => (Array.isArray(x) && x.length ? x : d).join(", "); + const parse = (t: string) => t.split(",").map((y) => y.trim()).filter(Boolean); + return ( +
+
data + + + + + +
+
+ ); +} + +export const ctdnaUi: KindUi<"ctdna"> = { + kind: "ctdna", + label: "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", + title: "ctDNA vs tissue", + genes: true, + maxCohorts: 3, + defaults: (known) => known.genes.length ? { kind: "ctdna", genes: known.genes.slice(0, 10) } : { kind: "ctdna" }, + sub: (spec) => "plasma " + (spec.attribute || "SAMPLE_CLASS") + " = " + (spec.plasma || ["cfDNA", "ctDNA"]).join("/") + " vs tissue " + (spec.tissue || ["Tumor"]).join("/") + + (spec.measure === "driver" ? ", oncogenic only" : ", any alteration") + (spec.withinDays != null ? ", drawn within " + spec.withinDays + " days" : "") + + (spec.chFilter ? ", clonal haematopoiesis filtered" : "") + + (spec.genes && spec.genes.length ? "" : ", the most altered genes"), + Controls, +}; diff --git a/app/lab/kinds/cutpoint.tsx b/app/lab/kinds/cutpoint.tsx new file mode 100644 index 0000000..2bbd447 --- /dev/null +++ b/app/lab/kinds/cutpoint.tsx @@ -0,0 +1,51 @@ +"use client"; +// The cutpoint view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the cutpoint view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const nums = c.attrs.filter((a) => a.kind === "num").map((a) => a.attribute); + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const attr = spec.attribute || "TMB_NONSYNONYMOUS"; + return ( +
+
data + + + +
+
+ ); +} + +export const cutpointUi: KindUi<"cutpoint"> = { + kind: "cutpoint", + label: "Biomarker cut-point (best split of a number for OS)", + title: "Cut-point", + maxCohorts: 1, + why: (k) => k.attrs.some((a) => a.kind === "num") ? null : "this study has no numeric clinical attribute to find a cut-point of", + defaults: (known) => { + const num = known.attrs.find((a) => a.attribute === "TMB_NONSYNONYMOUS") || known.attrs.find((a) => a.kind === "num"); + return { kind: "cutpoint", attribute: num ? num.attribute : "TMB_NONSYNONYMOUS" }; + }, + sub: (spec) => (spec.attribute || "TMB_NONSYNONYMOUS") + " on " + (spec.endpoint || "OS") + " · best log-rank cut, " + (spec.permutations ?? 1000) + " permutations" + + (spec.stratifyBy ? " · per " + spec.stratifyBy : ""), + Controls, +}; diff --git a/app/lab/kinds/diffexpr.tsx b/app/lab/kinds/diffexpr.tsx new file mode 100644 index 0000000..ecda8d0 --- /dev/null +++ b/app/lab/kinds/diffexpr.tsx @@ -0,0 +1,46 @@ +"use client"; +// The diffexpr view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Checkbox } from "../../_ui/checkbox"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the diffexpr view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + return ( +
+
data + + + +
+
+ ); +} + +export const diffexprUi: KindUi<"diffexpr"> = { + kind: "diffexpr", + label: "Differential expression + GSEA", + title: "Differential expression", + maxCohorts: 2, + minCohorts: 2, + why: (k) => !k.profiles.length ? "this study has no molecular profile to compare expression on" : null, + defaults: (known) => { + const profile = known.profiles.find((p) => /zscore/i.test(p) && /mrna|rna_seq/i.test(p)) + || known.profiles.find((p) => /mrna|rna_seq/i.test(p)) || known.profiles[0] || ""; + return { kind: "diffexpr", profileType: profile }; + }, + sub: (spec, v) => (spec.profileType || "no profile") + (v.diffexpr ? (v.diffexpr.log2 ? ", log2(v+1)" : "") + " · volcano and GSEA" : ""), + Controls, +}; diff --git a/app/lab/kinds/drivernegative.tsx b/app/lab/kinds/drivernegative.tsx new file mode 100644 index 0000000..6a8b82b --- /dev/null +++ b/app/lab/kinds/drivernegative.tsx @@ -0,0 +1,41 @@ +"use client"; +// The drivernegative view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the drivernegative view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const drv: string[] = Array.isArray(spec.drivers) ? spec.drivers : []; + return ( +
+
data + + + +
+
+ ); +} + +export const drivernegativeUi: KindUi<"drivernegative"> = { + kind: "drivernegative", + label: "Driver-negative analysis (genes enriched without a canonical driver)", + title: "Driver-negative analysis", + maxCohorts: 1, + sub: (spec) => (spec.alteration === "sv" ? "structural variants" : spec.alteration === "cna" ? "copy-number alterations" : "mutations") + + " without vs with a driver in " + (spec.drivers?.length ? spec.drivers.length + " genes" : "the NSCLC set") + ", per patient", + Controls, +}; diff --git a/app/lab/kinds/enrichment.tsx b/app/lab/kinds/enrichment.tsx new file mode 100644 index 0000000..9da9e1c --- /dev/null +++ b/app/lab/kinds/enrichment.tsx @@ -0,0 +1,79 @@ +"use client"; +// The enrichment view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Badge, type Col, PortalTable } from "../portal-bodies.tsx"; +import { DrawAtWidth } from "../figure.tsx"; +import type { DrawnResultOf, ViewResultOf } from "../../../lib/types"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; +import { enrichmentChartsSvg } from "../../../lib/views/enrichment/figure.ts"; +import { fmtPct } from "../../../lib/chart/format"; +import { pDigits } from "../../../lib/chart/format.ts"; +import { seriesStyle } from "../../../lib/chart/style"; + +// ---- Genomic Alterations: the three charts, then the table ------------------------------------- + +type EnRow = NonNullable["enrichment"]>["rows"][number]; + +function EnrichmentBody({ v }: { v: DrawnResultOf<"enrichment"> }) { + const r = v.enrichment; + const two = r.groups.length === 2; + const color = (g: string) => seriesStyle(Math.max(0, r.groups.indexOf(g))).color; + const cols: Col[] = [ + { key: "gene", label: "Gene", value: (g) => g.gene, render: (g) => {g.gene} }, + { key: "cytoband", label: "Cytoband", value: (g) => g.cytoband || null }, + ...r.groups.map((name, i): Col => ({ key: "g" + i, label: name, num: true, title: "altered / profiled in " + name, + value: (g) => g.cells[i].pct, render: (g) => <>{g.cells[i].altered.toLocaleString()} ({g.cells[i].pct == null ? "—" : fmtPct(g.cells[i].pct!)}) })), + ...(two ? [{ key: "lr", label: "Log2 Ratio", num: true, value: (g: EnRow) => g.log2Ratio ?? (g.cells[0].altered ? 99 : -99), + render: (g: EnRow) => g.log2Ratio == null ? (g.cells[0].altered ? ">10" : "<-10") : g.log2Ratio.toFixed(2) }] : []), + { key: "p", label: "p-Value", num: true, value: (g) => g.p, render: (g) => pDigits(g.p) }, + { key: "q", label: "q-Value", num: true, value: (g) => g.q, render: (g) => {pDigits(g.q)} }, + { key: "in", label: two ? "Enriched in" : "Most enriched in", value: (g) => g.enrichedIn, render: (g) => }, + ]; + return ( +
+ enrichmentChartsSvg(r, { width })} deps={[r]} /> + g.gene + " " + (g.cytoband || "")} + filters={[{ label: "Significant only", test: (g) => g.q < 0.05 }]} + note={r.tested > r.rows.length ? "the " + r.rows.length + " most significant of " + r.tested.toLocaleString() + " genes tested" : undefined} /> +
+ ); +} + +/** the enrichment view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + return ( +
+
data + + + +
+
+ ); +} + +export const enrichmentUi: KindUi<"enrichment"> = { + kind: "enrichment", + label: "Alteration enrichment (all genes)", + title: "Alteration enrichment", + maxCohorts: 6, + minCohorts: 2, + sub: (spec) => ({ mutation: "mutations", cna: "copy-number alterations", amp: "amplifications", homdel: "deep deletions", sv: "structural variants" } as Record)[spec.alteration || "mutation"] + + (spec.measure === "driver" ? " (drivers)" : "") + " in every gene, per sample", + Controls, + body: (c) => c.v.enrichment ? : null, +}; diff --git a/app/lab/kinds/exclusivity.tsx b/app/lab/kinds/exclusivity.tsx new file mode 100644 index 0000000..cfb1232 --- /dev/null +++ b/app/lab/kinds/exclusivity.tsx @@ -0,0 +1,44 @@ +"use client"; +// The exclusivity view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the exclusivity view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + return ( +
+
data + + +
+
+ ); +} + +export const exclusivityUi: KindUi<"exclusivity"> = { + kind: "exclusivity", + label: "Mutual exclusivity (DISCOVER, burden-aware)", + title: "Mutual exclusivity", + genes: true, + maxCohorts: 1, + cohortInHeader: true, + defaults: (known) => ({ kind: "exclusivity", genes: known.genes.length >= 2 ? known.genes.slice(0, 8) : ["KRAS", "EGFR", "BRAF", "STK11", "KEAP1", "TP53"] }), + sub: (spec, v) => { + const cohort = Array.isArray(v.cohorts) && v.cohorts[0] ? String(v.cohorts[0]).split(" / ").join(" › ") : null; + return <>{cohort && <>{cohort} · }{(spec.measure === "driver" ? "oncogenic alterations" : "any alteration") + ", background from each tumour's burden" + + (spec.stratifyBy ? ", within " + spec.stratifyBy : "")}; + }, + Controls, +}; diff --git a/app/lab/kinds/forest.tsx b/app/lab/kinds/forest.tsx new file mode 100644 index 0000000..c989857 --- /dev/null +++ b/app/lab/kinds/forest.tsx @@ -0,0 +1,76 @@ +"use client"; +// The forest view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the forest view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const e = spec.exposure || { source: "alteration", gene: "" }; + const isGene = e.source === "alteration"; + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const strata = spec.strata || "CANCER_TYPE"; + return ( +
+
exposure + + {isGene + ? patch({ exposure: { source: "alteration", gene: t.trim().toUpperCase() } })} /> + : <> + patch({ exposure: { source: "clinical", attribute: t.trim() } })} /> + + + } +
+
rows + + + +
+
+ ); +} + +export const forestUi: KindUi<"forest"> = { + kind: "forest", + label: "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", + title: "Forest plot", + maxCohorts: 6, + defaults: (known) => ({ kind: "forest", exposure: { source: "alteration", gene: known.genes[0] || "TP53" } }), + sub: (spec) => { + const e = spec.exposure || {}; + const what = e.source === "alteration" ? (e.oql || (e.genes || [e.gene]).join(" or ") + " mutated") : e.attribute ? e.attribute + (spec.threshold != null ? " ≥ " + spec.threshold : spec.exposed ? " = " + spec.exposed : "") : "no exposure chosen"; + return what + " · " + (spec.endpoint || "OS") + " hazard ratio per " + (spec.strata || "CANCER_TYPE") + ", random-effects pooled"; + }, + Controls, +}; diff --git a/app/lab/kinds/genome.tsx b/app/lab/kinds/genome.tsx new file mode 100644 index 0000000..3c26899 --- /dev/null +++ b/app/lab/kinds/genome.tsx @@ -0,0 +1,38 @@ +"use client"; +// The genome view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Checkbox } from "../../_ui/checkbox"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the genome view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + return ( +
+
data + + + +
+
+ ); +} + +export const genomeUi: KindUi<"genome"> = { + kind: "genome", + label: "Genome-wide copy number", + title: "Genome-wide copy number", + maxCohorts: 4, + sub: (_spec, v) => v.genome ? v.genome.build + ", " + v.genome.binMb + " Mb bins, from segments" : "from copy-number segments", + Controls, +}; diff --git a/app/lab/kinds/germline.tsx b/app/lab/kinds/germline.tsx new file mode 100644 index 0000000..1ade28e --- /dev/null +++ b/app/lab/kinds/germline.tsx @@ -0,0 +1,40 @@ +"use client"; +// The germline view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the germline view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const genes: string[] = Array.isArray(spec.genes) ? spec.genes : []; + return ( +
+
data + + + +
+
+ ); +} + +export const germlineUi: KindUi<"germline"> = { + kind: "germline", + label: "Germline carriers (and their somatic second hits)", + title: "Germline carriers", + maxCohorts: 4, + sub: (spec) => "germline carriers per patient sequenced, " + (spec.genes?.length ? spec.genes.length + " genes" : "every gene with ≥" + (spec.minCarriers ?? 3) + " carriers") + + " · somatic second hit vs non-carriers" + (spec.survival?.gene ? " · OS by " + spec.survival.gene + " carrier status" : ""), + Controls, +}; diff --git a/app/lab/kinds/hla.tsx b/app/lab/kinds/hla.tsx new file mode 100644 index 0000000..4730d0a --- /dev/null +++ b/app/lab/kinds/hla.tsx @@ -0,0 +1,44 @@ +"use client"; +// The hla view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the hla view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const nums = c.attrs.filter((a) => a.kind === "num").map((a) => a.attribute); + const cov = (spec.covariates || [])[0] || ""; + return ( +
+
data + + +
+
+ ); +} + +export const hlaUi: KindUi<"hla"> = { + kind: "hla", + label: "HLA class I genotype (homozygosity, HLA LOH and OS)", + title: "HLA class I genotype", + maxCohorts: 3, + why: (k) => k.attrs.some((a) => /^hla[-_]?[abc]/i.test(a.attribute)) ? null : "this study records no HLA class I alleles", + defaults: (known) => { + const tmb = known.attrs.find((a) => a.attribute === "TMB_NONSYNONYMOUS" || a.attribute === "TMB_SCORE"); + return tmb ? { kind: "hla", covariates: [tmb.attribute] } : { kind: "hla" }; + }, + sub: (spec) => "alleles at " + (spec.resolution === 1 ? "1 field" : spec.resolution === "full" ? "full resolution" : "2 fields") + " · " + (spec.endpoint || "OS") + + " by homozygosity and by HLA LOH" + (spec.covariates && spec.covariates.length ? " · adjusted for " + spec.covariates.join(", ") : ""), + Controls, +}; diff --git a/app/lab/kinds/hotspots.tsx b/app/lab/kinds/hotspots.tsx new file mode 100644 index 0000000..c257f77 --- /dev/null +++ b/app/lab/kinds/hotspots.tsx @@ -0,0 +1,33 @@ +"use client"; +// The hotspots view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Button } from "../../_ui/button"; +import type { CardProps } from "../view-cards.tsx"; +import { HotspotsBody } from "../../_lollipop/HotspotsBody.tsx"; +import type { KindUi } from "./kind-ui.ts"; +import { ViewHead } from "../view-chrome.tsx"; +import { placement } from "./parts.tsx"; + +function HotspotsCard({ ctx, v, vi }: CardProps) { + const { busy, cohortChoice, docView, menuOpenRef, openGenes, updateViewLocal } = ctx; + return <> + {v.kind === "hotspots" && v.hotspots && <> + openGenes(vi)}>genes} /> + {/* the plot from the same renderer MCP uses (lib/view-svg.ts), with the gene summary and the + alteration grid beside it as controls; the grid's selection re-runs nothing */} + updateViewLocal(vi, (vv: any) => { + const nx = { ...vv }; if (h) nx.hide = h; else delete nx.hide; return nx; + })} /> + } + ; +} + +export const hotspotsUi: KindUi<"hotspots"> = { + kind: "hotspots", + label: "Lollipop (hotspots)", + title: "Mutations", + maxCohorts: 1, + defaults: (k) => ({ kind: "hotspots", genes: k.genes.slice(0, 4), minCount: 1 }), + Card: HotspotsCard, + drawsOwnHead: (v) => !!v.hotspots, +}; diff --git a/app/lab/kinds/incidence.tsx b/app/lab/kinds/incidence.tsx new file mode 100644 index 0000000..dd2b9cb --- /dev/null +++ b/app/lab/kinds/incidence.tsx @@ -0,0 +1,63 @@ +"use client"; +// The incidence view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the incidence view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const e = spec.event || { eventType: "" }, a0 = (e.attrs || [])[0] || { key: "", value: "" }; + const an = spec.anchor || null; + const setEvent = (next: any) => patch({ event: next }); + const types = c.events.map((x) => x.label); + return ( +
+
event + + + +
+
clock + + {an && an.key && where {an.key} = {String(an.value ?? "")}} +
+
+ ); +} + +export const incidenceUi: KindUi<"incidence"> = { + kind: "incidence", + label: "Cumulative incidence (competing risks)", + title: "Cumulative incidence", + maxCohorts: 6, + why: (k) => k.events.length ? null : "this study records no timeline events to estimate the incidence of", + defaults: (known) => { + const ev = known.events.find((e) => /diagnos|progress|status/i.test(e.canonical)) || known.events[0]; + return { kind: "incidence", event: { eventType: ev ? ev.label : "" } }; + }, + sub: (spec) => { + const e = spec.event || {}; + const an = spec.anchor; + return (e.label || e.eventType || "no event chosen") + + (an && an.eventType ? " from first " + an.eventType + (an.key ? " [" + an.key + "=" + an.value + "]" : "") : " from day 0") + ", death competing"; + }, + Controls, +}; diff --git a/app/lab/kinds/index.ts b/app/lab/kinds/index.ts new file mode 100644 index 0000000..08f01d0 --- /dev/null +++ b/app/lab/kinds/index.ts @@ -0,0 +1,111 @@ +// Every view kind's page-side description (app/lab/kinds/.tsx), in the add menu's order: what the +// analysis page and the portal tabs read about a kind. A new kind is one file there and one entry here, +// beside its descriptor in lib/views/kinds.ts and its runner in lib/views/index.ts. +import type { ViewKind } from "../../../lib/types.ts"; +import type { KindUi } from "./kind-ui.ts"; +import { acquiredUi } from "./acquired.tsx"; +import { actionabilityUi } from "./actionability.tsx"; +import { adjustedUi } from "./adjusted.tsx"; +import { alterationsUi } from "./alterations.tsx"; +import { assaysUi } from "./assays.tsx"; +import { attritionUi } from "./attrition.tsx"; +import { cancertypesUi } from "./cancertypes.tsx"; +import { clinicalUi } from "./clinical.tsx"; +import { clonalityUi } from "./clonality.tsx"; +import { coexpressionUi } from "./coexpression.tsx"; +import { compareUi } from "./compare.tsx"; +import { compositionUi } from "./composition.tsx"; +import { cooccurrenceUi } from "./cooccurrence.tsx"; +import { correlatesUi } from "./correlates.tsx"; +import { courseUi } from "./course.tsx"; +import { coxUi } from "./cox.tsx"; +import { ctdnaUi } from "./ctdna.tsx"; +import { cutpointUi } from "./cutpoint.tsx"; +import { diffexprUi } from "./diffexpr.tsx"; +import { drivernegativeUi } from "./drivernegative.tsx"; +import { enrichmentUi } from "./enrichment.tsx"; +import { exclusivityUi } from "./exclusivity.tsx"; +import { forestUi } from "./forest.tsx"; +import { genomeUi } from "./genome.tsx"; +import { germlineUi } from "./germline.tsx"; +import { hlaUi } from "./hla.tsx"; +import { hotspotsUi } from "./hotspots.tsx"; +import { incidenceUi } from "./incidence.tsx"; +import { instabilityUi } from "./instability.tsx"; +import { labsUi } from "./labs.tsx"; +import { logisticUi } from "./logistic.tsx"; +import { molsubtypeUi } from "./molsubtype.tsx"; +import { multistateUi } from "./multistate.tsx"; +import { mutationsUi } from "./mutations.tsx"; +import { oncoprintUi } from "./oncoprint.tsx"; +import { organotropismUi } from "./organotropism.tsx"; +import { overlapUi } from "./overlap.tsx"; +import { pairedUi } from "./paired.tsx"; +import { pathwaysUi } from "./pathways.tsx"; +import { plotUi } from "./plot.tsx"; +import { predictiveUi } from "./predictive.tsx"; +import { riskmodelUi } from "./riskmodel.tsx"; +import { rmstUi } from "./rmst.tsx"; +import { sequenceUi } from "./sequence.tsx"; +import { survivalUi } from "./survival.tsx"; +import { tableUi } from "./table.tsx"; +import { timelineUi } from "./timeline.tsx"; +import { txendpointsUi } from "./txendpoints.tsx"; + +export const KIND_UI: { [K in ViewKind]: KindUi } = { + compare: compareUi, + survival: survivalUi, + cox: coxUi, + logistic: logisticUi, + oncoprint: oncoprintUi, + hotspots: hotspotsUi, + plot: plotUi, + cooccurrence: cooccurrenceUi, + clinical: clinicalUi, + coexpression: coexpressionUi, + course: courseUi, + sequence: sequenceUi, + attrition: attritionUi, + genome: genomeUi, + pathways: pathwaysUi, + enrichment: enrichmentUi, + assays: assaysUi, + composition: compositionUi, + table: tableUi, + mutations: mutationsUi, + timeline: timelineUi, + alterations: alterationsUi, + correlates: correlatesUi, + overlap: overlapUi, + cancertypes: cancertypesUi, + labs: labsUi, + incidence: incidenceUi, + hla: hlaUi, + cutpoint: cutpointUi, + predictive: predictiveUi, + rmst: rmstUi, + multistate: multistateUi, + txendpoints: txendpointsUi, + clonality: clonalityUi, + paired: pairedUi, + acquired: acquiredUi, + instability: instabilityUi, + exclusivity: exclusivityUi, + ctdna: ctdnaUi, + riskmodel: riskmodelUi, + drivernegative: drivernegativeUi, + organotropism: organotropismUi, + germline: germlineUi, + molsubtype: molsubtypeUi, + actionability: actionabilityUi, + adjusted: adjustedUi, + forest: forestUi, + diffexpr: diffexprUi, +}; + +/** a kind's page-side description, whatever kind it is */ +export type AnyKindUi = KindUi; + +/** the description of a kind known only at run time, or undefined for one the page does not know */ +export const kindUi = (kind: unknown): AnyKindUi | undefined => + Object.prototype.hasOwnProperty.call(KIND_UI, String(kind)) ? (KIND_UI as Record)[String(kind)] : undefined; diff --git a/app/lab/kinds/instability.tsx b/app/lab/kinds/instability.tsx new file mode 100644 index 0000000..e6f3d91 --- /dev/null +++ b/app/lab/kinds/instability.tsx @@ -0,0 +1,38 @@ +"use client"; +// The instability view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Checkbox } from "../../_ui/checkbox"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the instability view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + return ( +
+
data + + + +
+
+ ); +} + +export const instabilityUi: KindUi<"instability"> = { + kind: "instability", + label: "Genomic instability (aneuploidy score, FGA, arm-level calls)", + title: "Genomic instability", + maxCohorts: 6, + sub: (spec) => "from hg19 segments, ±" + (spec.threshold ?? 0.2) + ", arm called past " + Math.round(100 * (spec.armShare ?? 0.5)) + "% of its length" + (spec.includeX ? ", with chrX" : ""), + Controls, +}; diff --git a/app/lab/kinds/kind-ui.ts b/app/lab/kinds/kind-ui.ts new file mode 100644 index 0000000..14a1c12 --- /dev/null +++ b/app/lab/kinds/kind-ui.ts @@ -0,0 +1,96 @@ +// What the analysis page knows about one view kind (app/lab/kinds/.tsx, listed in +// app/lab/kinds/index.ts): its add-menu entry, the view a new one starts from, how many cohorts it +// draws, and its card — either the shared card (app/lab/kind-cards.tsx) with this kind's subtitle, +// controls and body, or a card of its own. +import type { ReactNode } from "react"; +import type { ViewKind } from "../../../lib/types.ts"; +import type { CardProps } from "../view-cards.tsx"; + +/** What the page knows about the study before a view is added. */ +export interface Known { + genes: string[]; attrs: { attribute: string; kind: string }[]; profiles: string[]; + /** generic-assay profiles (mutational signatures, arm-level CNA, …) */ + assays: string[]; + events: { canonical: string; label: string; n: number; perPatient?: number; hasDurations?: boolean }[]; cohorts: string[]; +} + +/** What a kind's add button may also ask of the page: its catalogs' state and what they offer. */ +export interface MenuPage { + catLoading: boolean; + exploreBusy: boolean; + trackAttrsLoading: boolean; + /** why survival and Cox have no endpoint to fit in this study, or null (use-view-options.ts) */ + noSurvivalWhy: () => string | null; + viewAttrs: () => string[]; + contProfile: () => string | null; + attrsLoading: () => boolean; +} + +/** What a new view's defaults may also ask of the page. */ +export interface AddPage { + viewAttrs: () => string[]; + contProfile: () => string | null; + numericAttrs: () => string[]; +} + +/** What the shared card hands a kind's controls and body: the view as written, its result, and a way to edit it. */ +export interface KindCardCtx { + vi: number; + /** the view as the DOCUMENT holds it (never the result, which has different fields) */ + spec: any; + /** the view's result */ + v: any; + busy: boolean; + /** a request for this view is running (its own re-run, or the whole document's) — a view that draws its own + * waiting state (drawsOwnWaiting) shows it; for the rest the page covers the card */ + updating?: boolean; + patch: (p: Record) => void; + attrs: { attribute: string; kind: string }[]; + profiles: string[]; + assays: string[]; + events: { canonical: string; label: string; n: number }[]; + /** the document as it stands, for a table's download */ + docJson?: () => string; + /** settings the surrounding page controls for every view at once (a portal tab's gene chips), so not offered here */ + locked?: string[]; +} + +export interface KindUi { + kind: K; + /** its entry in the add-view menu */ + label: string; + /** its card's title */ + title: string; + /** a gene list edited through the page's shared gene dialog */ + genes?: boolean; + /** the most cohorts this view can draw at once */ + maxCohorts?: number; + /** the fewest it compares; an empty view says so beside its "+ Add cohort" */ + minCohorts?: number; + /** the header's subtitle names the one cohort drawn, so the header leaves out its "Showing" line */ + cohortInHeader?: boolean; + /** why the add button is disabled for this study, or null when it can be added */ + why?: (known: Known, page: MenuPage) => string | null; + /** the data `why` decides on is still being fetched, so the button waits with a spinner rather than + * guessing (without one: no molecular profiles yet while a catalog loads) */ + loading?: (known: Known, page: MenuPage) => boolean; + /** the view a fresh "add view" click starts from, built from what the study is known to have ({ kind } without one) */ + defaults?: (known: Known, page: AddPage) => Record; + + /** a card of its own, in place of the shared one */ + Card?: (p: CardProps) => ReactNode; + /** whether its own Card draws the header (and with it the remove/move controls) for this result; the shared card always does */ + drawsOwnHead?: (v: any) => boolean; + + /** the shared card's one-line subtitle */ + sub?: (spec: any, v: any) => ReactNode; + /** the shared card's settings row */ + Controls?: (p: { c: KindCardCtx }) => ReactNode; + /** a body of the kind's own in place of the figure, or null to draw the figure */ + body?: (c: KindCardCtx) => ReactNode; + /** the result as the figure draws it: settings applied in the browser (dataPart) read from the document, not the last run */ + figureView?: (spec: any, v: any) => any; + /** it shows its own waiting state (the table views: their search, filters and headers stay usable while the rows are + * covered), so the page must not cover the card with its own */ + drawsOwnWaiting?: boolean; +} diff --git a/app/lab/kinds/labs.tsx b/app/lab/kinds/labs.tsx new file mode 100644 index 0000000..58e13f1 --- /dev/null +++ b/app/lab/kinds/labs.tsx @@ -0,0 +1,65 @@ +"use client"; +// The labs view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the labs view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const an = spec.anchor || { eventType: "" }; + const types = c.events.map((x) => x.label); + return ( +
+
test + + patch({ test: t.trim() })} /> + + +
+
clock + + + + +
+
+ ); +} + +export const labsUi: KindUi<"labs"> = { + kind: "labs", + label: "Lab course and response (e.g. PSA50)", + title: "Lab course", + maxCohorts: 4, + why: (k) => k.events.some((e) => /lab/i.test(e.canonical)) ? null : "this study records no lab-test timeline events", + defaults: (known) => { + const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); + return { kind: "labs", test: "PSA", anchor: { eventType: tx ? tx.label : "Treatment" } }; + }, + sub: (spec) => { + const an = spec.anchor || {}; + return (spec.test || "no test chosen") + " from first " + (an.eventType || "?") + (an.key ? " [" + an.key + "=" + an.value + "]" : "") + + " · responders fell ≥" + (spec.responseDrop ?? 50) + "%"; + }, + Controls, +}; diff --git a/app/lab/kinds/logistic.tsx b/app/lab/kinds/logistic.tsx new file mode 100644 index 0000000..ba3adbd --- /dev/null +++ b/app/lab/kinds/logistic.tsx @@ -0,0 +1,77 @@ +"use client"; +// The logistic view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { AxisPicker, ViewHead } from "../view-chrome.tsx"; +import type { CardProps } from "../view-cards.tsx"; +import { CovariateList, placement } from "./parts.tsx"; +import { Input } from "../../_ui/input"; +import type { KindUi } from "./kind-ui.ts"; +import { ViewFigure } from "../figure.tsx"; + +function LogisticCard({ ctx, v, vi }: CardProps) { + const { attrsLoading, busy, catalogAttrs, cohortChoice, doc, docView, menuOpenRef, patchView, profileCatalog, trackAttrs } = ctx; + return <> + {v.kind === "logistic" && <> + Multivariate logistic, odds ratios} /> + {(() => { + const sv: any = (doc.views || [])[vi] || {}; + const covariates: any[] = Array.isArray(sv.covariates) ? sv.covariates : []; + const setCovariate = (ci: number, next: any) => { + const nc = covariates.map((c, j) => j === ci ? next : c); + patchView(vi, { covariates: nc }); + }; + const removeCovariate = (ci: number) => patchView(vi, { covariates: covariates.filter((_, j) => j !== ci) }); + const addCovariate = () => patchView(vi, { covariates: [...covariates, { source: "clinical", attribute: (trackAttrs.attrs[0] || {}).attribute || "" }] }); + // Same idea as the Cox picker's observedLevels, but there's only ever one + // pooled fit here (logistic has no stratifyByCohort yet). + const observedLevels = (ci: number): string[] => { + const coefs = (v.logistic && v.logistic.coefficients) || []; + const mine = coefs.filter((c: any) => c.axisIndex === ci); + if (!mine.length) return []; + const levels = new Set(); + for (const c of mine) { if (c.reference) levels.add(c.reference); if (c.level) levels.add(c.level); } + return [...levels]; + }; + // The outcome's own observed levels, for the outcomeEvent picker: the + // currently-chosen event (if the fit succeeded) plus whatever got collapsed + // into "not event" (outcomeOther) — together, everything this run has seen. + const outcomeLevels = [...new Set([ + ...(sv.outcomeEvent ? [String(sv.outcomeEvent)] : []), + ...((v.logistic && v.logistic.outcomeOther) || []) + ])]; + const outcomeIsAlteration = sv.outcome?.source === "alteration"; + return ( +
+ outcome + patchView(vi, { outcome: next })} /> + {!outcomeIsAlteration && } + +
+ ); + })()} + + } + ; +} + +export const logisticUi: KindUi<"logistic"> = { + kind: "logistic", + label: "Logistic regression (odds ratios)", + title: "Logistic regression", + defaults: (k, p) => ({ kind: "logistic", + outcome: { source: "alteration", gene: k.genes[0] || "TP53", by: "mutated" }, outcomeEvent: "Mutated", + covariates: [{ source: "clinical", attribute: p.viewAttrs()[0] || "SEX" }] }), + Card: LogisticCard, + drawsOwnHead: () => true, +}; diff --git a/app/lab/kinds/molsubtype.tsx b/app/lab/kinds/molsubtype.tsx new file mode 100644 index 0000000..7434990 --- /dev/null +++ b/app/lab/kinds/molsubtype.tsx @@ -0,0 +1,43 @@ +"use client"; +// The molsubtype view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Checkbox } from "../../_ui/checkbox"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the molsubtype view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const by = spec.by || ""; + return ( +
+
data + + + +
+
+ ); +} + +export const molsubtypeUi: KindUi<"molsubtype"> = { + kind: "molsubtype", + label: "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", + title: "Molecular subtypes", + maxCohorts: 6, + sub: (spec) => "endometrial molecular classification per patient (POLE, MMRd, p53abn, NSMP)" + (spec.by ? " · by " + spec.by : "") + + (spec.survival === false ? "" : " · OS by subtype"), + Controls, +}; diff --git a/app/lab/kinds/multistate.tsx b/app/lab/kinds/multistate.tsx new file mode 100644 index 0000000..8a8e510 --- /dev/null +++ b/app/lab/kinds/multistate.tsx @@ -0,0 +1,55 @@ +"use client"; +// The multistate view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the multistate view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const pr = spec.progression || {}; + const types = c.events.map((x) => x.label); + const et = pr.eventType || "Diagnosis"; + return ( +
+
progression + + +
+
day 0 + +
+
+ ); +} + +export const multistateUi: KindUi<"multistate"> = { + kind: "multistate", + label: "Multistate model (progression, then death)", + title: "Multistate model", + maxCohorts: 4, + why: (k) => k.events.some((e) => /diagnos|progress/i.test(e.canonical)) ? null : "this study records no diagnosis or progression timeline events", + defaults: (known) => { + const dx = known.events.find((e) => /diagnos/i.test(e.canonical)); + return { kind: "multistate", progression: { eventType: dx ? dx.label : "Diagnosis", key: "PROGRESSION", values: ["Y", "Yes"] } }; + }, + sub: (spec) => { + const pr = spec.progression || {}; + return "start → progression → death, months from day 0 · progression = " + (pr.eventType || "Diagnosis") + " " + (pr.key || "PROGRESSION") + + (spec.prevalent === "exclude" ? " · progressed by day 0 left out" : ""); + }, + Controls, +}; diff --git a/app/lab/kinds/mutations.tsx b/app/lab/kinds/mutations.tsx new file mode 100644 index 0000000..6da117e --- /dev/null +++ b/app/lab/kinds/mutations.tsx @@ -0,0 +1,49 @@ +"use client"; +// The mutations view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { AnnotationBadge } from "../portal-bodies.tsx"; +import { DataTable, specQuery } from "../../_table/DataTable.tsx"; +import { DraftInput } from "./parts.tsx"; +import type { DrawnResultOf, TableQuery } from "../../../lib/types"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; + +// ---- Mutations: one gene's mutation table, on the shared table (app/_table/DataTable.tsx) --------- + +function MutationsBody({ v, spec, vi, updating, docJson, onQuery }: { v: DrawnResultOf<"mutations">; spec: Record; vi: number; updating: boolean; docJson?: () => string; + onQuery: (q: TableQuery) => void }) { + const t = v.mutations; + return ( +
+ id === "vaf" ? x.toFixed(2) : x.toLocaleString()} + cell={(id, x) => id === "annotation" && x ? + : id === "sample" ? {x} : undefined} /> +
+ ); +} + +/** the mutations view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + return (c.locked || []).includes("gene") ? null : ( +
+
data + {!(c.locked || []).includes("gene") && } + +
+
+ ); +} + +export const mutationsUi: KindUi<"mutations"> = { + kind: "mutations", + label: "Mutation table (one gene)", + title: "Mutations", + maxCohorts: 6, + defaults: (known) => ({ kind: "mutations", gene: known.genes[0] || "TP53" }), + sub: (spec, v) => (spec.gene || "no gene") + " · every mutation call" + (v.mutations ? " · " + v.mutations.total.toLocaleString() + " rows" : ""), + Controls, + body: (c) => c.v.mutations ? c.patch({ ...q })} /> : null, + drawsOwnWaiting: true, +}; diff --git a/app/lab/kinds/oncoprint.tsx b/app/lab/kinds/oncoprint.tsx new file mode 100644 index 0000000..a41027a --- /dev/null +++ b/app/lab/kinds/oncoprint.tsx @@ -0,0 +1,49 @@ +"use client"; +// The oncoprint view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { CardProps } from "../view-cards.tsx"; +import type { KindUi } from "./kind-ui.ts"; +import { OncoprintCard } from "../../_oncoprint/OncoprintCard.tsx"; +import { ViewHead } from "../view-chrome.tsx"; +import { placement } from "./parts.tsx"; + +function OncoprintViewCard({ ctx, v, vi }: CardProps) { + const { busy, cohortChoice, cohortFromSelection, contracts, doc, docView, featureFromSelection, menuOpenRef, ocView, openGenes, result, setContracts, trackAttrs, trackAttrsLoading, updateView, updateViewLocal } = ctx; + return <> + {v.kind === "oncoprint" && v.spec && (() => { + const c = contracts[JSON.stringify(v.spec)]; + const vNames = v.cohorts || ocView(vi).cohorts || Object.keys(result.cohorts); + const vStudies = [...new Set(vNames.flatMap(n => (result.cohorts[n] && result.cohorts[n].studies) || []))]; + return <> + {c.stats.nColumns.toLocaleString()} {c.unit}s · {c.conventions.annotation} : "loading…"} /> + featureFromSelection(sel, all, (c ? c.studies : vStudies) || vStudies)} + busy={busy} + cohortNames={Object.keys(result.cohorts)} + catalog={{ attrs: trackAttrs.attrs, profiles: trackAttrs.profiles, loading: trackAttrsLoading }} + onView={(next, rerun) => rerun ? updateView(vi, () => next) : updateViewLocal(vi, () => next)} + onGenes={() => openGenes(vi)} + onCreateCohort={(sel) => cohortFromSelection(sel, (c ? c.studies : vStudies) || vStudies)} + onContract={(ct) => { const k = JSON.stringify(v.spec); setContracts((m) => m[k] === ct ? m : { ...m, [k]: ct }); }} + contract={c || null} + note={v.note} studies={v.studies} + onUndo={ctx.docHistory.canUndo ? ctx.docHistory.undo : undefined} + /> + ; + })()} + ; +} + +export const oncoprintUi: KindUi<"oncoprint"> = { + kind: "oncoprint", + label: "Oncoprint", + title: "Oncoprint", + maxCohorts: 4, + defaults: (k) => ({ kind: "oncoprint", genes: k.genes.slice(0, 5) }), + Card: OncoprintViewCard, + drawsOwnHead: (v) => !!v.spec, +}; diff --git a/app/lab/kinds/organotropism.tsx b/app/lab/kinds/organotropism.tsx new file mode 100644 index 0000000..df87258 --- /dev/null +++ b/app/lab/kinds/organotropism.tsx @@ -0,0 +1,55 @@ +"use client"; +// The organotropism view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the organotropism view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const genes: string[] = Array.isArray(spec.genes) ? spec.genes : []; + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const strat = spec.stratifyBy === null ? "" : spec.stratifyBy || "CANCER_TYPE"; + return ( +
+
data + + + + +
+
+ ); +} + +export const organotropismUi: KindUi<"organotropism"> = { + kind: "organotropism", + label: "Metastatic organotropism (genes × metastatic sites, within cancer types)", + title: "Metastatic organotropism", + maxCohorts: 1, + cohortInHeader: true, + why: (k) => k.attrs.some((a) => /^DMETS_DX_|^METASTATIC_SITE$/i.test(a.attribute)) || k.events.some((e) => /diagnosis/i.test(e.canonical)) ? null + : "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)", + sub: (spec) => (spec.measure === "any" ? "" : "driver ") + (spec.alteration || "mutation") + "s in " + + (spec.genes?.length ? spec.genes.length + " genes" : "the " + (spec.top ?? 12) + " most altered genes") + " × metastatic sites from " + (spec.source && spec.source !== "auto" ? spec.source : "the study's site source") + + (spec.stratifyBy === null ? ", unstratified" : ", within " + (spec.stratifyBy || "CANCER_TYPE")), + Controls, +}; diff --git a/app/lab/kinds/overlap.tsx b/app/lab/kinds/overlap.tsx new file mode 100644 index 0000000..882e17e --- /dev/null +++ b/app/lab/kinds/overlap.tsx @@ -0,0 +1,12 @@ +"use client"; +// The overlap view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { KindUi } from "./kind-ui.ts"; + +export const overlapUi: KindUi<"overlap"> = { + kind: "overlap", + label: "Cohort overlap (UpSet)", + title: "Cohort overlap", + maxCohorts: 6, + minCohorts: 2, + sub: () => "patients in exactly each combination of cohorts", +}; diff --git a/app/lab/kinds/paired.tsx b/app/lab/kinds/paired.tsx new file mode 100644 index 0000000..2b0dde0 --- /dev/null +++ b/app/lab/kinds/paired.tsx @@ -0,0 +1,72 @@ +"use client"; +// The paired view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput, PAIRED_DEFAULT_GENES } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the paired view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const pb = spec.pairBy || { attribute: "SAMPLE_TYPE", a: "Primary", b: "Metastasis" }; + const csv = (x: any) => (Array.isArray(x) ? x : [x]).filter((y: any) => y != null && y !== "").join(", "); + const parse = (t: string) => { const xs = t.split(",").map((y) => y.trim()).filter(Boolean); return xs.length === 1 ? xs[0] : xs; }; + const setBy = (k: "attribute" | "a" | "b", t: string) => patch({ pairBy: { ...pb, [k]: k === "attribute" ? t.trim() : parse(t) } }); + const scanning = !(spec.genes && spec.genes.length); + const perStudy = pb.studies ? Object.keys(pb.studies).length : 0; + return ( +
+
data + {perStudy ? ( + st + ": " + sp.attribute + " " + csv(sp.a) + " vs " + csv(sp.b)).join("\n")}> + sides set per study in the document ({perStudy} {perStudy === 1 ? "study" : "studies"}) + + ) : <> + + + setBy("a", t)} /> + + + } + + {scanning && ( + + )} + +
+
+ ); +} + +export const pairedUi: KindUi<"paired"> = { + kind: "paired", + label: "Paired samples (primary vs metastasis, before vs after)", + title: "Paired samples", + genes: true, + maxCohorts: 3, + defaults: (known) => ({ kind: "paired", genes: known.genes.length ? known.genes.slice(0, 8) : PAIRED_DEFAULT_GENES }), + sub: (spec, v) => { + const pb = spec.pairBy || { attribute: "SAMPLE_TYPE", a: "Primary", b: "Metastasis" }; + const j = (x: any) => Array.isArray(x) ? x.join("/") : String(x ?? ""); + const sides = v.paired ? v.paired.labels.a + " vs " + v.paired.labels.b + : pb.studies ? "sides set per study" : pb.attribute + ": " + j(pb.a) + " vs " + j(pb.b); + return sides + (spec.measure === "any" ? ", any alteration" : ", drivers") + (spec.genes && spec.genes.length ? "" : ", every gene scanned"); + }, + Controls, +}; diff --git a/app/lab/kinds/parts.tsx b/app/lab/kinds/parts.tsx new file mode 100644 index 0000000..62342fd --- /dev/null +++ b/app/lab/kinds/parts.tsx @@ -0,0 +1,75 @@ +"use client"; +// What several view kinds' cards share (app/lab/kinds/.tsx): the text field, the header's placement +// controls, a regression's covariate list, a table's p-value cell. +import { AxisPicker } from "../view-chrome.tsx"; +import { Button } from "../../_ui/button"; +import { Choice } from "../../_ui/choice.tsx"; +import { FIELD, TB } from "../../_ui/toolbar.ts"; +import { Input } from "../../_ui/input"; +import { Plus, X } from "lucide-react"; +import type { ProfileInfo } from "../../../lib/profile-catalog.ts"; +import type { ViewCtx } from "../view-cards.tsx"; +import { cn } from "../../../lib/cn"; +import { pDigits } from "../../../lib/chart/format.ts"; +import { useEffect, useState } from "react"; + +/** + * A text field that keeps what is being typed to itself and hands the page the value only on + * Enter or blur — the page is one large component, and a keystroke in page state re-renders + * every card. + */ +export function DraftInput({ value, onCommit, disabled, width, placeholder, title }: { + value: string; onCommit: (v: string) => void; disabled?: boolean; width?: string; placeholder?: string; title?: string; +}) { + const [draft, setDraft] = useState(value); + useEffect(() => { setDraft(value); }, [value]); + const commit = () => { if (draft !== value) onCommit(draft); }; + return setDraft(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === "Enter") commit(); }} />; +} + +export const PAIRED_DEFAULT_GENES = ["TP53", "KRAS", "PIK3CA", "ESR1", "AR", "CDKN2A", "MYC", "TERT"]; + +/** a p- or q-value for a table cell, where the column header already says "p" (lib/chart/format.ts) */ +export const pCell = pDigits; + +/** A card header's reorder and remove controls, and its note's when the analysis is saved. */ +export function placement({ result, moveView, removeView, noteEdit }: ViewCtx, vi: number, note = true) { + return { + onMoveUp: vi > 0 ? () => moveView(vi, -1) : undefined, + onMoveDown: vi < result.views.length - 1 ? () => moveView(vi, 1) : undefined, + onRemove: () => removeView(vi), + ...(note ? { onEditNote: noteEdit(vi) } : {}), + }; +} + +/** 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. */ +export 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 }[]; catalog: ProfileInfo[]; loading: boolean; + onChange: (ci: number, next: any) => void; onRemove: (ci: number) => void; onAdd: () => void; +}) { + return ( +
+ {covariates.map((c, ci) => { + const levels = observedLevels(ci); + return ( + + onChange(ci, next)} /> + {levels.length > 1 && } + + + ); + })} + +
+ ); +} diff --git a/app/lab/kinds/pathways.tsx b/app/lab/kinds/pathways.tsx new file mode 100644 index 0000000..e7174de --- /dev/null +++ b/app/lab/kinds/pathways.tsx @@ -0,0 +1,34 @@ +"use client"; +// The pathways view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the pathways view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + return ( +
+
data + + +
+
+ ); +} + +export const pathwaysUi: KindUi<"pathways"> = { + kind: "pathways", + label: "Oncogenic pathways", + title: "Oncogenic signalling pathways", + maxCohorts: 4, + sub: (spec) => (spec.measure === "any" ? "any alteration" : "OncoKB drivers") + ", Sanchez-Vega et al. 2018", + Controls, +}; diff --git a/app/lab/kinds/plot.tsx b/app/lab/kinds/plot.tsx new file mode 100644 index 0000000..8b84944 --- /dev/null +++ b/app/lab/kinds/plot.tsx @@ -0,0 +1,48 @@ +"use client"; +// The plot view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { CardProps } from "../view-cards.tsx"; +import type { KindUi } from "./kind-ui.ts"; +import { PlotControls } from "../plot-controls.tsx"; +import { ViewFigure } from "../figure.tsx"; +import { ViewHead } from "../view-chrome.tsx"; +import { placement } from "./parts.tsx"; + +/** a numeric plot axis on a log scale: log10(v + 1), so a zero stays on the axis (lib/plot.ts) */ +function PlotCard({ ctx, v, vi }: CardProps) { + const { attrsLoading, busy, catalogAttrs, cohortChoice, docView, menuOpenRef, profileCatalog, updateView } = ctx; + // every cohort the document evaluates, but the plotted one and a split (which stands for several groups, not one set of cases) + const ch = cohortChoice(vi, docView(vi), 1); + const cohortOptions = ch.nodes.filter((nd) => nd.kind !== "split" && nd.name !== ch.drawn[0]) + .map((nd) => ({ name: nd.name, label: nd.label, depth: nd.depth })); + return <> + {v.kind === "plot" && <> + {v.plot.shape === "scatter" ? "density" : v.plot.shape === "box" ? "box" : "contingency"} · {v.plot.n.toLocaleString()} {(docView(vi).unit || "sample")}s : "two axes over the same cases"} /> + updateView(vi, fn)}> + + + } + ; +} + +export const plotUi: KindUi<"plot"> = { + kind: "plot", + label: "Plot", + title: "Plot", + maxCohorts: 1, + why: (_k, p) => p.viewAttrs().length ? null : "this study declares no clinical attributes to plot", + loading: (_k, p) => p.attrsLoading(), + defaults: (k, p) => { + // start from two attributes this study actually declares, and fall back to an + // alteration axis, which every study can answer + const nums = p.numericAttrs(); + if (nums.length >= 2) return { kind: "plot", x: { source: "clinical", attribute: nums[0] }, y: { source: "clinical", attribute: nums[1] }, unit: "sample" }; + const cat = k.attrs.find((a) => a.kind !== "num"); + return { kind: "plot", unit: "sample", + x: { source: "alteration", gene: k.genes[0] || "TP53", by: "mutationType" }, + y: cat ? { source: "clinical", attribute: cat.attribute } : { source: "alteration", gene: k.genes[1] || "KRAS", by: "mutated" } }; + }, + Card: PlotCard, + drawsOwnHead: () => true, +}; diff --git a/app/lab/kinds/predictive.tsx b/app/lab/kinds/predictive.tsx new file mode 100644 index 0000000..f0bea73 --- /dev/null +++ b/app/lab/kinds/predictive.tsx @@ -0,0 +1,51 @@ +"use client"; +// The predictive view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the predictive view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const ex = spec.exposure || { eventType: "Treatment" }; + const types = c.events.map((x) => x.label); + return ( +
+
exposure + + + +
+
+ ); +} + +export const predictiveUi: KindUi<"predictive"> = { + kind: "predictive", + label: "Predictive biomarkers (gene × treatment interaction)", + title: "Predictive biomarkers", + genes: true, + maxCohorts: 2, + why: (k) => k.events.some((e) => /treat|therap|drug|medicat/i.test(e.canonical)) ? null : "this study records no treatment timeline events to define an exposure", + defaults: (known) => { + const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); + return { kind: "predictive", exposure: { eventType: tx ? tx.label : "Treatment", key: "SUBTYPE", value: "Immuno" }, genes: known.genes.length ? known.genes.slice(0, 8) : ["STK11", "KEAP1", "KRAS", "TP53", "EGFR"] }; + }, + sub: (spec) => { + const ex = spec.exposure || {}; + return "gene × " + (ex.eventType || "?") + (ex.key ? " [" + ex.key + "=" + ex.value + "]" : "") + " on " + (spec.endpoint || "OS") + + (spec.covariates?.length ? " + " + spec.covariates.length + " covariate" + (spec.covariates.length === 1 ? "" : "s") : "") + " · Cox per gene"; + }, + Controls, +}; diff --git a/app/lab/kinds/riskmodel.tsx b/app/lab/kinds/riskmodel.tsx new file mode 100644 index 0000000..b5d559b --- /dev/null +++ b/app/lab/kinds/riskmodel.tsx @@ -0,0 +1,129 @@ +"use client"; +// The riskmodel view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { Checkbox } from "../../_ui/checkbox"; +import { Choice, OptionSelect } from "../../_ui/choice.tsx"; +import { DraftInput } from "./parts.tsx"; +import { GearMenu } from "../../_ui/gear-menu"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { PickMenu } from "../view-chrome.tsx"; +import { RISK_LABEL_ABOVE } from "../../../lib/views/riskmodel/figure.ts"; +import { RiskGroupsButton } from "../risk-groups-dialog.tsx"; +import { RiskTable1Button } from "../risk-table1-dialog.tsx"; +import { Settings2 } from "lucide-react"; + +/** the risk model's covariate menus, open or shut, by view (a viewport state, not the document's) */ +const RISK_MENUS = new Map(); + +/** the runs the model settings offer (OncoCast recommends 50 or more) */ +const RISK_RUN_STEPS = [5, 10, 20, 50]; + +const RISK_PENALTIES: [string, string][] = [["1", "lasso"], ["0.5", "elastic net"], ["0.1", "mostly ridge"]]; + +/** + * The risk model's fitting settings, set once and rarely changed, behind the view's settings menu + * (the sequence and course views' idiom) so the toolbar keeps to what a reader works with. + */ +function RiskModelSettings({ vi, spec, busy, patch }: { vi: number; spec: any; busy: boolean; patch: (p: Record) => void }) { + const alpha = String(spec.alpha ?? 0.5), runs = String(spec.runs ?? 10); + const penalties = RISK_PENALTIES.some(([v]) => v === alpha) ? RISK_PENALTIES : [[alpha, "alpha " + alpha] as [string, string], ...RISK_PENALTIES]; + const runSteps = RISK_RUN_STEPS.map(String); + return ( + settings}> +
+ penalty + patch({ alpha: Number(v) })} options={penalties} /> +
+
+ runs + patch({ runs: Number(v) })} + options={(runSteps.includes(runs) ? runSteps : [runs, ...runSteps]).map((r) => [r, r] as const)} /> +
+ +
+ validate on + patch({ validateOn: t.trim() || undefined })} /> +
+
+ ); +} + +/** the volcano's label thresholds the card offers */ +const RISK_LABEL_STEPS = [0.1, 0.25, 0.5, 0.75, 0.9, 1]; + +/** the riskmodel view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const labelAbove = Number(spec.labelAbove ?? RISK_LABEL_ABOVE); + return ( +
+
data + + + a.attribute !== spec.strata).map((a) => ({ value: a.attribute, label: a.attribute, hint: a.kind === "num" ? "number" : "category" }))} + chosen={spec.covariates || []} max={6} busy={busy} menuId={"risk-cov:" + c.vi} menuStore={RISK_MENUS} + onChange={(next) => patch({ covariates: next.length ? next : undefined })} /> + + {spec.anchor?.eventType && <> + + + } +
+
model + + + +
+
view + +
+
+ ); +} + +export const riskmodelUi: KindUi<"riskmodel"> = { + kind: "riskmodel", + label: "Risk model (elastic-net Cox, OncoCast-style)", + title: "Risk model", + maxCohorts: 1, + sub: (spec) => (spec.endpoint || "OS") + (spec.anchor?.eventType ? " from first " + spec.anchor.eventType + (spec.anchor.key ? " [" + spec.anchor.key + "=" + spec.anchor.value + "]" : "") : "") + + " on " + (spec.measure === "driver" ? "driver mutations" : "mutated genes") + + (spec.covariates?.length ? " + " + spec.covariates.length + " covariate" + (spec.covariates.length === 1 ? "" : "s") : "") + + (spec.strata ? ", stratified by " + spec.strata : "") + " · " + (spec.runs ?? 10) + " held-out splits" + (spec.validate ? ", validated on the held-out half" : "") + + (spec.validateOn ? ", scored on “" + spec.validateOn + "”" : ""), + Controls, + figureView: (spec, v) => v.riskmodel ? { ...v, riskmodel: { ...v.riskmodel, labelAbove: Number(spec.labelAbove ?? RISK_LABEL_ABOVE) } } : v, +}; diff --git a/app/lab/kinds/rmst.tsx b/app/lab/kinds/rmst.tsx new file mode 100644 index 0000000..ddce9cf --- /dev/null +++ b/app/lab/kinds/rmst.tsx @@ -0,0 +1,37 @@ +"use client"; +// The rmst view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; + +/** the rmst view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const lms = Array.isArray(spec.landmarkMonths) ? spec.landmarkMonths.join(", ") : "0"; + return ( +
+
restrict + + +
+
+ ); +} + +export const rmstUi: KindUi<"rmst"> = { + kind: "rmst", + label: "Restricted mean OS time (RMST, landmarks)", + title: "Restricted mean OS time", + maxCohorts: 6, + why: (k) => k.attrs.some((a) => /_MONTHS$/i.test(a.attribute)) ? null : "this study has no _MONTHS outcome column", + sub: (spec) => { + const lm = Array.isArray(spec.landmarkMonths) && spec.landmarkMonths.some((x: number) => x > 0) ? " · landmarks " + spec.landmarkMonths.join(", ") + " mo" : ""; + return "area under the KM curve to " + (spec.tau != null ? "τ = " + spec.tau + " months" : "τ where every cohort has 10 at risk") + " · " + (spec.anchor?.eventType ? "OS from first " + spec.anchor.eventType + (spec.anchor.key ? " [" + spec.anchor.key + "=" + spec.anchor.value + "]" : "") : (spec.endpoint || "OS")) + lm; + }, + Controls, +}; diff --git a/app/lab/kinds/sequence.tsx b/app/lab/kinds/sequence.tsx new file mode 100644 index 0000000..8662616 --- /dev/null +++ b/app/lab/kinds/sequence.tsx @@ -0,0 +1,97 @@ +"use client"; +// The sequence view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { CardProps } from "../view-cards.tsx"; +import { Choice } from "../../_ui/choice.tsx"; +import { CohortPicker, DrawnCohorts, Latency, Participation, SplitByNote, ViewHelpButton, ViewMenu } from "../view-chrome.tsx"; +import { GearMenu } from "../../_ui/gear-menu"; +import type { KindUi } from "./kind-ui.ts"; +import { Settings2 } from "lucide-react"; +import { ViewFigure } from "../figure.tsx"; +import { placement } from "./parts.tsx"; +import { treatmentEventOf } from "../../../lib/event-names.ts"; + +function SequenceKeyHint({ ctx, v, vi }: CardProps) { + const { busy, patchView } = ctx; + return <> + {v.kind === "sequence" && v.error && v.sequence && v.sequence.keyOptions && v.sequence.keyOptions.length > 0 && ( +
+ label lines by + patchView(vi, { labelKey: k })} + options={v.sequence.keyOptions.map(k => [k.label, k.label + " — " + k.values + " value" + (k.values === 1 ? "" : "s")] as const)} /> +
+ )} + ; +} + +function SequenceCard({ ctx, v, vi }: CardProps) { + const { busy, cohortChoice, docView, menuOpenRef, openCohortChooser, patchView, setViewCohorts, viewIds } = ctx; + return <> + {v.kind === "sequence" && !v.error && <> +
+

Treatment sequence + — lines of therapy by {v.sequence ? v.sequence.labelKey : ""} + {v.sequence ? " · " + v.sequence.lineGap + "-day line gap" : ""}

+
+ + openCohortChooser(vi)} + onChange={(next) => setViewCohorts(vi, next, cohortChoice(vi, docView(vi)).all)} /> + {v.sequence && v.sequence.keyOptions && v.sequence.keyOptions.length > 0 && ( + settings} title="treatment sequence options"> + {/* Label granularity is the choice that most changes this chart, and + the keys are not interchangeable: in MSK-CHORD SUBTYPE has 10 + values, AGENT hundreds, TREATMENT_TYPE exactly one. So each option + carries its value count rather than leaving the reader to discover + a useless key by picking it. */} +
+ label lines by + patchView(vi, { labelKey: k })} + options={v.sequence.keyOptions.map(k => [k.label, k.label + " — " + k.values + " value" + (k.values === 1 ? "" : "s") + + (k.values === 1 ? " (unusable)" : k.values > 60 ? " (very granular)" : "")] as const)} /> +
+
+ new line after + patchView(vi, { lineGap: Number(g) })} + options={[30, 60, 90, 120, 180].map(g => [String(g), g + " days"] as const)} /> +
+
+ lines shown + patchView(vi, { maxSteps: Number(m) })} + options={[2, 3, 4, 5].map(m => [String(m), String(m)] as const)} /> +
+
+ )} + + +
+
+ + {v.annotationNote &&

{v.annotationNote}

} + + + } + ; +} + +/** the sequence view's card: the key's hint over its own card */ +function Card(p: CardProps) { + return <>; +} + +export const sequenceUi: KindUi<"sequence"> = { + kind: "sequence", + label: "Treatment sequence", + title: "Treatment sequence", + maxCohorts: 1, + why: (k) => treatmentEventOf(k.events) ? null + : "this study records no treatment events, so there are no lines of therapy to sequence", + loading: (k, p) => !k.events.length && (p.catLoading || p.exploreBusy), + defaults: (k) => { + const tx = treatmentEventOf(k.events); + return { kind: "sequence", ...(tx ? { eventType: tx.canonical } : {}), labelKey: "SUBTYPE", + lineGap: 90, maxSteps: 3, topN: 8 }; + }, + Card, + drawsOwnHead: (v) => !!v.svg, +}; diff --git a/app/lab/kinds/survival.tsx b/app/lab/kinds/survival.tsx new file mode 100644 index 0000000..da9cdfb --- /dev/null +++ b/app/lab/kinds/survival.tsx @@ -0,0 +1,88 @@ +"use client"; +// The survival view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { CardProps } from "../view-cards.tsx"; +import { Choice } from "../../_ui/choice.tsx"; +import { Input } from "../../_ui/input"; +import type { KindUi } from "./kind-ui.ts"; +import { ViewFigure } from "../figure.tsx"; +import { ViewHead } from "../view-chrome.tsx"; +import { Waiting } from "../../_oncoprint/OncoprintCard.tsx"; +import { placement } from "./parts.tsx"; + +function SurvivalCard({ ctx, v, vi }: CardProps) { + const { busy, cat, catLoading, cohortChoice, doc, docView, menuOpenRef, patchView } = ctx; + return <> + {v.kind === "survival" && v.curves && <> + Kaplan–Meier, {v.endpoint && v.endpoint !== "OS" ? v.endpoint : "overall survival"}{v.anchoredTo && <> — from {v.anchoredTo}}} + chips={v.truncated ? left-truncated : null} /> + {(() => { + // The basis of a survival analysis — which endpoint, and what + // the clock starts from — is the thing people most want to + // change, so it is a control rather than a JSON edit. + const sv: any = (doc.views || [])[vi] || {}; + const a = sv.anchor || null; + const eps: string[] = (cat && cat.survivalEndpoints) || []; + const types: string[] = ((cat && cat.types) || []).map((t: any) => t.t); + const keys: string[] = (cat && a && cat.keys && cat.keys[a.eventType]) || []; + const vals: string[] = (cat && a && a.key && cat.values && cat.values[a.eventType] + && cat.values[a.eventType][a.key]) || []; + return ( +
+ endpoint + patchView(vi, { endpoint: v })} + options={(eps.length ? eps : [sv.endpoint || "OS"]).map(x => [x, x] as const)} pending={!cat ? "loading…" : undefined} /> + {catLoading && !cat && } + measured from + patchView(vi, { anchor: v ? { eventType: v, occurrence: "FIRST" } : null })} + options={[["", "study baseline (day 0)"], ...types.map(t => [t, t] as const)]} + pending={!cat ? "loading this study's events…" : undefined} /> + {a && <> + patchView(vi, { anchor: { ...a, occurrence: v } })} options={[["FIRST", "first"], ["LAST", "last"]]} /> + where + patchView(vi, { anchor: v + ? { ...a, key: v, value: (cat.values[a.eventType] || {})[v]?.[0] ?? "" } + : { eventType: a.eventType, occurrence: a.occurrence || "FIRST" } })} + options={[["", "(any)"], ...keys.map(k => [k, k] as const)]} /> + {a.key && ( + patchView(vi, { anchor: { ...a, value: v } })} options={vals.map(x => [x, x] as const)} /> + )} + } + display + patchView(vi, { display: v === "incidence" ? "incidence" : undefined })} + options={[["survival", "survival"], ["incidence", "cumulative incidence"]]} /> + ticks + patchView(vi, { tickMonths: v ? Number(v) : undefined })} + options={[["", "auto"], ...[6, 12, 24, 60].map(m => [String(m), m + "mo"] as const)]} /> + cutoff + patchView(vi, { cutoffMonths: e.target.value ? Number(e.target.value) : undefined })} /> + mo +
+ ); + })()} + + } + ; +} + +export const survivalUi: KindUi<"survival"> = { + kind: "survival", + label: "Survival", + title: "Survival", + why: (_k, p) => p.noSurvivalWhy(), + loading: (_k, p) => p.catLoading, + defaults: () => ({ kind: "survival" }), + Card: SurvivalCard, + drawsOwnHead: (v) => !!v.curves, +}; diff --git a/app/lab/kinds/table.tsx b/app/lab/kinds/table.tsx new file mode 100644 index 0000000..2daf7fb --- /dev/null +++ b/app/lab/kinds/table.tsx @@ -0,0 +1,41 @@ +"use client"; +// The table view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DataTable, specQuery } from "../../_table/DataTable.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** The clinical data table's body, on the shared table (app/_table/DataTable.tsx). */ +function ClinicalTableBody({ c }: { c: KindCardCtx }) { + const t = c.v.table as import("../../../lib/views/table/types.ts").TableResult; + return c.patch({ ...q, attributes: undefined })} + cell={(id, x) => (id === "patient" || id === "sample") ? {x} : undefined} />; +} + +/** the table view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + return ( +
+
data + +
+
+ ); +} + +export const tableUi: KindUi<"table"> = { + kind: "table", + label: "Clinical data table (download)", + title: "Clinical data", + maxCohorts: 6, + sub: (spec, v) => "one row per " + (spec.unit === "patient" ? "patient" : "sample") + (v.table ? " · " + v.table.total.toLocaleString() + " rows, " + v.table.columns.length + " attributes" : ""), + Controls, + body: (c) => c.v.table ? : null, + drawsOwnWaiting: true, +}; diff --git a/app/lab/kinds/timeline.tsx b/app/lab/kinds/timeline.tsx new file mode 100644 index 0000000..1ea2ee9 --- /dev/null +++ b/app/lab/kinds/timeline.tsx @@ -0,0 +1,19 @@ +"use client"; +// The timeline view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import type { KindUi } from "./kind-ui.ts"; +import { TimelineBody } from "../timeline-body"; + +export const timelineUi: KindUi<"timeline"> = { + kind: "timeline", + label: "Patient timeline (one patient at a time)", + title: "Patient timeline", + maxCohorts: 1, + cohortInHeader: true, + why: (k) => k.events.length ? null : "this study records no timeline events", + sub: (_spec, v) => { + const cohort = Array.isArray(v.cohorts) && v.cohorts[0] ? String(v.cohorts[0]).split(" / ").join(" › ") : null; + return <>{cohort && <>{cohort} · }{v.timeline ? v.timeline.total.toLocaleString() + " patients · " : ""}clinical events from diagnosis; + }, + body: (c) => c.v.timeline ? : null, + drawsOwnWaiting: true, +}; diff --git a/app/lab/kinds/txendpoints.tsx b/app/lab/kinds/txendpoints.tsx new file mode 100644 index 0000000..b827560 --- /dev/null +++ b/app/lab/kinds/txendpoints.tsx @@ -0,0 +1,64 @@ +"use client"; +// The txendpoints view on the analysis page: its entry in the add menu, the view a new one starts from, and its card. +import { DraftInput } from "./parts.tsx"; +import type { KindCardCtx, KindUi } from "./kind-ui.ts"; +import { OptionSelect } from "../../_ui/choice.tsx"; + +/** the txendpoints view's settings row */ +function Controls({ c }: { c: KindCardCtx }) { + const { spec, busy, patch } = c; + const an = spec.anchor || { eventType: "Treatment" }; + const types = c.events.map((x) => x.label); + return ( +
+
endpoint + + +
+
clock + + + +
+
+ ); +} + +export const txendpointsUi: KindUi<"txendpoints"> = { + kind: "txendpoints", + label: "Treatment endpoints (TTNT, time on treatment, real-world PFS)", + title: "Treatment endpoints", + maxCohorts: 6, + why: (k) => k.events.some((e) => /treat|therap|drug|medicat/i.test(e.canonical)) ? null : "this study records no treatment timeline events", + defaults: (known) => { + const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); + return { kind: "txendpoints", endpoint: "ttnt", anchor: { eventType: tx ? tx.label : "Treatment", key: "SUBTYPE", value: "Immuno" } }; + }, + sub: (spec) => { + const an = spec.anchor || {}; + const ep = spec.endpoint === "tot" ? "time on treatment" : spec.endpoint === "rwpfs" ? "real-world PFS" : "time to next treatment"; + return ep + " from " + (an.occurrence === "LAST" ? "last " : "first ") + (an.eventType || "?") + (an.key ? " [" + an.key + "=" + an.value + "]" : "") + ", death an event"; + }, + Controls, +}; diff --git a/app/lab/portal-bodies.tsx b/app/lab/portal-bodies.tsx index 181879f..a2db098 100644 --- a/app/lab/portal-bodies.tsx +++ b/app/lab/portal-bodies.tsx @@ -1,62 +1,23 @@ "use client"; -// Card bodies for the views that reproduce cBioPortal's own tabs, laid out as the portal lays them -// out: its sub-tabs, its sortable tables beside the charts, a row that opens its own plot. The -// charts are lib/figures/'s — the same functions that draw the server figure for chat -// and MCP — so the two cannot drift; only the arrangement and the interaction live here. Which -// sub-tab or row is open is this browser's (invariant 9), never the document's. -import React, { useEffect, useMemo, useRef, useState } from "react"; -import type { ViewResult, ViewResultOf, DrawnResultOf, TableQuery, CohortDef, View } from "../../lib/types"; -import { DataTable, specQuery } from "../_table/DataTable.tsx"; +// What the card bodies that reproduce cBioPortal's own tabs share (cancertypes, enrichment, correlates, +// assays and mutations, each in app/lab/kinds/.tsx): the portal's sortable, searchable, paged +// table and its annotation badges. Those bodies lay their views out as the portal does — its sub-tabs, +// its tables beside the charts, a row that opens its own plot — with the charts the server figure is +// drawn from, so the two cannot drift. Which sub-tab or row is open is this browser's (invariant 9), +// never the document's. +import React, { useMemo, useState } from "react"; export { usePages, Pager } from "../_table/DataTable.tsx"; -import { cancertypesPanelSvg } from "../../lib/views/cancertypes/figure.ts"; -import { enrichmentChartsSvg } from "../../lib/views/enrichment/figure.ts"; -import { assayEntitySvg } from "../../lib/views/assays/figure.ts"; -import { pDigits, fmtP, fmtPct, fmtNum } from "../../lib/chart/format"; -import { textOn, seriesStyle } from "../../lib/chart/style"; -import { frame, palette, SERIES } from "../../lib/svg-kit"; -import { api } from "./api.ts"; -import { DrawAtWidth, ViewFigure } from "./figure.tsx"; +import { textOn } from "../../lib/chart/style"; +import { SERIES } from "../../lib/svg-kit"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { Button } from "../_ui/button"; import { Checkbox } from "../_ui/checkbox"; import { Input } from "../_ui/input"; import { cn } from "../../lib/cn"; -import { OptionSelect } from "../_ui/choice.tsx"; -import { FIELD, SEG_TAB, SEG_TABS, TB } from "../_ui/toolbar.ts"; -import { Tabs, TabsList, TabsTrigger } from "../_ui/tabs"; +import { FIELD, TB } from "../_ui/toolbar.ts"; -/** Tabs over a body, the portal's gene / group selectors. */ -function Pills({ items, on, set }: { items: string[]; on: string; set: (x: string) => void }) { - return ( - - - {items.map((x) => {x})} - - - ); -} - -// ---- Cancer Types Summary: "All Queried Genes" and one tab per gene, a bar chart per cohort ---- - -export function CancertypesBody({ v }: { v: DrawnResultOf<"cancertypes"> }) { - const r = v.cancertypes; - const ALL = "All Queried Genes"; - const [gene, setGene] = useState(ALL); - // each cohort's chart laid out for the card's width; one with more groups than fit grows and scrolls - const draw = (i: number) => (width: number) => { - const c = cancertypesPanelSvg(r, i, { width, gene: gene === ALL ? null : gene, title: r.panels.length > 1 || gene !== ALL }); - return frame(c.width, c.height, c.body, palette("auto").bg, c.grown, "Cancer types summary — " + r.panels[i].cohort); - }; - return ( -
- {r.genes.length > 1 && } - {r.panels.map((_, i) => )} -
- ); -} - // ---- a sortable, searchable, paged table — the portal's tables --------------------------------- export interface Col { key: string; label: string; num?: boolean; value: (r: T) => string | number | null; render?: (r: T) => React.ReactNode; title?: string } @@ -116,154 +77,10 @@ export function PortalTable({ rows, cols, initialSort, search, filters, selec ); } -const Badge = ({ text, color }: { text: string; color: string | null }) => +export const Badge = ({ text, color }: { text: string; color: string | null }) => {text}; /** An OncoKB annotation cell: oncogenic calls coloured, the rest grey; nothing when there is no call. */ export const AnnotationBadge = ({ text }: { text: string }) => text ? : null; -// ---- Genomic Alterations: the three charts, then the table ------------------------------------- - -type EnRow = NonNullable["enrichment"]>["rows"][number]; - -export function EnrichmentBody({ v }: { v: DrawnResultOf<"enrichment"> }) { - const r = v.enrichment; - const two = r.groups.length === 2; - const color = (g: string) => seriesStyle(Math.max(0, r.groups.indexOf(g))).color; - const cols: Col[] = [ - { key: "gene", label: "Gene", value: (g) => g.gene, render: (g) => {g.gene} }, - { key: "cytoband", label: "Cytoband", value: (g) => g.cytoband || null }, - ...r.groups.map((name, i): Col => ({ key: "g" + i, label: name, num: true, title: "altered / profiled in " + name, - value: (g) => g.cells[i].pct, render: (g) => <>{g.cells[i].altered.toLocaleString()} ({g.cells[i].pct == null ? "—" : fmtPct(g.cells[i].pct!)}) })), - ...(two ? [{ key: "lr", label: "Log2 Ratio", num: true, value: (g: EnRow) => g.log2Ratio ?? (g.cells[0].altered ? 99 : -99), - render: (g: EnRow) => g.log2Ratio == null ? (g.cells[0].altered ? ">10" : "<-10") : g.log2Ratio.toFixed(2) }] : []), - { key: "p", label: "p-Value", num: true, value: (g) => g.p, render: (g) => pDigits(g.p) }, - { key: "q", label: "q-Value", num: true, value: (g) => g.q, render: (g) => {pDigits(g.q)} }, - { key: "in", label: two ? "Enriched in" : "Most enriched in", value: (g) => g.enrichedIn, render: (g) => }, - ]; - return ( -
- enrichmentChartsSvg(r, { width })} deps={[r]} /> - g.gene + " " + (g.cytoband || "")} - filters={[{ label: "Significant only", test: (g) => g.q < 0.05 }]} - note={r.tested > r.rows.length ? "the " + r.rows.length + " most significant of " + r.tested.toLocaleString() + " genes tested" : undefined} /> -
- ); -} - -// ---- Co-expression: the correlated genes, and the query gene against the one picked ---------- - -type CoRow = NonNullable["correlates"]>["positive"][number]; - -/** - * A plot of two values over one cohort, computed when asked: an ordinary plot view sent for the - * pair, drawn here by the shared renderer. A newer pick supersedes one still in flight. - */ -function usePairPlot(docJson: (() => string) | undefined, cohort: string, view: Record | null) { - const [state, setState] = useState<{ key: string; view?: ViewResult; error?: string } | null>(null); - const want = useRef(""); - const key = JSON.stringify(view); - useEffect(() => { - if (!view || !docJson) return; - want.current = key; - setState(null); - let doc: { study?: string; cohorts?: Record }; - try { doc = JSON.parse(docJson()); } catch { setState({ key, error: "no document to plot from" }); return; } - const def = doc.cohorts && doc.cohorts[cohort]; - if (!def) { setState({ key, error: "cohort “" + cohort + "” is not in the document" }); return; } - api.analysis({ study: doc.study, cohorts: { [cohort]: def }, views: [{ ...view, cohorts: [cohort] } as View] }, { query: "?oncoprint=spec" }) - .then((r) => r.json()) - .then((j) => { if (want.current !== key) return; const pv = j.views && j.views[0]; setState({ key, ...(j.error || pv?.error ? { error: j.error || pv.error } : { view: pv }) }); }) - .catch((e) => { if (want.current === key) setState({ key, error: String(e) }); }); - }, [key]); // eslint-disable-line react-hooks/exhaustive-deps - return state && state.key === key ? state : null; -} - -export function CorrelatesBody({ v, docJson }: { v: DrawnResultOf<"correlates">; docJson?: () => string }) { - const r = v.correlates; - const [which, setWhich] = useState<"any" | "positive" | "negative">("any"); - const rows = useMemo(() => which === "positive" ? r.positive : which === "negative" ? r.negative : [...r.positive, ...r.negative], [r, which]); - 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: "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 }, - { key: "rho", label: "Spearman's Correlation", num: true, value: (g) => g.rho, - render: (g) => 0 ? SERIES[2] : SERIES[4] }}>{g.rho?.toFixed(3)} }, - { key: "p", label: "p-Value", num: true, value: (g) => g.rhoP, render: (g) => pDigits(g.rhoP) }, - { key: "q", label: "q-Value", num: true, value: (g) => g.q, render: (g) => {pDigits(g.q)} }, - ]; - return ( -
-

Genes on {r.otherProfile} correlated with {r.gene} on {r.profileType}, over {r.cohort} · {r.tested.toLocaleString()} genes tested · {r.significant.toLocaleString()} at q < 0.05

-
-
- setWhich(v as typeof which)}> - - - g.gene + " " + (g.cytoband || "")} selected={picked} onSelect={setSel} - note={"the " + r.positive.length + " most positive and " + r.negative.length + " most negative of " + r.tested.toLocaleString()} /> -
-
- {picked &&

{r.gene} vs {picked.gene} · Spearman {picked.rho?.toFixed(2)} ({fmtP(picked.rhoP)}) · Pearson {picked.r?.toFixed(2)} ({fmtP(picked.p)})

} - {!plot && picked &&
drawing {r.gene} vs {picked.gene}…
} - {plot?.error &&

{plot.error}

} - {plot?.view && } -
-
-
- ); -} - -// ---- Generic assays: one row per entity, and the picked entity per group -------------------- - -type AsRow = NonNullable["assays"]>["rows"][number]; - -export function AssaysBody({ v }: { v: DrawnResultOf<"assays"> }) { - const r = v.assays; - const [sel, setSel] = useState(null); - const picked = sel && r.rows.includes(sel) ? sel : r.rows[0] || null; - const color = (g: string) => seriesStyle(Math.max(0, r.groups.indexOf(g))).color; - const numeric = r.datatype === "numeric"; - const cols: Col[] = [ - { key: "name", label: "Entity", value: (e) => e.name, render: (e) => {e.name} }, - ...r.groups.flatMap((name, i): Col[] => numeric - ? [{ key: "m" + i, label: name + " mean", num: true, value: (e) => e.cells[i].mean ?? null, render: (e) => e.cells[i].mean == null ? "—" : fmtNum(e.cells[i].mean!) }, - { key: "s" + i, label: name + " SD", num: true, value: (e) => e.cells[i].sd ?? null, render: (e) => e.cells[i].sd == null ? "—" : fmtNum(e.cells[i].sd!) }] - : [{ key: "n" + i, label: name + " n", num: true, value: (e) => e.cells[i].n }]), - { key: "p", label: "p-Value", num: true, value: (e) => e.p, render: (e) => pDigits(e.p) }, - { key: "q", label: "q-Value", num: true, value: (e) => e.q, render: (e) => {pDigits(e.q)} }, - { key: "in", label: "Higher in", value: (e) => e.higherIn, render: (e) => }, - ]; - return ( -
-

{r.profileName} · {r.test} · {r.tested.toLocaleString()} entities tested · {r.significant.toLocaleString()} at q < 0.05

-
-
- e.name + " " + e.entity} selected={picked} onSelect={setSel} - filters={[{ label: "Significant only", test: (e) => e.q < 0.05 }]} - note={r.tested > r.rows.length ? "the " + r.rows.length + " most significant of " + r.tested.toLocaleString() : undefined} /> -
- {picked &&
assayEntitySvg(r, picked, { width })} deps={[r, picked]} />
} -
-
- ); -} - -// ---- Mutations: one gene's mutation table, on the shared table (app/_table/DataTable.tsx) --------- - -export function MutationsBody({ v, spec, vi, updating, docJson, onQuery }: { v: DrawnResultOf<"mutations">; spec: Record; vi: number; updating: boolean; docJson?: () => string; - onQuery: (q: TableQuery) => void }) { - const t = v.mutations; - return ( -
- id === "vaf" ? x.toFixed(2) : x.toLocaleString()} - cell={(id, x) => id === "annotation" && x ? - : id === "sample" ? {x} : undefined} /> -
- ); -} diff --git a/app/lab/use-view-edits.ts b/app/lab/use-view-edits.ts index 1c094e9..87721e7 100644 --- a/app/lab/use-view-edits.ts +++ b/app/lab/use-view-edits.ts @@ -4,13 +4,13 @@ // `updateViewLocal` when the browser can apply it alone (use-analysis.ts leaves those out of its // keys). Also what a view's header offers: which cohorts it may draw (cohortChoice). Every edit // goes through the document store (doc-store.ts), so a view keeps its id and its card. -import { hasKindCard, defaultKindView, KIND_CARDS } from "./kind-cards"; +import { kindUi } from "./kinds/index.ts"; +import { defaultView } from "./kind-cards"; import { pruneSort } from "../_oncoprint/OncoprintCard.tsx"; import type { GeneTrackSpec } from "../../lib/gene-tracks.ts"; import type { AnalysisDoc, AnalysisResult, CohortDef } from "../../lib/types.ts"; import type { AddCohortTarget } from "./add-cohort-dialog.tsx"; import { expandSplits, type PartitionShape } from "../../lib/partition-arms.ts"; -import { treatmentEventOf } from "../../lib/event-names.ts"; import type { CohortNode } from "./view-chrome.tsx"; import type { DocStore } from "./doc-store.ts"; @@ -45,13 +45,10 @@ export function useViewEdits(page: ViewEditsPage) { const viewTitle = (kind: string): string => (VIEW_KINDS.find((k) => k.kind === kind) || { label: kind }).label; /** the most cohorts a view of this kind draws at once (its cohorts menu enforces the same) */ - const maxCohortsOf = (kind: string): number | undefined => - hasKindCard(kind) ? KIND_CARDS[kind].maxCohorts - : ["hotspots", "coexpression", "cooccurrence", "plot", "sequence"].includes(kind) ? 1 - : kind === "oncoprint" ? 4 : undefined; + const maxCohortsOf = (kind: string): number | undefined => kindUi(kind)?.maxCohorts; /** what an empty view of this kind will draw, said where its "+ Add cohort" is */ const emptyViewHint = (kind: string): string => { - const max = maxCohortsOf(kind), min = hasKindCard(kind) ? KIND_CARDS[kind].minCohorts : undefined; + const max = maxCohortsOf(kind), min = kindUi(kind)?.minCohorts; if (max === 1) return "It draws one cohort."; if (min && max) return "It compares " + (min === max ? "exactly " + min : min + " to " + max) + " cohorts."; if (max) return "It draws up to " + max + " cohorts, side by side."; @@ -77,59 +74,13 @@ export function useViewEdits(page: ViewEditsPage) { }; const addView = (kind: string) => { - const genes = viewGenes(); - const v: any = - kind === "survival" ? { kind } - : kind === "clinical" ? { kind, attributes: viewAttrs() } - : kind === "coexpression" ? { kind, genes: genes.slice(0, 5), profileType: contProfile() } - : kind === "hotspots" ? { kind, genes: genes.slice(0, 4), minCount: 1 } - : kind === "plot" ? (() => { - // start from two attributes this study actually declares, and fall back to an - // alteration axis, which every study can answer - const nums = numericAttrs(); - if (nums.length >= 2) return { kind, x: { source: "clinical", attribute: nums[0] }, y: { source: "clinical", attribute: nums[1] }, unit: "sample" }; - const cat = catalogAttrs().find((a) => a.kind !== "num"); - return { kind, unit: "sample", - x: { source: "alteration", gene: genes[0] || "TP53", by: "mutationType" }, - y: cat ? { source: "clinical", attribute: cat.attribute } : { source: "alteration", gene: genes[1] || "KRAS", by: "mutated" } }; - })() - : kind === "course" ? (() => { - const evs = viewEvents(); - // Rank by STRUCTURE, not by how common the type is. Picking the two - // commonest gives Sequencing and Sample acquisition on MSK-CHORD — - // exactly one event per patient and no duration, i.e. filing records - // that draw a spike and nothing else. A type that carries durations is - // a state worth plotting; one that recurs is an event worth counting; - // one that happens once per patient is neither. - const tier = (e: any) => e.hasDurations ? 2 : (e.perPatient || 0) >= 1.5 ? 1 : 0; - const ranked = [...evs].sort((a, b) => tier(b) - tier(a) || b.n - a.n); - // The anchor wants the opposite property: a landmark nearly everyone - // has. A therapy start is the classic one; failing that, the most - // universal event. - const anchorOn = evs.find(e => e.hasDurations) - || [...evs].sort((a, b) => b.n - a.n)[0]; - return { kind, eventTypes: ranked.slice(0, 2).map(e => e.canonical), - ...(anchorOn ? { anchor: { eventType: anchorOn.label, occurrence: "FIRST" } } : {}), - binMonths: 3, fromMonths: -12, toMonths: 60 }; - })() - : kind === "sequence" ? (() => { - const tx = treatmentEventOf(viewEvents()); - return { kind, ...(tx ? { eventType: tx.canonical } : {}), labelKey: "SUBTYPE", - lineGap: 90, maxSteps: 3, topN: 8 }; - })() - : kind === "cooccurrence" ? { kind, genes: genes.slice(0, 6) } - : kind === "oncoprint" ? { kind, genes: genes.slice(0, 5) } - : kind === "cox" ? { kind, covariates: [{ source: "alteration", gene: genes[0] || "TP53", by: "mutated" }] } - : hasKindCard(kind) ? defaultKindView(kind, { genes, attrs: catalogAttrs(), profiles: profileOptions(), assays: assayOptions(), events: viewEvents(), cohorts: [] }) - : kind === "logistic" ? { kind, - outcome: { source: "alteration", gene: genes[0] || "TP53", by: "mutated" }, outcomeEvent: "Mutated", - covariates: [{ source: "clinical", attribute: viewAttrs()[0] || "SEX" }] } - : { kind, genes }; + const v: any = defaultView(kind, { genes: viewGenes(), attrs: catalogAttrs(), profiles: profileOptions(), assays: assayOptions(), events: viewEvents(), cohorts: [] }, + { viewAttrs, contProfile, numericAttrs }); // With one cohort in the document a new view draws it, unless the view needs more than one; // otherwise it draws nothing until a cohort is added to it (its card says so and offers one). // Every other card's answer is already cached under its key. const names = Object.keys(doc.cohorts || {}); - const min = (hasKindCard(kind) && KIND_CARDS[kind].minCohorts) || 1; + const min = kindUi(kind)?.minCohorts || 1; docs.set({ ...doc, views: [{ ...v, cohorts: names.length === 1 && min <= 1 ? names : [] }, ...(doc.views || [])] }); }; diff --git a/app/lab/use-view-options.ts b/app/lab/use-view-options.ts index b07daf5..3e142dc 100644 --- a/app/lab/use-view-options.ts +++ b/app/lab/use-view-options.ts @@ -4,9 +4,10 @@ // attributes, events and molecular profiles a picker lists, which view kinds can be added // (VIEW_KINDS, each with why it cannot when it cannot, and whether that is still being // fetched), and which kinds draw their own header. Read-only: it edits nothing. -import { KIND_CARDS, hasKindCard } from "./kind-cards"; +import { KIND_UI, kindUi, type AnyKindUi } from "./kinds/index.ts"; +import type { Known, MenuPage } from "./kinds/kind-ui.ts"; import type { AnalysisDoc, AnalysisResult } from "../../lib/types.ts"; -import { foldEventNames, treatmentEventOf } from "../../lib/event-names.ts"; +import { foldEventNames } 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"; @@ -130,35 +131,18 @@ export function useViewOptions(page: ViewOptionsPage) { if (!Array.isArray(cat.survivalEndpoints) || cat.survivalEndpoints.length) return null; return cat.study + " has no survival data (no _MONTHS/_STATUS pair), so there is no endpoint to fit"; }; + /** what the page knows about the study, for an add button's `why` */ + const known = (): Known => ({ genes: [], attrs: catalogAttrs(), profiles: profileOptions(), assays: assayOptions(), events: viewEvents(), cohorts: Object.keys(doc.cohorts || {}) }); + const menuPage: MenuPage = { catLoading, exploreBusy, trackAttrsLoading, noSurvivalWhy, viewAttrs, contProfile, attrsLoading }; /** * `loading`: the data this button's `why` decides on is still being fetched. The button waits * with a spinner rather than guessing — enabling it could add a view that can only error, and * `why` alone would call a study empty when its catalog simply hasn't arrived. */ - const VIEW_KINDS: { kind: string; label: string; why?: () => string | null; loading?: () => boolean }[] = [ - { kind: "compare", label: "Compare genes" }, - { kind: "survival", label: "Survival", why: noSurvivalWhy, loading: () => catLoading }, - { kind: "cox", label: "Cox regression (forest plot)", why: noSurvivalWhy, loading: () => catLoading }, - { kind: "logistic", label: "Logistic regression (odds ratios)" }, - { kind: "oncoprint", label: "Oncoprint" }, - { kind: "hotspots", label: "Lollipop (hotspots)" }, - { kind: "plot", label: "Plot", loading: attrsLoading, - why: () => viewAttrs().length ? null : "this study declares no clinical attributes to plot" }, - { kind: "cooccurrence", label: "Co-occurrence" }, - { kind: "clinical", label: "Clinical comparison", loading: attrsLoading, - why: () => viewAttrs().length ? null : "this study declares no clinical attributes to compare" }, - { kind: "coexpression", label: "Co-expression", loading: () => !contProfile() && (trackAttrsLoading || exploreBusy), - why: () => contProfile() ? null : "this study has no continuous molecular profile (mRNA z-scores) to correlate" }, - { kind: "course", label: "Clinical course", loading: () => !viewEvents().length && (catLoading || exploreBusy), - why: () => viewEvents().length ? null - : "this study records no timeline events (only 131 of 545 studies have any)" }, - { kind: "sequence", label: "Treatment sequence", loading: () => !viewEvents().length && (catLoading || exploreBusy), - why: () => treatmentEventOf(viewEvents()) ? null - : "this study records no treatment events, so there are no lines of therapy to sequence" }, - ...Object.entries(KIND_CARDS).map(([kind, k]) => ({ kind, label: k.label, - ...(k.why ? { why: () => k.why!({ genes: [], attrs: catalogAttrs(), profiles: profileOptions(), assays: assayOptions(), events: viewEvents(), cohorts: Object.keys(doc.cohorts || {}) }), - loading: () => !profileOptions().length && (trackAttrsLoading || exploreBusy) } : {}) })) - ].map((k) => ({ ...k, + const VIEW_KINDS: { kind: string; label: string; why?: () => string | null; loading?: () => boolean }[] = (Object.values(KIND_UI) as AnyKindUi[]).map((k) => ({ kind: k.kind, label: k.label, + ...(k.why ? { why: () => k.why!(known(), menuPage), + loading: () => k.loading ? k.loading(known(), menuPage) : !profileOptions().length && (trackAttrsLoading || exploreBusy) } : {}) })) + .map((k) => ({ ...k, // federation first: it decides whether the view can be drawn at all, the data whether it has anything to draw why: () => federationWhy(k.kind) ?? (k.why ? k.why() : null), loading: () => (!studies.length && Object.keys(doc.cohorts || {}).length > 0) || (k.loading ? k.loading() : false) })); @@ -168,27 +152,15 @@ export function useViewOptions(page: ViewOptionsPage) { * Most kinds only draw theirs once they have data, so a view that came back with only an error * would otherwise be a bare red box the reader can't remove without editing the JSON. */ - const drawsOwnHead = (v: any): boolean => - hasKindCard(v.kind) || v.kind === "cox" || v.kind === "logistic" || v.kind === "plot" || - (v.kind === "compare" && !!v.rows) || (v.kind === "hotspots" && !!v.hotspots) || - (v.kind === "coexpression" && !!v.coexpression) || (v.kind === "clinical" && !!v.clinical) || - (v.kind === "cooccurrence" && !!v.pairs) || (v.kind === "survival" && !!v.curves) || - ((v.kind === "sequence" || v.kind === "course") && !!v.svg) || (v.kind === "oncoprint" && !!v.spec); + const drawsOwnHead = (v: any): boolean => { + const ui = kindUi(v.kind); + return !!ui && (!ui.Card || !!ui.drawsOwnHead?.(v)); + }; - /** - * The gene list of a view, editable in place. - * - * Genes used to be settable only by hand-editing the document JSON: addView - * seeded them from the most-altered genes in Explore (or a hardcoded fallback) - * and nothing could change them afterwards. Five view kinds take genes, so - * this is one control rendered in each one's settings panel rather than five - * near-identical ones. - */ - const GENE_VIEWS = ["compare", "oncoprint", "cooccurrence", "coexpression", "hotspots"]; /** the most-altered genes this exploration found, for the suggestion list */ const geneChoices = (): string[] => { return mostAlteredGenes(explore).slice(0, 60); }; - return { viewGenes, viewAttrs, viewEvents, catalogAttrs, numericAttrs, attrsLoading, profileOptions, profileCatalog, 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, geneChoices, cantDrawCohort }; } diff --git a/app/lab/view-cards.tsx b/app/lab/view-cards.tsx index 1180439..465c65a 100644 --- a/app/lab/view-cards.tsx +++ b/app/lab/view-cards.tsx @@ -1,30 +1,20 @@ "use client"; -// The analysis page's view cards: one component per view kind, each drawing a view's result with -// its header and controls. The page hands every card the same ViewCtx — the document, the result -// and the edits a card can make — so a card holds no state of its own beyond what its body needs. +// The analysis page's view cards: each view's card is its kind's own (app/lab/kinds/.tsx) or the +// shared one (kind-cards.tsx), inside what every card has — the waiting cover, the queries, the error. +// The page hands every card the same ViewCtx — the document, the result and the edits a card can +// make — so a card holds no state of its own beyond what its body needs. import { useLayoutEffect, useRef, useState } from "react"; -import { HotspotsBody } from "../_lollipop/HotspotsBody.tsx"; -import { KIND_CARDS, hasKindCard, kindSub, KindCardControls, KindCardBody, drawsOwnWaiting, figureView } from "./kind-cards"; +import { KindCard, drawsOwnWaiting } from "./kind-cards"; +import { kindUi } from "./kinds/index.ts"; +import { placement } from "./kinds/parts.tsx"; import type { ColumnSelection } from "../_oncoprint/OncoprintFigure.tsx"; -import { OncoprintCard, Waiting } from "../_oncoprint/OncoprintCard.tsx"; import type { UnpackedContract } from "../../lib/oncoprint-binary.ts"; -import { isDrawn, type AnalysisDoc, type AnalysisResult, type ExploreResult } from "../../lib/types.ts"; -import { pDigits } from "../../lib/chart/format.ts"; +import { type AnalysisDoc, type AnalysisResult, type ExploreResult } from "../../lib/types.ts"; 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 { ViewHead, Participation } from "./view-chrome.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"; import { ErrorAlert } from "../_ui/error-alert.tsx"; -import { Checkbox } from "../_ui/checkbox"; -import { Input } from "../_ui/input"; -import { cn } from "../../lib/cn"; -import { Choice } from "../_ui/choice.tsx"; -import { GearMenu, MenuSeparator } from "../_ui/gear-menu.tsx"; -import { CHIP, TB } from "../_ui/toolbar.ts"; import type { OncoprintView } from "../../lib/views/oncoprint/types.ts"; type ResultView = AnalysisResult["views"][number]; @@ -85,10 +75,7 @@ export interface ViewCtx { cohortFromSelection: (sel: ColumnSelection, studies: string[]) => Promise; featureFromSelection: (sel: ColumnSelection, all: string[], studies: string[]) => Promise; } -type CardProps = { ctx: ViewCtx; v: ResultView; vi: number }; - -/** a p- or q-value for a table cell, where the column header already says "p" (lib/chart/format.ts) */ -const pCell = pDigits; +export type CardProps = { ctx: ViewCtx; v: ResultView; vi: number }; export function Updating() { const ref = useRef(null); @@ -116,47 +103,6 @@ export function Updating() { ); } -/** A card header's reorder and remove controls, and its note's when the analysis is saved. */ -function placement({ result, moveView, removeView, noteEdit }: ViewCtx, vi: number, note = true) { - return { - onMoveUp: vi > 0 ? () => moveView(vi, -1) : undefined, - onMoveDown: vi < result.views.length - 1 ? () => moveView(vi, 1) : undefined, - onRemove: () => removeView(vi), - ...(note ? { onEditNote: noteEdit(vi) } : {}), - }; -} - -/** 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, catalog, loading, onChange, onRemove, onAdd }: { - covariates: any[]; ratio: "hazard" | "odds"; observedLevels: (ci: number) => string[]; busy: boolean; - attrs: { attribute: string; kind: string }[]; catalog: ProfileInfo[]; loading: boolean; - onChange: (ci: number, next: any) => void; onRemove: (ci: number) => void; onAdd: () => void; -}) { - return ( -
- {covariates.map((c, ci) => { - const levels = observedLevels(ci); - return ( - - onChange(ci, next)} /> - {levels.length > 1 && } - - - ); - })} - -
- ); -} - /** One view of the analysis, as a card: the waiting cover, the kind's own body, its queries, its error. */ export function ViewCard({ ctx, v, vi }: CardProps) { const { busy, pendingKeys, viewIds } = ctx; @@ -174,15 +120,8 @@ export function ViewCard({ ctx, v, vi }: CardProps) { ); } -const VIEW_HEAD_TITLES: Record = { - compare: "Compare", hotspots: "Mutations", coexpression: "Co-expression", clinical: "Clinical comparison", - cooccurrence: "Co-occurrence", survival: "Survival", cox: "Cox regression", logistic: "Logistic regression", - plot: "Plot", sequence: "Treatment sequence", course: "Clinical course", oncoprint: "Oncoprint", -}; - /** the title a view's header carries once drawn, so its loading card reads the same */ -export const viewHeadTitle = (kind: string): string => - hasKindCard(kind) ? KIND_CARDS[kind].title : VIEW_HEAD_TITLES[kind] ?? kind; +export const viewHeadTitle = (kind: string): string => kindUi(kind)?.title ?? kind; /** A view still waiting for its first result: its header, then a skeleton where the figure will be. */ export function LoadingViewCard({ ctx, vi }: { ctx: Omit; vi: number }) { @@ -202,642 +141,9 @@ export function LoadingViewCard({ ctx, vi }: { ctx: Omit; vi: } function KindBody(p: CardProps) { - const k = p.v.kind; - if (hasKindCard(k)) return ; - switch (k) { - case "compare": return ; - case "hotspots": return ; - case "coexpression": return ; - case "clinical": return ; - case "cooccurrence": return ; - case "survival": return ; - case "cox": return ; - case "logistic": return ; - case "plot": return ; - case "sequence": return <>; - case "course": return ; - case "oncoprint": return ; - default: return null; - } -} - -function CompareCard({ ctx, v, vi }: CardProps) { - const { busy, cohortChoice, docView, menuOpenRef, openGenes } = ctx; - return <> - {v.kind === "compare" && v.rows && <> - alteration frequencies · {v.annotation}} {...placement(ctx, vi)} - actions={} /> - {v.note &&

2 ? { cursor: "help", textDecorationLine: "underline", textDecorationStyle: "dotted" } : undefined}>{v.note}

} -
- - - - {v.cohorts!.map((n, i: number) => )} - {v.rows[0] && v.rows[0].pAny != null && } - {v.rows[0] && v.rows[0].qAny != null && } - - {v.rows.map(r => ( - - - {r.cells.map(c => { - const bt = c.byType; - const missing = bt && (["mutation", "cna", "structuralVariant"] as const) - .filter(t => bt[t].profiled === 0 && (bt.mutation.profiled > 0 || bt.cna.profiled > 0 || bt.structuralVariant.profiled > 0)); - const label = { mutation: "mutation", cna: "copy-number", structuralVariant: "structural-variant" }; - const title = bt ? "mutation " + bt.mutation.altered + "/" + bt.mutation.profiled + - " · copy-number " + bt.cna.altered + "/" + bt.cna.profiled + - " · structural-variant " + bt.structuralVariant.altered + "/" + bt.structuralVariant.profiled + - (missing && missing.length ? " — no " + missing.map(t => label[t]).join("/") + " data for this cohort's study" : "") : undefined; - return ( - - ); - })} - {r.pAny != null && } - {r.qAny != null && } - ))} - -
gene {n}
driver / any
p (any)q (any)
{r.gene} - {c.driverPct}% / {c.anyPct}%{missing && missing.length ? * : null} - {r.pAny < 1e-4 ? r.pAny.toExponential(1) : r.pAny.toFixed(3)}{r.qAny < 1e-4 ? r.qAny.toExponential(1) : r.qAny.toFixed(3)}
-
- } - ; -} - -function HotspotsCard({ ctx, v, vi }: CardProps) { - const { busy, cohortChoice, docView, menuOpenRef, openGenes, updateViewLocal } = ctx; - return <> - {v.kind === "hotspots" && v.hotspots && <> - openGenes(vi)}>genes} /> - {/* the plot from the same renderer MCP uses (lib/view-svg.ts), with the gene summary and the - alteration grid beside it as controls; the grid's selection re-runs nothing */} - updateViewLocal(vi, (vv: any) => { - const nx = { ...vv }; if (h) nx.hide = h; else delete nx.hide; return nx; - })} /> - } - ; -} - -function CoexpressionCard({ ctx, v, vi }: CardProps) { - const { busy, cohortChoice, docView, explore, menuOpenRef, openGenes, updateView } = ctx; - return <> - {v.kind === "coexpression" && isDrawn(v) && <> - - - - } /> - {v.note &&

{v.note}

} -
- - - - - - {v.coexpression.some(x => x.rMeta != null) && } - - - - {v.coexpression.map(r => ( - s2.study + ": r=" + s2.r + " (n=" + s2.n + ")").join("\n") || undefined}> - - - - - {v.coexpression.some(x => x.rMeta != null) && } - - - ))} - -
pairn - rr (meta)pq
{r.a} / {r.b}{r.n.toLocaleString()} - {r.r != null && } - 0.5 ? " bold" : "")}>{r.r ?? "—"}{r.rMeta ?? "—"}{pCell(r.pMeta ?? r.p)}{pCell(r.q)}
-
- } - ; -} - -function ClinicalCard({ ctx, v, vi }: CardProps) { - const { attrsLoading, busy, catalogAttrs, cohortChoice, docView, menuOpenRef, setViewAttrs, viewIds } = ctx; - return <> - {v.kind === "clinical" && v.clinical && <> - ({ value: a.attribute, label: a.attribute, hint: a.kind }))} - chosen={(docView(vi).attributes || []).map(String)} min={1} max={20} - onChange={(next) => setViewAttrs(vi, next)} />} /> - {v.note &&

{v.note}

} -
- - - - {v.cohorts!.map((n, i: number) => )} - - - - - {v.clinical.map(r => ( - - - {r.skipped - ? - : <> - {r.cells.map(c => ( - - ))} - - - - } - ))} - -
attribute {n}testpq
- {r.attribute}
{r.per}-level · {r.kind === "num" ? "numeric" : (r.distinct ?? 0) + " values"} -
not compared — {r.skipped} - {r.kind === "num" - ? {c.mean} ± {c.sd} (n={c.n.toLocaleString()}) - {c.median != null && <>
median {c.median} ({c.q1}–{c.q3})}
- : {(c.top || []).slice(0, 3).map(t2 => t2.v + " " + Math.round(1000 * t2.n / (c.n || 1)) / 10 + "%").join(" · ")}} -
- {r.test}{r.rankP != null && "*"} - {r.p != null ? pCell(r.p) : "—"}{r.q != null ? pCell(r.q) : "—"}
-
- } - ; -} - -function CooccurrenceCard({ ctx, v, vi }: CardProps) { - const { busy, cohortChoice, docView, menuOpenRef, openGenes } = ctx; - return <> - {v.kind === "cooccurrence" && v.pairs && <> - openGenes(vi)}>genes} /> - {v.note &&

{v.note}

} -
- - - - - - - - - - - - - {v.pairs.map(r => { - const lor = r.oddsRatioMH != null ? Math.log2(r.oddsRatioMH) : r.log2Odds; - const pShown = r.pStratified ?? r.p; - return ( - - - - - - - - - - - ); })} - -
pairbothA onlyB onlyneitherlog2 ORpqtendency
{r.a} / {r.b}{r.both.toLocaleString()}{r.onlyA.toLocaleString()}{r.onlyB.toLocaleString()}{r.neither.toLocaleString()}{lor == null ? "—" : lor.toFixed(2)}{pCell(pShown)}{pCell(r.q)} - {r.tendency && {r.tendency}} -
-
- } - ; -} - -function SurvivalCard({ ctx, v, vi }: CardProps) { - const { busy, cat, catLoading, cohortChoice, doc, docView, menuOpenRef, patchView } = ctx; - return <> - {v.kind === "survival" && v.curves && <> - Kaplan–Meier, {v.endpoint && v.endpoint !== "OS" ? v.endpoint : "overall survival"}{v.anchoredTo && <> — from {v.anchoredTo}}} - chips={v.truncated ? left-truncated : null} /> - {(() => { - // The basis of a survival analysis — which endpoint, and what - // the clock starts from — is the thing people most want to - // change, so it is a control rather than a JSON edit. - const sv: any = (doc.views || [])[vi] || {}; - const a = sv.anchor || null; - const eps: string[] = (cat && cat.survivalEndpoints) || []; - const types: string[] = ((cat && cat.types) || []).map((t: any) => t.t); - const keys: string[] = (cat && a && cat.keys && cat.keys[a.eventType]) || []; - const vals: string[] = (cat && a && a.key && cat.values && cat.values[a.eventType] - && cat.values[a.eventType][a.key]) || []; - return ( -
- endpoint - patchView(vi, { endpoint: v })} - options={(eps.length ? eps : [sv.endpoint || "OS"]).map(x => [x, x] as const)} pending={!cat ? "loading…" : undefined} /> - {catLoading && !cat && } - measured from - patchView(vi, { anchor: v ? { eventType: v, occurrence: "FIRST" } : null })} - options={[["", "study baseline (day 0)"], ...types.map(t => [t, t] as const)]} - pending={!cat ? "loading this study's events…" : undefined} /> - {a && <> - patchView(vi, { anchor: { ...a, occurrence: v } })} options={[["FIRST", "first"], ["LAST", "last"]]} /> - where - patchView(vi, { anchor: v - ? { ...a, key: v, value: (cat.values[a.eventType] || {})[v]?.[0] ?? "" } - : { eventType: a.eventType, occurrence: a.occurrence || "FIRST" } })} - options={[["", "(any)"], ...keys.map(k => [k, k] as const)]} /> - {a.key && ( - patchView(vi, { anchor: { ...a, value: v } })} options={vals.map(x => [x, x] as const)} /> - )} - } - display - patchView(vi, { display: v === "incidence" ? "incidence" : undefined })} - options={[["survival", "survival"], ["incidence", "cumulative incidence"]]} /> - ticks - patchView(vi, { tickMonths: v ? Number(v) : undefined })} - options={[["", "auto"], ...[6, 12, 24, 60].map(m => [String(m), m + "mo"] as const)]} /> - cutoff - patchView(vi, { cutoffMonths: e.target.value ? Number(e.target.value) : undefined })} /> - mo -
- ); - })()} - - } - ; -} - -function CoxCard({ ctx, v, vi }: CardProps) { - const { attrsLoading, busy, cat, catLoading, catalogAttrs, cohortChoice, doc, docView, menuOpenRef, patchView, profileCatalog, trackAttrs } = ctx; - return <> - {v.kind === "cox" && <> - Multivariate Cox, {v.endpoint && v.endpoint !== "OS" ? v.endpoint : "overall survival"}} /> - {(() => { - const sv: any = (doc.views || [])[vi] || {}; - const covariates: any[] = Array.isArray(sv.covariates) ? sv.covariates : []; - const eps: string[] = (cat && cat.survivalEndpoints) || []; - const setCovariate = (ci: number, next: any) => { - const nc = covariates.map((c, j) => j === ci ? next : c); - patchView(vi, { covariates: nc }); - }; - const removeCovariate = (ci: number) => patchView(vi, { covariates: covariates.filter((_, j) => j !== ci) }); - const addCovariate = () => patchView(vi, { covariates: [...covariates, { source: "clinical", attribute: (trackAttrs.attrs[0] || {}).attribute || "" }] }); - // The reference level for a categorical covariate is picked from the levels - // this run actually observed (v.cox.coefficients), not re-derived client-side — - // a covariate the fit dropped (only one level, or no result yet) simply gets no - // picker rather than a guess at what the data might contain. - const observedLevels = (ci: number): string[] => { - // when stratified by cohort there's no single pooled coefficients array — - // union the levels each cohort's own fit happened to observe instead - const coefs = (v.cox && v.cox.coefficients.length) ? v.cox.coefficients - : (v.cox?.byCohort || []).flatMap((c: any) => c.coefficients); - const mine = coefs.filter((c: any) => c.axisIndex === ci); - if (!mine.length) return []; - const levels = new Set(); - for (const c of mine) { if (c.reference) levels.add(c.reference); if (c.level) levels.add(c.level); } - return [...levels]; - }; - return ( -
- endpoint - patchView(vi, { endpoint: v })} - options={(eps.length ? eps : [sv.endpoint || "OS"]).map(x => [x, x] as const)} pending={!cat ? "loading…" : undefined} /> - {catLoading && !cat && } - cutoff - patchView(vi, { cutoffMonths: e.target.value ? Number(e.target.value) : undefined })} /> - mo - - - -
- ); - })()} - - } - ; -} - -function KindCard({ ctx, v, vi }: CardProps) { - const { assayOptions, busy, catalogAttrs, cohortChoice, doc, docView, menuOpenRef, openGenes, patchView, pendingKeys, profileOptions, viewEvents, viewIds } = ctx; - return <> - {hasKindCard(v.kind) && <> - openGenes(vi)}>genes - : undefined} /> - {(() => { - const mc = { vi, spec: docView(vi), v, busy, updating: busy || pendingKeys.has(viewIds[vi]), patch: (p: Record) => patchView(vi, p), - attrs: catalogAttrs(), profiles: profileOptions(), assays: assayOptions(), events: viewEvents(), docJson: () => JSON.stringify(doc) }; - const body = KindCardBody({ c: mc }); - return <> - - {v.note &&

{v.note}

} - {body || } - {body && v.caveat &&

{v.caveat}

} - ; - })()} - } - ; -} - -function LogisticCard({ ctx, v, vi }: CardProps) { - const { attrsLoading, busy, catalogAttrs, cohortChoice, doc, docView, menuOpenRef, patchView, profileCatalog, trackAttrs } = ctx; - return <> - {v.kind === "logistic" && <> - Multivariate logistic, odds ratios} /> - {(() => { - const sv: any = (doc.views || [])[vi] || {}; - const covariates: any[] = Array.isArray(sv.covariates) ? sv.covariates : []; - const setCovariate = (ci: number, next: any) => { - const nc = covariates.map((c, j) => j === ci ? next : c); - patchView(vi, { covariates: nc }); - }; - const removeCovariate = (ci: number) => patchView(vi, { covariates: covariates.filter((_, j) => j !== ci) }); - const addCovariate = () => patchView(vi, { covariates: [...covariates, { source: "clinical", attribute: (trackAttrs.attrs[0] || {}).attribute || "" }] }); - // Same idea as the Cox picker's observedLevels, but there's only ever one - // pooled fit here (logistic has no stratifyByCohort yet). - const observedLevels = (ci: number): string[] => { - const coefs = (v.logistic && v.logistic.coefficients) || []; - const mine = coefs.filter((c: any) => c.axisIndex === ci); - if (!mine.length) return []; - const levels = new Set(); - for (const c of mine) { if (c.reference) levels.add(c.reference); if (c.level) levels.add(c.level); } - return [...levels]; - }; - // The outcome's own observed levels, for the outcomeEvent picker: the - // currently-chosen event (if the fit succeeded) plus whatever got collapsed - // into "not event" (outcomeOther) — together, everything this run has seen. - const outcomeLevels = [...new Set([ - ...(sv.outcomeEvent ? [String(sv.outcomeEvent)] : []), - ...((v.logistic && v.logistic.outcomeOther) || []) - ])]; - const outcomeIsAlteration = sv.outcome?.source === "alteration"; - return ( -
- outcome - patchView(vi, { outcome: next })} /> - {!outcomeIsAlteration && } - -
- ); - })()} - - } - ; -} - -function SequenceKeyHint({ ctx, v, vi }: CardProps) { - const { busy, patchView } = ctx; - return <> - {v.kind === "sequence" && v.error && v.sequence && v.sequence.keyOptions && v.sequence.keyOptions.length > 0 && ( -
- label lines by - patchView(vi, { labelKey: k })} - options={v.sequence.keyOptions.map(k => [k.label, k.label + " — " + k.values + " value" + (k.values === 1 ? "" : "s")] as const)} /> -
- )} - ; -} - -/** a numeric plot axis on a log scale: log10(v + 1), so a zero stays on the axis (lib/plot.ts) */ -function PlotCard({ ctx, v, vi }: CardProps) { - const { attrsLoading, busy, catalogAttrs, cohortChoice, docView, menuOpenRef, profileCatalog, updateView } = ctx; - // every cohort the document evaluates, but the plotted one and a split (which stands for several groups, not one set of cases) - const ch = cohortChoice(vi, docView(vi), 1); - const cohortOptions = ch.nodes.filter((nd) => nd.kind !== "split" && nd.name !== ch.drawn[0]) - .map((nd) => ({ name: nd.name, label: nd.label, depth: nd.depth })); - return <> - {v.kind === "plot" && <> - {v.plot.shape === "scatter" ? "density" : v.plot.shape === "box" ? "box" : "contingency"} · {v.plot.n.toLocaleString()} {(docView(vi).unit || "sample")}s : "two axes over the same cases"} /> - updateView(vi, fn)}> - - - } - ; -} - -function SequenceCard({ ctx, v, vi }: CardProps) { - const { busy, cohortChoice, docView, menuOpenRef, openCohortChooser, patchView, setViewCohorts, viewIds } = ctx; - return <> - {v.kind === "sequence" && !v.error && <> -
-

Treatment sequence - — lines of therapy by {v.sequence ? v.sequence.labelKey : ""} - {v.sequence ? " · " + v.sequence.lineGap + "-day line gap" : ""}

-
- - openCohortChooser(vi)} - onChange={(next) => setViewCohorts(vi, next, cohortChoice(vi, docView(vi)).all)} /> - {v.sequence && v.sequence.keyOptions && v.sequence.keyOptions.length > 0 && ( - settings} title="treatment sequence options"> - {/* Label granularity is the choice that most changes this chart, and - the keys are not interchangeable: in MSK-CHORD SUBTYPE has 10 - values, AGENT hundreds, TREATMENT_TYPE exactly one. So each option - carries its value count rather than leaving the reader to discover - a useless key by picking it. */} -
- label lines by - patchView(vi, { labelKey: k })} - options={v.sequence.keyOptions.map(k => [k.label, k.label + " — " + k.values + " value" + (k.values === 1 ? "" : "s") + - (k.values === 1 ? " (unusable)" : k.values > 60 ? " (very granular)" : "")] as const)} /> -
-
- new line after - patchView(vi, { lineGap: Number(g) })} - options={[30, 60, 90, 120, 180].map(g => [String(g), g + " days"] as const)} /> -
-
- lines shown - patchView(vi, { maxSteps: Number(m) })} - options={[2, 3, 4, 5].map(m => [String(m), String(m)] as const)} /> -
-
- )} - - -
-
- - {v.annotationNote &&

{v.annotationNote}

} - - - } - ; -} - -function CourseCard({ ctx, v, vi }: CardProps) { - const { busy, cohortChoice, doc, docView, menuOpenRef, openCohortChooser, patchView, setViewCohorts, viewIds } = ctx; - return <> - {v.kind === "course" && !v.error && <> -
-

Clinical course - — {[...new Set((v.course || []).map(c => c.eventType))].join(", ")} - {" · "}{v.courseAxis ? v.courseAxis.binMonths + "-month bins" : ""}

-
- - openCohortChooser(vi)} - onChange={(next) => setViewCohorts(vi, next, cohortChoice(vi, docView(vi)).all)} /> - settings} title="clinical course options"> - {/* Every parameter of this chart is a choice, and the defaults are only a - guess at a good one — so all of them are settable. They live behind - the gear rather than above the figure because they are read when - setting a chart up and rarely after. Each event type shows the two - structural facts that decide whether it is worth plotting: how often - it recurs, and whether it carries a duration. */} - {v.eventOptions && v.eventOptions.length > 0 && (() => { - const cv: any = (doc.views || [])[vi] || {}; - const chosen: string[] = (cv.eventTypes || []).map((x: string) => String(x).trim().toLowerCase()); - const anchor = cv.anchor || null; - const toggle = (canon: string) => { - const next = chosen.includes(canon) ? chosen.filter(x => x !== canon) : [...chosen, canon]; - if (next.length && next.length <= 6) patchView(vi, { eventTypes: next }); - }; - return ( - <> -
measuring
- {v.eventOptions.map(e => ( - - ))} -
- -
aligned on - patchView(vi, { anchor: ev - ? { eventType: ev, occurrence: anchor ? anchor.occurrence || "FIRST" : "FIRST" } : null })} - options={[["", "study day 0 (single study only)"], ...v.eventOptions.map(e => [e.canonical, e.label] as const)]} /> - {anchor && ( - patchView(vi, { anchor: { ...anchor, occurrence: o } })} options={[["FIRST", "first"], ["LAST", "last"]]} /> - )} -
-
bins - patchView(vi, { binMonths: Number(b) })} - options={[1, 3, 6, 12].map(b => [String(b), b + " month" + (b === 1 ? "" : "s")] as const)} /> -
-
window - patchView(vi, { fromMonths: Number(f) })} - options={[0, -6, -12, -24, -60].map(f => [String(f), f === 0 ? "the anchor" : f + "mo"] as const)} /> - to - patchView(vi, { toMonths: Number(t) })} - options={[12, 24, 60, 120, 240].map(t => [String(t), "+" + t + "mo"] as const)} /> -
- - ); - })()} -
- - -
-
- - {v.annotationNote &&

{v.annotationNote}

} - - - } - ; -} - -function OncoprintViewCard({ ctx, v, vi }: CardProps) { - const { busy, cohortChoice, cohortFromSelection, contracts, doc, docView, featureFromSelection, menuOpenRef, ocView, openGenes, result, setContracts, trackAttrs, trackAttrsLoading, updateView, updateViewLocal } = ctx; - return <> - {v.kind === "oncoprint" && v.spec && (() => { - const c = contracts[JSON.stringify(v.spec)]; - const vNames = v.cohorts || ocView(vi).cohorts || Object.keys(result.cohorts); - const vStudies = [...new Set(vNames.flatMap(n => (result.cohorts[n] && result.cohorts[n].studies) || []))]; - return <> - {c.stats.nColumns.toLocaleString()} {c.unit}s · {c.conventions.annotation} : "loading…"} /> - featureFromSelection(sel, all, (c ? c.studies : vStudies) || vStudies)} - busy={busy} - cohortNames={Object.keys(result.cohorts)} - catalog={{ attrs: trackAttrs.attrs, profiles: trackAttrs.profiles, loading: trackAttrsLoading }} - onView={(next, rerun) => rerun ? updateView(vi, () => next) : updateViewLocal(vi, () => next)} - onGenes={() => openGenes(vi)} - onCreateCohort={(sel) => cohortFromSelection(sel, (c ? c.studies : vStudies) || vStudies)} - onContract={(ct) => { const k = JSON.stringify(v.spec); setContracts((m) => m[k] === ct ? m : { ...m, [k]: ct }); }} - contract={c || null} - note={v.note} studies={v.studies} - onUndo={ctx.docHistory.canUndo ? ctx.docHistory.undo : undefined} - /> - ; - })()} - ; + const ui = kindUi(p.v.kind); + if (!ui) return null; + return ui.Card ? : ; } function QueriesPanel({ ctx, v, vi }: CardProps) { diff --git a/eslint.config.mjs b/eslint.config.mjs index 4e60600..809eb60 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,7 +17,7 @@ import path from "node:path"; // kind; a shared view module imports no kind at all. So a change to one view cannot break another, // and what two kinds need goes into a shared module. const SHARED_VIEW_MODULES = ["lib/views/run.ts", "lib/views/shared.ts", "lib/views/rows.ts", "lib/views/covariates.ts", - "lib/views/kind-def.ts", "lib/views/text.ts"]; + "lib/views/kind-def.ts", "lib/views/text.ts", "app/lab/kinds/kind-ui.ts", "app/lab/kinds/parts.tsx"]; const VIEW_REGISTRIES = ["lib/views/index.ts", "lib/views/kinds.ts", "app/lab/kinds/index.ts"]; const kindBoundary = { meta: { type: "problem", schema: [] }, @@ -25,7 +25,7 @@ const kindBoundary = { const rel = (f) => path.relative(context.cwd, f).split(path.sep).join("/"); const resolve = (f) => [f, f + ".ts", f + ".tsx"].find((x) => /\.tsx?$/.test(x)) && (/\.tsx?$/.test(f) ? f : f + ".ts"); /** the kind a file belongs to, or null for shared code */ - const kindOf = (f) => (/^lib\/views\/([^/]+)\//.exec(f) || /^app\/lab\/kinds\/([^/.]+)(\/|\.tsx?$)/.exec(f) || [])[1] ?? null; + const kindOf = (f) => SHARED_VIEW_MODULES.includes(f) ? null : (/^lib\/views\/([^/]+)\//.exec(f) || /^app\/lab\/kinds\/([^/.]+)(\/|\.tsx?$)/.exec(f) || [])[1] ?? null; const from = rel(context.filename); if (VIEW_REGISTRIES.includes(from)) return {}; const fromKind = kindOf(from); diff --git a/lib/views/kinds.ts b/lib/views/kinds.ts index df28e4c..76faccd 100644 --- a/lib/views/kinds.ts +++ b/lib/views/kinds.ts @@ -1,6 +1,6 @@ // Every view kind's descriptor (lib/views//kind.ts), by kind: what the pages, the figures, the -// results text, the help and the federation rules read about a kind. A new kind is one entry here -// and one in lib/views/index.ts (its runner). Browser-safe. +// results text, the help and the federation rules read about a kind. A new kind is one entry here, +// one in lib/views/index.ts (its runner) and one in app/lab/kinds/index.ts (its page file). Browser-safe. import type { ViewKind, ViewResult } from "../types.ts"; import type { SvgOpts } from "../svg-kit.ts"; import type { ViewHelp } from "../view-help.ts"; diff --git a/test/view-help.test.ts b/test/view-help.test.ts index 687ceb9..f34aeff 100644 --- a/test/view-help.test.ts +++ b/test/view-help.test.ts @@ -9,8 +9,8 @@ import { readFileSync } from "node:fs"; import { VIEW_HELP } from "../lib/view-help.ts"; import { viewKinds } from "./support/view-kinds.ts"; -// the view cards (app/lab/view-cards.tsx) are where course and sequence draw their own headers -const cards = readFileSync(new URL("../app/lab/view-cards.tsx", import.meta.url), "utf8"); +/** a kind's card (app/lab/kinds/.tsx), where course and sequence draw their own headers */ +const cardOf = (kind: string) => readFileSync(new URL("../app/lab/kinds/" + kind + ".tsx", import.meta.url), "utf8"); const chrome = readFileSync(new URL("../app/lab/view-chrome.tsx", import.meta.url), "utf8"); /** every view kind the document language has (test/support/view-kinds.ts) */ @@ -45,7 +45,7 @@ describe("VIEW_HELP: every view kind has a plain-language explanation", () => { // either path silently regressing back to no button at all. assert.match(chrome, / k.kind === kind); const emptyMenu = empty.opts.VIEW_KINDS.find((k) => k.kind === kind); const aggregateMenu = aggregate.opts.VIEW_KINDS.find((k) => k.kind === kind); - const card = kindCards.hasKindCard(kind) ? kindCards.KIND_CARDS[kind] : null; + const ui = (KIND_UI as Record)[kind]; + const card = kindCards.hasKindCard(kind) + ? Object.fromEntries(["label", "title", "genes", "maxCohorts", "minCohorts", "cohortInHeader"].filter((f) => ui[f] !== undefined).map((f) => [f, ui[f]])) : null; const spec = rich.addView(kind) as Record | null; out[kind] = { menu: menu ? { label: menu.label, why: menu.why?.() ?? null, loading: menu.loading?.() ?? false } : null, @@ -82,15 +85,15 @@ function facts() { maxCohorts: rich.edits.maxCohortsOf(kind) ?? null, emptyHint: rich.edits.emptyViewHint(kind), title: rich.edits.viewTitle(kind), headTitle: viewHeadTitle(kind), drawsOwnHead: { error: rich.opts.drawsOwnHead({ kind, error: "x" }), withData: rich.opts.drawsOwnHead({ kind, rows: [], hotspots: [], coexpression: [], clinical: [], pairs: [], curves: [], svg: "x", spec: {} }) }, - card: card ? { ...card, why: card.why?.(known(RICH)) ?? null, whyBareStudy: card.why?.(known(EMPTY)) ?? null, - defaults: kindCards.defaultKindView(kind, known(RICH)), defaultsBareStudy: kindCards.defaultKindView(kind, known(EMPTY)) } : null, + card: card ? { ...card, why: ui.why?.(known(RICH), {}) ?? null, whyBareStudy: ui.why?.(known(EMPTY), {}) ?? null, + defaults: kindCards.defaultView(kind, known(RICH), {} as never), defaultsBareStudy: kindCards.defaultView(kind, known(EMPTY), {} as never) } : null, drawsOwnWaiting: kindCards.drawsOwnWaiting(kind), sub: spec && card ? (() => { const s = kindCards.kindSub(kind, spec, {}); return typeof s === "string" || s == null ? s : "(element)"; })() : null, help: VIEW_HELP[kind] ?? null, need: viewNeed(kind), preview: previewPending(kind), isNew: isNewKind(kind), figureTitle: viewTitle({ kind, cohorts: ["A", "B"] } as never), }; } - return { menuOrder: rich.opts.VIEW_KINDS.map((k) => k.kind), cardOrder: Object.keys(kindCards.KIND_CARDS), kinds: out }; + return { menuOrder: rich.opts.VIEW_KINDS.map((k) => k.kind), cardOrder: Object.keys(KIND_UI).filter(kindCards.hasKindCard), kinds: out }; } /** a value with every object's keys in order, so the file pins what is said, not the order it was written in */ From 0a3fd254c3a2455379132339d5120ca488599b5a Mon Sep 17 00:00:00 2001 From: Aaron Lisman Date: Fri, 23 Oct 2026 22:22:54 +0000 Subject: [PATCH 7/7] The last four runners combine their sites' answers after all are in, in the view's order compare, cancertypes, overlap and composition summed each study's answer inside its callback, as it landed. Their sums were integers, or sorted afterwards, so no snapshot moved, but composition's moments are floating-point sums (their rounding depends on the order they are added) and overlap's intersections broke ties by the order masks first arrived. All four now ask through askEachSite (or, for overlap, whose sites are studies, one Promise.all) and combine in the view's order, as every other runner does. Verified: every view snapshot unchanged, including each case's reversed-delivery run; npm test passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CuBjiBEjYispDSEYDZDWM1 --- lib/views/cancertypes/run.ts | 17 ++++++++--------- lib/views/compare/run.ts | 34 +++++++++++++++------------------- lib/views/composition/run.ts | 26 ++++++++++++++------------ lib/views/overlap/run.ts | 11 +++++------ 4 files changed, 42 insertions(+), 46 deletions(-) diff --git a/lib/views/cancertypes/run.ts b/lib/views/cancertypes/run.ts index 115a8dd..3100353 100644 --- a/lib/views/cancertypes/run.ts +++ b/lib/views/cancertypes/run.ts @@ -2,7 +2,7 @@ import type { ViewResult } from "../../types.ts"; import { geneSymbols } from "../../gene-tracks.ts"; import type { ViewRun } from "../run.ts"; -import { overLimit, ATTR_NAME, answering, plural, refuse } from "../shared.ts"; +import { overLimit, ATTR_NAME, answering, askEachSite, plural, refuse } from "../shared.ts"; import type { CancertypesView, CancertypesGroup, CancertypesPanel } from "./types.ts"; import { cancerTypesSql, cancerTypeSamplesSql } from "./sql.ts"; import { cancertypesMethod } from "./method.ts"; @@ -11,7 +11,7 @@ const zeroGroup = (key: string, label: string): CancertypesGroup => ({ key, label, samples: 0, profiled: 0, altered: 0, mutation: 0, amp: 0, homdel: 0, sv: 0, multiple: 0 }); export async function runCancertypes(view: CancertypesView, ctx: ViewRun): Promise { - const { doc, cohorts, vNames, pin } = ctx; + const { doc, vNames, pin } = ctx; { const e = overLimit("a cancertypes view", 2, vNames.length); if (e) return refuse("cancertypes", ctx, e.replace("at most 2", "at most 2 cohorts")); } let genes: string[]; try { genes = geneSymbols(view.genes || []); } catch (e) { return refuse("cancertypes", ctx, (e as Error).message); } @@ -51,6 +51,10 @@ export async function runCancertypes(view: CancertypesView, ctx: ViewRun): Promi } return { groups: keep, ...(pooled ? { pooled } : {}) }; }; + const answers = await askEachSite(ctx, ok, (n, _st, r) => Promise.all([ + r.viewAggregate("cancertypes:" + n, doc.cohorts[n], (c, s, d) => cancerTypesSql(c, s, d, genes, attribute, pin, measure, unit)), + r.viewAggregate("cancertypes:samples:" + n, doc.cohorts[n], (c, s, d) => cancerTypeSamplesSql(c, s, d, attribute, unit)) + ])); for (const n of vNames) { // gene "*" is the genes together; every other key one gene const byGene = new Map>(); @@ -60,12 +64,7 @@ export async function runCancertypes(view: CancertypesView, ctx: ViewRun): Promi let g = m.get(k); if (!g) { g = zeroGroup(k, label); m.set(k, g); } else if (!g.label && label) g.label = label; return g; }; - await Promise.all(cohorts[n].studies.filter((st) => ok[st]).map(async (st) => { - const def = doc.cohorts[n]; - const [rows, tot] = await Promise.all([ - ok[st].viewAggregate("cancertypes:" + n, def, (c, s, d) => cancerTypesSql(c, s, d, genes, attribute, pin, measure, unit)), - ok[st].viewAggregate("cancertypes:samples:" + n, def, (c, s, d) => cancerTypeSamplesSql(c, s, d, attribute, unit)) - ]); + for (const { study: st, answer: [rows, tot] } of answers.filter((a) => a.cohort === n)) { let profiled = 0; for (const r of rows) { if (r.grp === ALL_KEY) { @@ -83,7 +82,7 @@ export async function runCancertypes(view: CancertypesView, ctx: ViewRun): Promi totals.set(String(r.grp), t); } perSite.push({ cohort: n, study: st, groups: rows.filter((r) => r.gene === "*").length, profiled }); - })); + } // every group with units appears, profiled or not; each carries its totals for (const [k, t] of totals) get("*", k, t.label); for (const m of byGene.values()) for (const g of m.values()) { diff --git a/lib/views/compare/run.ts b/lib/views/compare/run.ts index 718c294..609c567 100644 --- a/lib/views/compare/run.ts +++ b/lib/views/compare/run.ts @@ -6,7 +6,7 @@ import { fisherExact, benjaminiHochberg, chiSquareTest } from "../../stats.ts"; import type { ViewResult } from "../../types.ts"; import { isPatientLevel, type GeneCounts } from "../../federation.ts"; import { untimed, type Untimed, type ViewRun, type DocView } from "../run.ts"; -import { overLimit } from "../shared.ts"; +import { overLimit, askEachSite } from "../shared.ts"; import type { CompareRow, CompareCell, CompareView } from "./types.ts"; import { compareMethod } from "./method.ts"; @@ -44,25 +44,21 @@ export async function runCompare(view: DocView, c: ViewRun): Promise ({ mutation: { profiled: 0, altered: 0 }, cna: { profiled: 0, altered: 0 }, structuralVariant: { profiled: 0, altered: 0 } }); const zeroCounts = (gene: string): GeneCounts => ({ gene, profiled: 0, altered: 0, driver: 0, byType: zeroByType() }); const tally = new Map(); // "cohort|gene" -> summed counts - const perSite: { cohort: string; study: string; counts: GeneCounts[] }[] = []; - await Promise.all(vNames.flatMap((n) => - cohorts[n].studies.map(async (st) => { - const counts = await resolvers[st].geneCounts(doc.cohorts[n], genes); - perSite.push({ cohort: n, study: st, counts }); - for (const c of counts) { - const k = n + "|" + c.gene; - const cur = tally.get(k) || zeroCounts(c.gene); - cur.profiled += c.profiled; cur.altered += c.altered; cur.driver += c.driver; - // an older MCP peer may not send byType yet — treat it as absent, not a crash - for (const t of ALTERATION_TYPES) { - cur.byType[t].profiled += c.byType?.[t]?.profiled ?? 0; - cur.byType[t].altered += c.byType?.[t]?.altered ?? 0; - } - tally.set(k, cur); - } - }))); - // arrival order is whichever study answered first; fix it so the method record is stable + const perSite = (await askEachSite(c, resolvers, (n, _st, r) => r.geneCounts(doc.cohorts[n], genes))) + .map(({ cohort, study, answer }) => ({ cohort, study, counts: answer })); + // in study order within each cohort, so the method record is stable perSite.sort((x, y) => vNames.indexOf(x.cohort) - vNames.indexOf(y.cohort) || (x.study < y.study ? -1 : 1)); + for (const { cohort: n, counts } of perSite) for (const c of counts) { + const k = n + "|" + c.gene; + const cur = tally.get(k) || zeroCounts(c.gene); + cur.profiled += c.profiled; cur.altered += c.altered; cur.driver += c.driver; + // an older MCP peer may not send byType yet — treat it as absent, not a crash + for (const t of ALTERATION_TYPES) { + cur.byType[t].profiled += c.byType?.[t]?.profiled ?? 0; + cur.byType[t].altered += c.byType?.[t]?.altered ?? 0; + } + tally.set(k, cur); + } const aggOnly = vStudies.filter((st) => !isPatientLevel(resolvers[st])); const annSource = (await buildAnnotationIndex([], genes, vStudies.length === 1 ? vStudies[0] : vStudies)).source; const rows: CompareRow[] = genes.map((g) => { diff --git a/lib/views/composition/run.ts b/lib/views/composition/run.ts index 7ed5918..8c761fd 100644 --- a/lib/views/composition/run.ts +++ b/lib/views/composition/run.ts @@ -8,7 +8,7 @@ import { COMPOSITION_MAX_PARTS, COMPOSITION_CASES_CAP, COMPOSITION_EPS, compositionCasesSql, partNames, validateSource, type CompositionSource } from "../../composition.ts"; import type { ViewRun } from "../run.ts"; -import { overLimit, answering, refuse, PROFILE_NAME } from "../shared.ts"; +import { overLimit, answering, askEachSite, refuse, PROFILE_NAME } from "../shared.ts"; import type { CompositionView, CompositionCell, CompositionResult } from "./types.ts"; import { compositionMomentsSql, compositionEntitiesSql } from "./sql.ts"; import { compositionMethod } from "./method.ts"; @@ -85,20 +85,22 @@ export async function runComposition(view: CompositionView, ctx: ViewRun): Promi const perSite: { cohort: string; study: string; n: number }[] = []; const sums = new Map(vNames.map((n) => [n, { n: 0, sx: Array(k).fill(0), sxx: Array(k).fill(0), hx: 0, hxx: 0, incomplete: 0, invalid: 0 }])); const bars = new Map(); + const moments = await askEachSite(ctx, Object.fromEntries(holders.map((st) => [st, ok[st]])), async (name, _st, r) => + (await r.viewAggregate("composition:moments:" + name, doc.cohorts[name], (c, s, d) => compositionMomentsSql(c, s, d, src)))[0] || {}); + for (const { cohort: name, study: st, answer: r } of moments) { + const acc = sums.get(name)!; + const n = Number(r.n || 0); + acc.n += n; acc.hx += Number(r.hx || 0); acc.hxx += Number(r.hxx || 0); + acc.incomplete += Number(r.incomplete || 0); acc.invalid += Number(r.invalid || 0); + if (n) { + const sx = (r.sx as unknown as number[]).map(Number), sxx = (r.sxx as unknown as number[]).map(Number); + for (let j = 0; j < k; j++) { acc.sx[j] += sx[j]; acc.sxx[j] += sxx[j]; } + } + perSite.push({ cohort: name, study: st, n }); + } await Promise.all(vNames.map(async (name) => { const sites = cohorts[name].studies.filter((st) => holders.includes(st)); const def = doc.cohorts[name], acc = sums.get(name)!; - await Promise.all(sites.map(async (st) => { - const r = (await ok[st].viewAggregate("composition:moments:" + name, def, (c, s, d) => compositionMomentsSql(c, s, d, src)))[0] || {}; - const n = Number(r.n || 0); - acc.n += n; acc.hx += Number(r.hx || 0); acc.hxx += Number(r.hxx || 0); - acc.incomplete += Number(r.incomplete || 0); acc.invalid += Number(r.invalid || 0); - if (n) { - const sx = (r.sx as unknown as number[]).map(Number), sxx = (r.sxx as unknown as number[]).map(Number); - for (let j = 0; j < k; j++) { acc.sx[j] += sx[j]; acc.sxx[j] += sxx[j]; } - } - perSite.push({ cohort: name, study: st, n }); - })); const closed = sites.filter((st) => !isPatientLevel(ctx.resolvers[st])); if (closed.length) { bars.set(name, { why: closed.join(", ") + " may not show individual cases" }); return; } if (acc.n > COMPOSITION_CASES_CAP) { bars.set(name, { why: "more than " + COMPOSITION_CASES_CAP.toLocaleString("en-US") + " " + unit + ", too many to draw one bar each" }); return; } diff --git a/lib/views/overlap/run.ts b/lib/views/overlap/run.ts index c765f2c..1a69eef 100644 --- a/lib/views/overlap/run.ts +++ b/lib/views/overlap/run.ts @@ -17,12 +17,11 @@ export async function runOverlap(_view: OverlapView, ctx: ViewRun): Promise(), samplesByMask = new Map(); const perSite: { study: string; cohorts: string[]; patients: number }[] = []; - await Promise.all(Object.keys(ok).map(async (st) => { - const here = vNames.filter((n) => cohorts[n].studies.includes(st)); - if (!here.length) return; - const rows = await ok[st].viewAggregateMany("overlap", here.map((n) => doc.cohorts[n]), overlapCountsSql); + const sites = Object.keys(ok).map((st) => ({ st, here: vNames.filter((n) => cohorts[n].studies.includes(st)) })).filter((x) => x.here.length); + const answers = await Promise.all(sites.map(({ st, here }) => ok[st].viewAggregateMany("overlap", here.map((n) => doc.cohorts[n]), overlapCountsSql))); + sites.forEach(({ st, here }, si) => { let patients = 0; - for (const r of rows) { + for (const r of answers[si]) { const local = Number(r.mask), n = Number(r.n); let global = 0; here.forEach((name, i) => { if (local & (1 << i)) global |= 1 << vNames.indexOf(name); }); @@ -31,7 +30,7 @@ export async function runOverlap(_view: OverlapView, ctx: ViewRun): Promise m > 0) .map(([m, n]) => ({ members: vNames.filter((_, i) => m & (1 << i)), n, samples: samplesByMask.get(m) || 0 })) .sort((a, b) => b.n - a.n || a.members.length - b.members.length);