@datagrok/proteomics 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +52 -3
  2. package/CLAUDE.md +178 -0
  3. package/README.md +195 -3
  4. package/detectors.js +152 -9
  5. package/dist/package-test.js +1 -1744
  6. package/dist/package-test.js.map +1 -1
  7. package/dist/package.js +1 -146
  8. package/dist/package.js.map +1 -1
  9. package/docs/personas-and-capabilities.md +165 -0
  10. package/files/demo/README.md +264 -0
  11. package/files/demo/cptac-spike-in.txt +1571 -0
  12. package/files/demo/enrichment-demo.csv +120 -0
  13. package/files/demo/fragpipe-smoke-test.tsv +11 -0
  14. package/files/demo/proteinGroups.txt +28 -0
  15. package/files/demo/spectronaut-hye-candidates.tsv +94 -0
  16. package/files/demo/spectronaut-hye-demo.tsv +8761 -0
  17. package/files/demo/spectronaut-hye-mix.tsv +8761 -0
  18. package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
  19. package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
  20. package/files/demo/spectronaut-hye-precursor.tsv +493 -0
  21. package/images/enrichment-crosslink.png +0 -0
  22. package/images/enrichment-term-selected.png +0 -0
  23. package/images/hero.png +0 -0
  24. package/images/pipeline.svg +80 -0
  25. package/package.json +87 -62
  26. package/scripts/deqms_de.R +60 -0
  27. package/scripts/limma_de.R +42 -0
  28. package/scripts/vsn_normalize.R +19 -0
  29. package/src/analysis/differential-expression.ts +450 -0
  30. package/src/analysis/enrichment-export.ts +101 -0
  31. package/src/analysis/enrichment.ts +602 -0
  32. package/src/analysis/experiment-setup.ts +199 -0
  33. package/src/analysis/imputation.ts +407 -0
  34. package/src/analysis/log2-scale.ts +139 -0
  35. package/src/analysis/normalization.ts +255 -0
  36. package/src/analysis/pca.ts +254 -0
  37. package/src/analysis/spc-storage.ts +515 -0
  38. package/src/analysis/spc.ts +544 -0
  39. package/src/analysis/subcellular-location.ts +431 -0
  40. package/src/demo/enrichment-demo.ts +94 -0
  41. package/src/demo/proteomics-demo.ts +123 -0
  42. package/src/global.d.ts +15 -0
  43. package/src/menu.ts +133 -0
  44. package/src/package-api.ts +136 -14
  45. package/src/package-test.ts +45 -20
  46. package/src/package.g.ts +161 -0
  47. package/src/package.ts +1029 -17
  48. package/src/panels/protein-focus.ts +63 -0
  49. package/src/panels/published-analysis-panel.ts +151 -0
  50. package/src/panels/uniprot-panel.ts +349 -0
  51. package/src/parsers/fragpipe-parser.ts +200 -0
  52. package/src/parsers/generic-parser.ts +197 -0
  53. package/src/parsers/maxquant-parser.ts +162 -0
  54. package/src/parsers/shared-utils.ts +163 -0
  55. package/src/parsers/spectronaut-candidates-parser.ts +307 -0
  56. package/src/parsers/spectronaut-parser.ts +604 -0
  57. package/src/publishing/assert-published-shape.ts +260 -0
  58. package/src/publishing/post-open-recovery.ts +104 -0
  59. package/src/publishing/publish-project.ts +515 -0
  60. package/src/publishing/publish-settings.ts +59 -0
  61. package/src/publishing/publish-state.ts +316 -0
  62. package/src/publishing/share-dialog.ts +171 -0
  63. package/src/publishing/trim-dataframe.ts +247 -0
  64. package/src/tests/analysis.ts +658 -0
  65. package/src/tests/enrichment-export.ts +61 -0
  66. package/src/tests/enrichment-visualization.ts +340 -0
  67. package/src/tests/enrichment.ts +224 -0
  68. package/src/tests/fragpipe-e2e.ts +74 -0
  69. package/src/tests/fragpipe-parser.ts +147 -0
  70. package/src/tests/gene-label-resolver.ts +387 -0
  71. package/src/tests/generic-parser.ts +152 -0
  72. package/src/tests/group-mean-correlation.ts +139 -0
  73. package/src/tests/log2-scale.ts +93 -0
  74. package/src/tests/organisms.ts +56 -0
  75. package/src/tests/parsers.ts +182 -0
  76. package/src/tests/publish-roundtrip.ts +584 -0
  77. package/src/tests/publish-spike.ts +377 -0
  78. package/src/tests/qc-dashboard.ts +210 -0
  79. package/src/tests/smart-pathway-filter.ts +193 -0
  80. package/src/tests/spc-formula-lines-spike.ts +129 -0
  81. package/src/tests/spc.ts +640 -0
  82. package/src/tests/spectronaut-candidates-e2e.ts +140 -0
  83. package/src/tests/spectronaut-candidates-parser.ts +398 -0
  84. package/src/tests/spectronaut-parser.ts +668 -0
  85. package/src/tests/subcellular-location.ts +361 -0
  86. package/src/tests/uniprot-panel.ts +202 -0
  87. package/src/tests/volcano.ts +603 -0
  88. package/src/utils/column-detection.ts +28 -0
  89. package/src/utils/gene-label-resolver.ts +443 -0
  90. package/src/utils/organisms.ts +82 -0
  91. package/src/utils/proteomics-types.ts +30 -0
  92. package/src/viewers/enrichment-viewers.ts +274 -0
  93. package/src/viewers/group-mean-correlation.ts +218 -0
  94. package/src/viewers/heatmap.ts +168 -0
  95. package/src/viewers/pca-plot.ts +169 -0
  96. package/src/viewers/qc-computations.ts +266 -0
  97. package/src/viewers/qc-dashboard.ts +176 -0
  98. package/src/viewers/spc-dashboard.ts +755 -0
  99. package/src/viewers/volcano.ts +690 -0
  100. package/test-console-output-1.log +2055 -0
  101. package/test-record-1.mp4 +0 -0
  102. package/tools/derive-precursor-golden-sidecar.mjs +81 -0
  103. package/tools/generate-enrichment-fixture.sh +160 -0
  104. package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
  105. package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
  106. package/tools/spectronaut-aggregate.sh +46 -0
  107. package/tools/spectronaut-aggregate.sql +80 -0
  108. package/tsconfig.json +18 -71
  109. package/webpack.config.js +86 -45
  110. package/.eslintignore +0 -1
  111. package/.eslintrc.json +0 -56
  112. package/LICENSE +0 -674
  113. package/package.png +0 -0
  114. package/scripts/number_antibody.py +0 -190
  115. package/scripts/number_antibody_abnumber.py +0 -177
  116. package/scripts/number_antibody_anarci.py +0 -200
@@ -0,0 +1,63 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as DG from 'datagrok-api/dg';
3
+
4
+ import {findColumn} from '../utils/column-detection';
5
+ import {SEMTYPE} from '../utils/proteomics-types';
6
+
7
+ const PROTEIN_ID_HINTS =
8
+ ['primary protein id', 'pg.proteingroups', 'protein id', 'uniprot', 'accession'];
9
+
10
+ /**
11
+ * Selects a protein row so the semType-registered UniProt panel populates in the
12
+ * context panel, opens the context panel, and (best-effort) expands the UniProt
13
+ * pane so the result is actually visible rather than one click away.
14
+ *
15
+ * Shared by two callers: every import handler passes the default row 0 (a small
16
+ * "it works" cue the moment a file opens), and the demo passes its top
17
+ * differential-expression hit. No-op if the table has no recognizable protein-id
18
+ * column, so it's safe to call after any import.
19
+ */
20
+ export function focusProtein(df: DG.DataFrame, rowIdx: number = 0): void {
21
+ if (df.rowCount === 0 || rowIdx < 0 || rowIdx >= df.rowCount) return;
22
+ const idCol = findColumn(df, SEMTYPE.PROTEIN_ID, PROTEIN_ID_HINTS);
23
+ if (!idCol) return;
24
+ df.currentCell = df.cell(rowIdx, idCol.name);
25
+ grok.shell.windows.showContextPanel = true;
26
+ void expandUniProtPane();
27
+ }
28
+
29
+ /**
30
+ * Best-effort: expands the UniProt widget in the semantic context panel so the
31
+ * protein result is visible immediately.
32
+ *
33
+ * There is no supported API to expand a semantic sub-accordion pane from a
34
+ * package function — `onAccordionConstructed` only surfaces the top-level
35
+ * `Actions` accordion, while the UniProt widget is rendered inside the platform's
36
+ * semantic panel — so this reaches into the context-panel DOM. It is EXPAND-ONLY
37
+ * (it clicks a pane header only when it lacks the `expanded` class, so it never
38
+ * collapses a pane the user already opened) and fully guarded: if the platform
39
+ * DOM ever changes shape, the panel simply stays collapsed, exactly as if this
40
+ * helper were absent. The panel content itself is populated either way by the
41
+ * current-cell selection in `focusProtein`.
42
+ */
43
+ async function expandUniProtPane(): Promise<void> {
44
+ const wanted = ['Proteomics', 'UniProt']; // group pane, then the widget inside it
45
+ for (let attempt = 0; attempt < 12; attempt++) {
46
+ await new Promise((r) => setTimeout(r, 250));
47
+ try {
48
+ const acc = document.querySelector('[d4-title="semantic meta.Proteomics-ProteinId"]');
49
+ if (!acc) continue;
50
+ let allExpanded = true;
51
+ for (const name of wanted) {
52
+ const header = Array.from(acc.querySelectorAll('.d4-accordion-pane-header'))
53
+ .find((h) => h.textContent?.trim() === name) as HTMLElement | undefined;
54
+ if (!header) { allExpanded = false; continue; }
55
+ if (!header.classList.contains('expanded')) {
56
+ header.click(); // reveals the child pane on a later iteration
57
+ allExpanded = false;
58
+ }
59
+ }
60
+ if (allExpanded) return;
61
+ } catch { /* best-effort presentation only — leave collapsed on any DOM change */ }
62
+ }
63
+ }
@@ -0,0 +1,151 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as ui from 'datagrok-api/ui';
3
+ import * as DG from 'datagrok-api/dg';
4
+
5
+ import {
6
+ META_COLUMNS, PUBLISHED_TAGS, PublishedMetadata,
7
+ isPublished, getPublishedMetadata, buildMailtoUrl,
8
+ } from '../publishing/publish-state';
9
+ import {findHostDataFrameForProtein} from './uniprot-panel';
10
+ import {getGroups} from '../analysis/experiment-setup';
11
+
12
+ /**
13
+ * Reviewer-side audit context panel. Opens when a reviewer clicks a protein
14
+ * row in a published Project. Surfaces the metadata the analyst stamped at
15
+ * publish time (DE method, thresholds, group names, target, share date, sharer
16
+ * name) plus PUB-13 mailto button. First-line `isPublished(df)` guard ensures
17
+ * it does NOT render on the analyst's live working DataFrame.
18
+ *
19
+ * Reads metadata column FIRST, tag SECOND per Pitfall 3 — the column survives
20
+ * the serializer; the tag is a defensive secondary. All user-facing strings
21
+ * pass the Pitfall 14 biologist-jargon audit. Imports {@link buildMailtoUrl}
22
+ * from `../publishing/publish-state` rather than `../publishing/share-dialog`
23
+ * (B-1 fix — avoids the wave-2 → wave-4 dependency cycle).
24
+ */
25
+
26
+ /** Human-friendly expansion of the DE method ID for the audit "Method" row.
27
+ * CONTEXT.md domain pin: narrate what an expert would assume known. */
28
+ function methodLabel(method: string): string {
29
+ switch (method) {
30
+ case 'limma': return 'limma — moderated t-test';
31
+ case 'deqms': return 'DEqMS — peptide-count-aware moderated t-test';
32
+ case 't-test': return 'Welch t-test';
33
+ case 'spectronaut': return 'Spectronaut Candidates (pre-computed DE)';
34
+ default: return method || '(unknown method)';
35
+ }
36
+ }
37
+
38
+ function dateSlice(d: Date | string | number | null | undefined): string {
39
+ if (d == null) return 'unknown date';
40
+ try {
41
+ if (d instanceof Date) {
42
+ if (Number.isNaN(d.getTime())) return 'unknown date';
43
+ return d.toISOString().slice(0, 10);
44
+ }
45
+ const parsed = new Date(d);
46
+ if (Number.isNaN(parsed.getTime())) return 'unknown date';
47
+ return parsed.toISOString().slice(0, 10);
48
+ } catch {
49
+ return 'unknown date';
50
+ }
51
+ }
52
+
53
+ function fieldRow(label: string, value: string): HTMLDivElement {
54
+ const labelEl = ui.divText(label);
55
+ labelEl.style.fontWeight = '600';
56
+ labelEl.style.fontSize = '12px';
57
+ labelEl.style.color = '#666';
58
+ const valueEl = ui.divText(value);
59
+ valueEl.style.marginBottom = '8px';
60
+ const row = ui.div([labelEl, valueEl]);
61
+ row.style.marginBottom = '6px';
62
+ return row;
63
+ }
64
+
65
+ function readSharerEmail(df: DG.DataFrame, meta: PublishedMetadata): string | null {
66
+ if (meta.publishedByEmail != null && meta.publishedByEmail !== '')
67
+ return meta.publishedByEmail;
68
+ try {
69
+ const col = df.col(META_COLUMNS.PUBLISHED_BY_EMAIL);
70
+ if (col != null) {
71
+ const v = col.get(0);
72
+ if (v != null && v !== '') return String(v);
73
+ }
74
+ } catch { /* fall through */ }
75
+ try {
76
+ const t = df.getTag(PUBLISHED_TAGS.PUBLISHED_BY_EMAIL);
77
+ if (t) return t;
78
+ } catch { /* fall through */ }
79
+ return null;
80
+ }
81
+
82
+ export function publishedAnalysisPanel(proteinId: string): DG.Widget {
83
+ const df = findHostDataFrameForProtein(proteinId);
84
+ if (df == null) return new DG.Widget(ui.div());
85
+ if (!isPublished(df)) return new DG.Widget(ui.div());
86
+
87
+ const meta = getPublishedMetadata(df);
88
+ if (meta == null)
89
+ return new DG.Widget(ui.divText('Shared analysis metadata unavailable. Ask the sharer to re-share.'));
90
+
91
+ const body = ui.div();
92
+ body.style.padding = '8px 4px';
93
+
94
+ if (meta.supersededBy != null && meta.supersededBy !== '') {
95
+ const newerLink = ui.link('Newer version available — open it', async () => {
96
+ try {
97
+ const newer = await grok.dapi.projects.find(meta.supersededBy!);
98
+ await newer.open();
99
+ } catch (e) {
100
+ grok.shell.warning(`Could not open the newer version: ${(e as Error)?.message ?? e}`);
101
+ }
102
+ });
103
+ const banner = ui.div([newerLink]);
104
+ banner.style.padding = '8px';
105
+ banner.style.marginBottom = '10px';
106
+ banner.style.backgroundColor = '#fff8dc';
107
+ banner.style.borderRadius = '4px';
108
+ body.appendChild(banner);
109
+ }
110
+
111
+ body.appendChild(ui.h2('Shared analysis details'));
112
+
113
+ const groups = getGroups(df);
114
+ const comparison = groups != null
115
+ ? `${groups.group2.name} vs ${groups.group1.name}`
116
+ : 'unavailable';
117
+
118
+ const fcFold = Number.isFinite(meta.fcThreshold) ? Math.pow(2, meta.fcThreshold) : NaN;
119
+ const fcLabel = Number.isFinite(meta.fcThreshold)
120
+ ? `log2(${meta.fcThreshold}) (${Number.isFinite(fcFold) ? fcFold.toFixed(2) : 'NaN'}-fold)`
121
+ : '(unknown)';
122
+ const pLabel = Number.isFinite(meta.pThreshold)
123
+ ? `${meta.pThreshold} (FDR-adjusted)`
124
+ : '(unknown)';
125
+
126
+ body.appendChild(fieldRow('Target', meta.target || '(unknown)'));
127
+ body.appendChild(fieldRow('Shared', dateSlice(meta.publishedAt)));
128
+ body.appendChild(fieldRow('Shared by', meta.publishedBy || '(unknown)'));
129
+ body.appendChild(fieldRow('Method', methodLabel(meta.deMethod)));
130
+ body.appendChild(fieldRow('Comparison', comparison));
131
+ body.appendChild(fieldRow('Fold-change cutoff', fcLabel));
132
+ body.appendChild(fieldRow('p-value cutoff', pLabel));
133
+
134
+ const sharerEmail = readSharerEmail(df, meta);
135
+ if (sharerEmail != null) {
136
+ const projectName = df.name || 'shared analysis';
137
+ const mailtoUrl = buildMailtoUrl({
138
+ sharerEmail,
139
+ sharerName: meta.publishedBy || 'colleague',
140
+ projectName,
141
+ publishedDateStr: dateSlice(meta.publishedAt),
142
+ });
143
+ const mailLink = ui.link('Request re-run with different parameters', mailtoUrl);
144
+ (mailLink as HTMLAnchorElement).href = mailtoUrl;
145
+ const mailRow = ui.div([mailLink]);
146
+ mailRow.style.marginTop = '12px';
147
+ body.appendChild(mailRow);
148
+ }
149
+
150
+ return new DG.Widget(body);
151
+ }
@@ -0,0 +1,349 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as ui from 'datagrok-api/ui';
3
+ import * as DG from 'datagrok-api/dg';
4
+ import {fetchUniProtEntry} from '../analysis/subcellular-location';
5
+ import {getGroups, GroupAssignment} from '../analysis/experiment-setup';
6
+ import {findColumn} from '../utils/column-detection';
7
+ import {SEMTYPE, DIRECTION_COLORS_BASE} from '../utils/proteomics-types';
8
+
9
+
10
+ /** UniProt REST API response types */
11
+ interface UniProtEntry {
12
+ accession?: string;
13
+ proteinDescription?: {
14
+ recommendedName?: { fullName?: { value?: string } };
15
+ submissionNames?: Array<{ fullName?: { value?: string } }>;
16
+ };
17
+ genes?: Array<{ geneName?: { value?: string } }>;
18
+ organism?: { scientificName?: string; commonName?: string };
19
+ comments?: Array<{
20
+ commentType?: string;
21
+ texts?: Array<{ value?: string }>;
22
+ }>;
23
+ uniProtKBCrossReferences?: Array<{
24
+ database?: string;
25
+ id?: string;
26
+ properties?: Array<{ key?: string; value?: string }>;
27
+ }>;
28
+ }
29
+
30
+ interface GoTerms {
31
+ mf: string[];
32
+ bp: string[];
33
+ cc: string[];
34
+ }
35
+
36
+
37
+ /**
38
+ * Parse a UniProt accession from a raw protein ID value.
39
+ * Handles:
40
+ * - Bare accessions: "P04637"
41
+ * - UniProt format prefixes: "sp|P04637|P53_HUMAN" or "tr|Q67890|..."
42
+ * - Semicolon-delimited protein groups: "P12345;Q67890" (uses first)
43
+ */
44
+ export function parseAccession(raw: string): string | null {
45
+ if (!raw || !raw.trim())
46
+ return null;
47
+
48
+ // Take first entry if semicolon-delimited
49
+ const first = raw.split(';')[0].trim();
50
+ if (!first)
51
+ return null;
52
+
53
+ // Handle sp|ACC|NAME or tr|ACC|NAME format
54
+ const pipeMatch = first.match(/^(?:sp|tr)\|([A-Za-z0-9_-]+)\|/);
55
+ if (pipeMatch)
56
+ return pipeMatch[1];
57
+
58
+ // Handle CPTAC/UPS format: P00167ups|CYB5_HUMAN_UPS — extract accession before "ups" suffix
59
+ const upsMatch = first.match(/^([A-Za-z0-9]+?)ups\|/i);
60
+ if (upsMatch)
61
+ return upsMatch[1];
62
+
63
+ // Handle generic ACC|NAME format (single pipe, no sp/tr prefix)
64
+ if (first.includes('|'))
65
+ return first.split('|')[0].trim();
66
+
67
+ // Bare accession -- strip trailing "ups" suffix (CPTAC spike-in format) then return
68
+ const bareMatch = first.match(/^([A-Za-z0-9_-]+)$/);
69
+ if (bareMatch) {
70
+ const acc = bareMatch[1];
71
+ return acc.replace(/ups$/i, '');
72
+ }
73
+
74
+ return null;
75
+ }
76
+
77
+
78
+ /** Fetch protein data from UniProt. Delegates to the shared subcellular-location
79
+ * module's cached fetcher so re-clicking a protein doesn't re-hit the network
80
+ * (closes the folded cache-uniprot todo); the shared fetcher keeps the same
81
+ * fetchProxy / warn+null discipline this panel relied on. */
82
+ async function fetchUniProtData(accession: string): Promise<UniProtEntry | null> {
83
+ return (await fetchUniProtEntry(accession)) as UniProtEntry | null;
84
+ }
85
+
86
+
87
+ /** Extract and group GO terms by aspect (F/P/C). */
88
+ function extractGoTerms(entry: UniProtEntry): GoTerms {
89
+ const mf: string[] = [];
90
+ const bp: string[] = [];
91
+ const cc: string[] = [];
92
+
93
+ for (const xref of entry.uniProtKBCrossReferences ?? []) {
94
+ if (xref.database !== 'GO') continue;
95
+ const term = xref.properties?.find((p) => p.key === 'GoTerm')?.value ?? '';
96
+ if (term.startsWith('F:')) mf.push(term.substring(2));
97
+ else if (term.startsWith('P:')) bp.push(term.substring(2));
98
+ else if (term.startsWith('C:')) cc.push(term.substring(2));
99
+ }
100
+ return {mf, bp, cc};
101
+ }
102
+
103
+
104
+ /** Get protein name, falling back to submission name if no recommended name. */
105
+ function getProteinName(entry: UniProtEntry): string {
106
+ return entry.proteinDescription?.recommendedName?.fullName?.value ??
107
+ entry.proteinDescription?.submissionNames?.[0]?.fullName?.value ??
108
+ 'Unknown';
109
+ }
110
+
111
+
112
+ /**
113
+ * R3 / D-11 — locate the host DataFrame for a clicked protein in the UniProt panel.
114
+ * Walks grok.shell.tables for tables whose `getGroups()` is set AND whose primary
115
+ * protein-id column resolves the queried accession in some row. Prefers the
116
+ * table behind the current table view when multiple match.
117
+ */
118
+ export function findHostDataFrameForProtein(accession: string): DG.DataFrame | null {
119
+ if (!accession) return null;
120
+ const candidates: DG.DataFrame[] = [];
121
+ for (const df of grok.shell.tables) {
122
+ if (!getGroups(df)) continue;
123
+ const idCol = findColumn(df, SEMTYPE.PROTEIN_ID,
124
+ ['primary protein id', 'protein id', 'uniprot', 'accession']);
125
+ if (!idCol) continue;
126
+ for (let i = 0; i < df.rowCount; i++) {
127
+ const raw = idCol.get(i) as string | null;
128
+ if (raw && parseAccession(raw) === accession) {
129
+ candidates.push(df);
130
+ break;
131
+ }
132
+ }
133
+ }
134
+ if (candidates.length === 0) return null;
135
+ const activeDf = grok.shell.tv?.dataFrame ?? null;
136
+ if (activeDf && candidates.includes(activeDf)) return activeDf;
137
+ return candidates[0];
138
+ }
139
+
140
+ /**
141
+ * R3 / D-11 — compact SVG bar chart (one bar per experimental group, mean ± SD
142
+ * whiskers, magenta/cyan per D-04). Returns null when the protein has no row
143
+ * in the host DataFrame; the caller renders the empty-state message instead.
144
+ * Returns the empty-state element when the row exists but every group has zero
145
+ * non-null observations.
146
+ *
147
+ * Source: 14-RESEARCH.md §"Pattern 6 — Inline SVG bar chart in a panel".
148
+ */
149
+ export function renderPerGroupBars(df: DG.DataFrame, accession: string): HTMLElement | null {
150
+ const groups = getGroups(df);
151
+ if (!groups) return null;
152
+
153
+ const idCol = findColumn(df, SEMTYPE.PROTEIN_ID,
154
+ ['primary protein id', 'protein id', 'uniprot', 'accession']);
155
+ if (!idCol) return null;
156
+
157
+ let rowIdx = -1;
158
+ for (let i = 0; i < df.rowCount; i++) {
159
+ const raw = idCol.get(i) as string | null;
160
+ if (raw && parseAccession(raw) === accession) { rowIdx = i; break; }
161
+ }
162
+ if (rowIdx < 0) return null;
163
+
164
+ const stats = [groups.group1, groups.group2].map((g) => {
165
+ const vals = g.columns
166
+ .map((n) => df.col(n))
167
+ .filter((c): c is DG.Column => c != null)
168
+ .map((c) => (c.isNone(rowIdx) ? NaN : c.get(rowIdx) as number))
169
+ .filter((v) => typeof v === 'number' && !isNaN(v) && v !== DG.FLOAT_NULL);
170
+ const n = vals.length;
171
+ if (n === 0) return {name: g.name, mean: NaN, sd: NaN, n: 0};
172
+ const mean = vals.reduce((s, v) => s + v, 0) / n;
173
+ const variance = n > 1 ? vals.reduce((s, v) => s + (v - mean) ** 2, 0) / (n - 1) : 0;
174
+ return {name: g.name, mean, sd: Math.sqrt(variance), n};
175
+ });
176
+ if (stats.every((s) => s.n === 0))
177
+ return ui.divText('No per-group quantities available for this protein');
178
+
179
+ // SVG layout — locked spacing per 14-UI-SPEC §"Spacing Scale > Per-group bar-chart".
180
+ const W = 200, H = 120, BAR_W = 24, GAP = 8, PAD = 24;
181
+ const maxVal = Math.max(
182
+ ...stats.map((s) => (isNaN(s.mean) ? 0 : s.mean + (isNaN(s.sd) ? 0 : s.sd))),
183
+ );
184
+ const safeMax = maxVal > 0 ? maxVal : 1;
185
+ const scale = (v: number) => H - PAD - (v / safeMax) * (H - 2 * PAD);
186
+ // D-04 LOCKED palette: group1=magenta, group2=cyan — derived from the shared
187
+ // DIRECTION_COLORS_BASE (ARGB) so the panel can't drift from the volcano.
188
+ // #RRGGBB (uppercase) matches the SVG fill the panel has always emitted.
189
+ const hexRgb = (argb: number) => '#' + (argb & 0xFFFFFF).toString(16).padStart(6, '0').toUpperCase();
190
+ const COLORS = [
191
+ hexRgb(DIRECTION_COLORS_BASE.enrichedG1),
192
+ hexRgb(DIRECTION_COLORS_BASE.enrichedG2),
193
+ ];
194
+ const svgNs = 'http://www.w3.org/2000/svg';
195
+ const svg = document.createElementNS(svgNs, 'svg');
196
+ svg.setAttribute('width', String(W));
197
+ svg.setAttribute('height', String(H));
198
+
199
+ stats.forEach((s, i) => {
200
+ const x = PAD + i * (BAR_W + GAP * 2);
201
+ if (s.n === 0) return;
202
+ const y = scale(s.mean);
203
+ const rect = document.createElementNS(svgNs, 'rect');
204
+ rect.setAttribute('x', String(x));
205
+ rect.setAttribute('y', String(y));
206
+ rect.setAttribute('width', String(BAR_W));
207
+ rect.setAttribute('height', String(Math.max(0, H - PAD - y)));
208
+ rect.setAttribute('fill', COLORS[i] ?? hexRgb(DIRECTION_COLORS_BASE.notSig));
209
+ svg.appendChild(rect);
210
+
211
+ // Mean±SD whiskers only when SD is defined (n > 1).
212
+ if (s.n > 1 && !isNaN(s.sd)) {
213
+ const whiskerX = x + BAR_W / 2;
214
+ const yTop = scale(s.mean + s.sd);
215
+ const yBot = scale(s.mean - s.sd);
216
+ const line = document.createElementNS(svgNs, 'line');
217
+ line.setAttribute('x1', String(whiskerX));
218
+ line.setAttribute('x2', String(whiskerX));
219
+ line.setAttribute('y1', String(yTop));
220
+ line.setAttribute('y2', String(yBot));
221
+ line.setAttribute('stroke', '#333');
222
+ line.setAttribute('stroke-width', '1');
223
+ svg.appendChild(line);
224
+ }
225
+
226
+ const label = document.createElementNS(svgNs, 'text');
227
+ label.setAttribute('x', String(x + BAR_W / 2));
228
+ label.setAttribute('y', String(H - 4));
229
+ label.setAttribute('text-anchor', 'middle');
230
+ label.setAttribute('font-size', '10');
231
+ label.textContent = `${s.name} (n=${s.n})`;
232
+ svg.appendChild(label);
233
+ });
234
+
235
+ const container = ui.divV([svg]);
236
+ stats.forEach((s) => {
237
+ if (s.n > 0) {
238
+ const txt = ui.divText(`${s.name}: mean=${s.mean.toFixed(2)} SD=${s.sd.toFixed(2)}`);
239
+ txt.style.fontSize = '0.85em';
240
+ container.appendChild(txt);
241
+ }
242
+ });
243
+ return container;
244
+ }
245
+
246
+
247
+ /** Build the widget DOM for a successfully fetched UniProt entry. */
248
+ export function renderUniProtWidget(entry: UniProtEntry, accession: string): HTMLElement {
249
+ const container = document.createElement('div');
250
+
251
+ // Prominent link to full UniProt entry
252
+ const link = ui.link(
253
+ `UniProt: ${accession}`,
254
+ `https://www.uniprot.org/uniprot/${accession}`,
255
+ 'Open full UniProt entry',
256
+ );
257
+ link.style.fontWeight = 'bold';
258
+ link.style.display = 'block';
259
+ link.style.marginBottom = '8px';
260
+ container.appendChild(link);
261
+
262
+ // Summary table
263
+ const proteinName = getProteinName(entry);
264
+ const gene = entry.genes?.[0]?.geneName?.value ?? 'Unknown';
265
+ const organism = entry.organism?.scientificName ?? 'Unknown';
266
+
267
+ const funcComment = entry.comments?.find((c) => c.commentType === 'FUNCTION');
268
+ let funcText = funcComment?.texts?.[0]?.value ?? '';
269
+ if (funcText.length > 200)
270
+ funcText = funcText.substring(0, 200) + '...';
271
+
272
+ const map: Record<string, string> = {
273
+ 'Protein': proteinName,
274
+ 'Gene': gene,
275
+ 'Organism': organism,
276
+ };
277
+ if (funcText)
278
+ map['Function'] = funcText;
279
+
280
+ container.appendChild(ui.tableFromMap(map));
281
+
282
+ // GO terms grouped by category
283
+ const go = extractGoTerms(entry);
284
+ const goCategories: Array<{label: string; terms: string[]}> = [
285
+ {label: 'Molecular Function', terms: go.mf},
286
+ {label: 'Biological Process', terms: go.bp},
287
+ {label: 'Cellular Component', terms: go.cc},
288
+ ];
289
+
290
+ const hasGoTerms = goCategories.some((c) => c.terms.length > 0);
291
+ if (hasGoTerms) {
292
+ const goHeader = ui.divText('GO Terms');
293
+ goHeader.style.fontWeight = 'bold';
294
+ goHeader.style.marginTop = '8px';
295
+ goHeader.style.marginBottom = '4px';
296
+ container.appendChild(goHeader);
297
+
298
+ for (const cat of goCategories) {
299
+ if (cat.terms.length === 0) continue;
300
+ const label = ui.divText(cat.label);
301
+ label.style.fontWeight = 'bold';
302
+ label.style.fontSize = '0.9em';
303
+ label.style.marginTop = '4px';
304
+ container.appendChild(label);
305
+
306
+ const termText = cat.terms.slice(0, 5).join(', ');
307
+ const termDiv = ui.divText(termText);
308
+ termDiv.style.fontSize = '0.85em';
309
+ termDiv.style.marginLeft = '8px';
310
+ container.appendChild(termDiv);
311
+ }
312
+ }
313
+
314
+ // R3 / D-11 — per-group quantities bar chart (Phase 14)
315
+ // The panel's `accession` parameter is the canonical source; threading it
316
+ // through findHostDataFrameForProtein avoids depending on whichever optional
317
+ // field the UniProtData shape happens to expose.
318
+ const hostDf = findHostDataFrameForProtein(accession);
319
+ if (hostDf) {
320
+ const perGroupHeader = ui.divText('Per-Group Quantities');
321
+ perGroupHeader.style.fontWeight = 'bold';
322
+ perGroupHeader.style.marginTop = '8px';
323
+ perGroupHeader.style.marginBottom = '4px';
324
+ container.appendChild(perGroupHeader);
325
+ const bars = renderPerGroupBars(hostDf, accession);
326
+ container.appendChild(bars ?? ui.divText('No per-group quantities available for this protein'));
327
+ }
328
+
329
+ return container;
330
+ }
331
+
332
+
333
+ /**
334
+ * Main panel function. Accepts a raw protein ID string, returns a DG.Widget
335
+ * with async loading (spinner while fetching).
336
+ */
337
+ export function uniprotPanel(proteinId: string): DG.Widget {
338
+ const accession = parseAccession(proteinId);
339
+
340
+ if (!accession)
341
+ return new DG.Widget(ui.divText('No valid UniProt accession found'));
342
+
343
+ return new DG.Widget(ui.wait(async () => {
344
+ const data = await fetchUniProtData(accession);
345
+ if (!data)
346
+ return ui.divText(`Unable to fetch UniProt data for ${accession}`);
347
+ return renderUniProtWidget(data, accession);
348
+ }));
349
+ }