gramene-search 1.6.46 → 1.6.47

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gramene-search",
3
- "version": "1.6.46",
3
+ "version": "1.6.47",
4
4
  "description": "search wrapper for gramene",
5
5
  "source": "src/index.js",
6
6
  "main": "dist/index.js",
@@ -23,6 +23,7 @@
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
25
  "@fortawesome/fontawesome-free": "^6.6.0",
26
+ "@parcel/watcher": "^2.5.1",
26
27
  "ag-grid-community": "^31.1.1",
27
28
  "ag-grid-react": "^31.0.3",
28
29
  "axios": "^1.6.8",
@@ -6,10 +6,11 @@ import {build} from "gramene-taxonomy-with-genomes";
6
6
 
7
7
  const facets = [
8
8
  "{!facet.limit='300' facet.mincount='1' key='taxon_id'}taxon_id",
9
- "{!facet.limit='100' facet.mincount='1' key='genetree'}gene_tree",
10
- "{!facet.limit='100' facet.mincount='1' key='pathways'}pathways__ancestors",
11
- "{!facet.limit='100' facet.mincount='1' key='domains'}domain_roots",
12
- "{!facet.limit='-1' facet.mincount='1' key='fixed_1000__bin'}fixed_1000__bin"
9
+ // "{!facet.limit='100' facet.mincount='1' key='genetree'}gene_tree",
10
+ // "{!facet.limit='100' facet.mincount='1' key='pathways'}pathways__ancestors",
11
+ // "{!facet.limit='100' facet.mincount='1' key='domains'}domain_roots",
12
+ "{!facet.limit='-1' facet.mincount='1' key='fixed_1000__bin'}fixed_1000__bin",
13
+ "{!facet.limit='100' facet.mincount='0' key='AED' type='range' start=0 end=1.0 gap=0.01}MAKER__AED__attr_f"
13
14
  ];
14
15
  const genomesOfInterest = '(taxon_id:2769) OR (taxon_id:3055) OR (taxon_id:3218) OR (taxon_id:3702) OR (taxon_id:3847) OR (taxon_id:4555) OR (taxon_id:4558) OR (taxon_id:4577) OR (taxon_id:13333) OR (taxon_id:15368) OR (taxon_id:29760) OR (taxon_id:39947) OR (taxon_id:55577) OR (taxon_id:88036) OR (taxon_id:214687)';
15
16
  const sites = ['main','oryza','maize','sorghum','grapevine'];
@@ -291,54 +292,43 @@ grameneGermplasm.reactGrameneGermplasm = createSelector(
291
292
  // }
292
293
  // );
293
294
 
294
- const attribFacetFields = [
295
- "{!facet.limit='10' facet.mincount='1' key='age'}panset_age_attr_s",
296
- "{!facet.limit='100' facet.mincount='1' key='taxa'}panset_ntaxa_attr_i",
297
- "{!facet.limit='100' facet.mincount='1' key='AED' type='range' start=0 end=1.0 gap=0.25}MAKER_AED_attr_f",
298
- "{!facet.limit='100' facet.mincount='1' key='QI2' type='range' start=0 end=1.0 gap=0.25}MAKER_QI2_attr_f",
299
- "{!facet.limit='100' facet.mincount='1' key='QI3' type='range' start=0 end=1.0 gap=0.25}MAKER_QI3_attr_f",
300
- "{!facet.limit='100' facet.mincount='1' key='QI4' type='range' start=0 end=1.0 gap=0.25}MAKER_QI4_attr_f",
301
- "{!facet.limit='100' facet.mincount='1' key='QI5' type='range' start=0 end=1.0 gap=0.25}MAKER_QI5_attr_f",
302
- "{!facet.limit='100' facet.mincount='1' key='QI6' type='range' start=0 end=1.0 gap=0.25}MAKER_QI6_attr_f"
295
+ const MAKERAttribs = [
296
+ 'MAKER__AED__attr_f',
297
+ 'MAKER__QI1__attr_i',
298
+ 'MAKER__QI2__attr_f',
299
+ 'MAKER__QI3__attr_f',
300
+ 'MAKER__QI4__attr_f',
301
+ 'MAKER__QI5__attr_f',
302
+ 'MAKER__QI6__attr_f',
303
+ 'MAKER__QI7__attr_i',
304
+ 'MAKER__QI8__attr_i',
305
+ 'MAKER__QI9__attr_i'
303
306
  ];
304
- const attribFacets = {
305
- "age":{ "type": "terms", "field": "panset_age_attr_s" },
306
- "nTaxa": { "type": "terms", "field": "panset_ntaxa_attr_i", "limit": 100 },
307
- "AED":{ "type": "range", "field": "MAKER_AED_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
308
- "QI2":{ "type": "range", "field": "MAKER_QI2_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
309
- "QI3":{ "type": "range", "field": "MAKER_QI3_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
310
- "QI4":{ "type": "range", "field": "MAKER_QI4_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
311
- "QI5":{ "type": "range", "field": "MAKER_QI5_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
312
- "QI6":{ "type": "range", "field": "MAKER_QI6_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
313
- "byAge":{ "type": "terms", "field": "panset_age_attr_s", "facet": {
314
- "nTaxa": { "type": "terms", "field": "panset_ntaxa_attr_i", "limit": 100 },
315
- "AED":{ "type": "range", "field": "MAKER_AED_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
316
- "QI2":{ "type": "range", "field": "MAKER_QI2_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
317
- "QI3":{ "type": "range", "field": "MAKER_QI3_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
318
- "QI4":{ "type": "range", "field": "MAKER_QI4_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
319
- "QI5":{ "type": "range", "field": "MAKER_QI5_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 },
320
- "QI6":{ "type": "range", "field": "MAKER_QI6_attr_f", "start": 0.0, "end": 1.0, "gap": 0.25 }
321
- }
322
- }
307
+ const geneAttribs = {
308
+ "MAKER transcript metrics" : [
309
+ { name: "AED", description: "Annotation Edit Distance", dtype: "f", fieldName: "MAKER__AED__attr_f" },
310
+ { name: "QI1", description: "Length of the 5' UTR", dtype: "i", fieldName: "MAKER__QI1__attr_i" },
311
+ { name: "QI2", description: "Fraction of splice sites confirmed by an EST alignment", dtype: "f", fieldName: "MAKER__QI2__attr_f" },
312
+ { name: "QI3", description: "Fraction of exons that overlap an EST alignment", dtype: "f", fieldName: "MAKER__QI3__attr_f" },
313
+ { name: "QI4", description: "Fraction of exons that overlap EST or Protein alignments", dtype: "f", fieldName: "MAKER__QI4__attr_f" },
314
+ // { name: "QI5", description: "Fraction of splice sites confirmed by a SNAP prediction", dtype: "f", fieldName: "MAKER__QI5__attr_f" },
315
+ // { name: "QI6", description: "Fraction of exons that overlap a SNAP prediction", dtype: "f", fieldName: "MAKER__QI6__attr_f" },
316
+ { name: "QI7", description: "Number of exons in the mRNA", dtype: "i", fieldName: "MAKER__QI7__attr_i" },
317
+ { name: "QI8", description: "Length of the 3' UTR", dtype: "i", fieldName: "MAKER__QI8__attr_i" },
318
+ { name: "QI9", description: "Length of the protein sequence produced by the mRNA", dtype: "i", fieldName: "MAKER__QI9__attr_i" }
319
+ ]
323
320
  }
321
+ const statsFields = geneAttribs['MAKER transcript metrics'].map(
322
+ // (f) => `stats.field={!min=true max=true count=true mean=true stddev=true percentiles='20,40,60,80,90,95,99'}${f}`
323
+ (f) => `stats.field={!min=true max=true count=true mean=true stddev=true percentiles='10,20,30,40,50,60,70,80,90,99.99'}${f.fieldName}`
324
+ );
325
+
324
326
  const grameneGeneAttribs = createAsyncResourceBundle( {
325
327
  name: 'grameneGeneAttribs',
326
328
  actionBaseType: 'GRAMENE_GENE_ATTRIBS',
327
329
  persist: false,
328
330
  getPromise: ({store}) => {
329
- const g = store.selectGrameneGenomes();
330
- const taxa = Object.keys(g.active);
331
- let fq='';
332
- if (taxa.length) {
333
- console.log('search add a fq for ',taxa);
334
- fq = `&fq=taxon_id:(${taxa.join(' OR ')})`;
335
- }
336
- return fetch(`${store.selectGrameneAPI()}/search?q=${store.selectGrameneFiltersQueryString()}&json.facet=${JSON.stringify(attribFacets)}&rows=0${fq}`)
337
- // return fetch(`${store.selectGrameneAPI()}/search?rows=1&q=MAKER_AED_attr_f:*&json.facet=${JSON.stringify(attribFacets)}`)
338
- .then(res => res.json())
339
- .then(res => {
340
- return res.facets
341
- })
331
+ return fetch(`${store.selectGrameneAPI()}/geneAttributes`).then(res => geneAttribs)
342
332
  }
343
333
  });
344
334
  grameneGeneAttribs.reactGrameneGeneAttribs = createSelector(
@@ -369,7 +359,7 @@ const grameneSearch = createAsyncResourceBundle({
369
359
  console.log('search add a fq for ',taxa);
370
360
  fq = `&fq=taxon_id:(${taxa.join(' OR ')})`;
371
361
  }
372
- return fetch(`${store.selectGrameneAPI()}/search?q=${store.selectGrameneFiltersQueryString()}&facet.field=${facets}&rows=${rows}&start=${offset}${fq}`)
362
+ return fetch(`${store.selectGrameneAPI()}/search?q=${store.selectGrameneFiltersQueryString()}&facet.field=${facets}&rows=${rows}&start=${offset}${fq}&stats=true&${statsFields.join('&')}`)
373
363
  .then(res => res.json())
374
364
  .then(res => {
375
365
  res.response.docs.forEach(d => {
@@ -511,11 +501,11 @@ const grameneParalogs = {
511
501
  dispatch({type: 'GRAMENE_PARALOGS_REQUESTED', payload: geneId});
512
502
  const API = store.selectGrameneAPI();
513
503
  const q= supertree ? `supertree_attr_s:${supertree}` : `homology__within_species_paralog:${geneId}`;
514
- fetch(`${API}/search?q=${q}&rows=100&fq=taxon_id:${taxon_id}`)
504
+ fetch(`${API}/search?q=${q}&rows=1000&fq=taxon_id:${taxon_id}`)
515
505
  .then(res => res.json())
516
506
  .then(res => {
517
507
  let newParalogs = {};
518
- newParalogs[geneId] = res.response.docs.map(d => d.id);
508
+ newParalogs[geneId] = res.response.numFound > 0 ? res.response.docs.map(d => d.id) : [geneId];
519
509
  dispatch({type: 'GRAMENE_PARALOGS_RECEIVED', payload: newParalogs})
520
510
  })
521
511
  }
@@ -578,4 +568,4 @@ const grameneParalogs = {
578
568
  // });
579
569
 
580
570
 
581
- export default [grameneSuggestions, grameneSearch, grameneMaps, grameneTaxonomy, grameneTaxDist, grameneOrthologs, grameneParalogs, curatedGenes, grameneGermplasm, grameneGeneAttribs, expressionSamples, expressionStudies];
571
+ export default [grameneSuggestions, grameneSearch, grameneGeneAttribs, grameneMaps, grameneTaxonomy, grameneTaxDist, grameneOrthologs, grameneParalogs, curatedGenes, grameneGermplasm, expressionSamples, expressionStudies];
@@ -126,6 +126,7 @@ const grameneDocs = {
126
126
  else {
127
127
  newState.desiredSamples[payload] = {status: 'need'}
128
128
  }
129
+ return newState;
129
130
  }
130
131
  return state;
131
132
  }
@@ -37,7 +37,7 @@ const grameneViews = {
37
37
  {
38
38
  id: 'attribs',
39
39
  name: 'Gene attributes',
40
- show: 'disabled',
40
+ show: 'off',
41
41
  shouldScroll: false
42
42
  }
43
43
  // {
@@ -2,6 +2,7 @@ import React, { useState, Suspense } from 'react'
2
2
  import {connect} from "redux-bundler-react";
3
3
  import { Accordion, Button } from 'react-bootstrap';
4
4
  import "./expression.css";
5
+ import Study from './Study';
5
6
  const LazyStudy = React.lazy(() => import('./Study'));
6
7
 
7
8
  const StudyList = props => {
@@ -11,10 +12,10 @@ const StudyList = props => {
11
12
  <Accordion.Item key={idx} eventKey={'study_'+idx}>
12
13
  <Accordion.Header>{study.description}</Accordion.Header>
13
14
  <Accordion.Body>
14
- <Suspense fallback={<div>Loading...</div>}>
15
- <LazyStudy id={study._id}/>
16
- </Suspense>
17
- {/*<Study id={study._id} />*/}
15
+ {/*<Suspense fallback={<div>Loading...</div>}>*/}
16
+ {/* <LazyStudy id={study._id}/>*/}
17
+ {/*</Suspense>*/}
18
+ <Study id={study._id}/>
18
19
  </Accordion.Body>
19
20
  </Accordion.Item>
20
21
  )
@@ -26,24 +27,37 @@ const Expression = props => {
26
27
  let searchTaxa = {};
27
28
  if (props.grameneSearch) {
28
29
  const taxon_id_facet = props.grameneSearch.facet_counts.facet_fields.taxon_id;
29
- taxon_id_facet.filter((tid, idx) => idx % 2 === 0).forEach(tid => searchTaxa[tid] = true);
30
+ taxon_id_facet.filter((tid, idx) => idx % 2 === 0).forEach(tid => {
31
+ if (tid > 1000000) {
32
+ const tid2 = Math.floor(tid / 1000);
33
+ if (props.expressionStudies[tid2]) {
34
+ searchTaxa[tid2] = tid;
35
+ }
36
+ }
37
+ else {
38
+ searchTaxa[tid] = tid;
39
+ }
40
+ });
30
41
  }
31
42
  const availableTaxa = Object.keys(props.expressionStudies)
32
- .filter(tid => searchTaxa[tid] || searchTaxa[tid + '001'])
33
- .sort((a,b) => props.grameneMaps[a + '001'].left_index - props.grameneMaps[b + '001'].left_index);
43
+ .filter(tid => searchTaxa[tid])
44
+ .sort((a,b) => props.grameneMaps[searchTaxa[a]].left_index - props.grameneMaps[searchTaxa[b]].left_index);
34
45
  return availableTaxa && props.grameneTaxonomy &&
35
46
  <div>
36
47
  <div>This is where you can launch a component for the selected samples. props.desiredSamples lists them.
37
48
  This component can request the data from the API
38
49
  organize samples by factor metadata? One big table with all the studies?
39
- <Button>Show Samples ({Object.keys(props.desiredSamples).length} selected)</Button>
50
+ <Button
51
+ onClick={console.log(Object.keys(props.desiredSamples))}
52
+ >Show Samples ({Object.keys(props.desiredSamples).length} selected)</Button>
40
53
  </div>
41
54
  <Accordion alwaysOpen defaultActiveKey={availableTaxa.length === 1 ? "tax_0" : undefined}>
42
55
  {availableTaxa.map((tid, idx) => {
43
- const n = props.expressionStudies[tid].length;
56
+ const baselineStudies = props.expressionStudies[tid].filter(study => study.type === 'Baseline');
57
+ const n = baselineStudies.length;
44
58
  return <Accordion.Item key={idx} eventKey={'tax_'+idx}>
45
59
  <Accordion.Header>{props.grameneTaxonomy[tid].name} - {n} {n === 1 ? 'study' : 'studies'}</Accordion.Header>
46
- <Accordion.Body><StudyList studies={props.expressionStudies[tid]}/></Accordion.Body>
60
+ <Accordion.Body><StudyList studies={baselineStudies}/></Accordion.Body>
47
61
  </Accordion.Item>
48
62
  })}
49
63
  </Accordion>
@@ -1,6 +1,9 @@
1
- import React from 'react'
2
1
  import { connect } from 'redux-bundler-react'
3
2
 
3
+ import React, { useEffect, useMemo, useState } from "react";
4
+ import StatsByGroup from "./StatsByGroup";
5
+
6
+
4
7
  class GeneAttribs extends React.Component {
5
8
  constructor(props) {
6
9
  super(props);
@@ -8,15 +11,17 @@ class GeneAttribs extends React.Component {
8
11
  };
9
12
  }
10
13
  render() {
11
- return (
12
- <div className="gramene-attribs">
13
- {this.props.grameneGeneAttribs && <pre>hi</pre>}
14
- </div>
15
- );
14
+ if (this.props.grameneGeneAttribs && this.props.grameneSearch) {
15
+ const groups = this.props.grameneGeneAttribs;
16
+ const stats = this.props.grameneSearch.stats;
17
+ return <StatsByGroup groups={groups} stats={stats} />;
18
+ }
19
+ return null;
16
20
  }
17
21
  }
18
22
 
19
23
  export default connect(
24
+ 'selectGrameneSearch',
20
25
  'selectGrameneGeneAttribs',
21
26
  GeneAttribs
22
27
  );
@@ -0,0 +1,387 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ /**
4
+ * StatsByGroup
5
+ *
6
+ * Props:
7
+ * - groups: {
8
+ * [groupName: string]: Array<{
9
+ * name: string,
10
+ * description: string,
11
+ * dtype: "i" | "f",
12
+ * fieldName: string
13
+ * }>
14
+ * }
15
+ * - stats: {
16
+ * stats_fields: {
17
+ * [fieldName: string]: {
18
+ * min?: number,
19
+ * max?: number,
20
+ * count?: number,
21
+ * mean?: number,
22
+ * stddev?: number,
23
+ * percentiles?: (string | number)[] // ["10.0", v10, "20.0", v20, ...] (order preserved)
24
+ * }
25
+ * }
26
+ * }
27
+ *
28
+ * Columns: attr, desc, mean, stddev, min, max, count, dist
29
+ */
30
+ export default function StatsByGroup({ groups, stats }) {
31
+ const fields = stats?.stats_fields || {};
32
+
33
+ return (
34
+ <div style={{ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", lineHeight: 1.35 }}>
35
+ {Object.entries(groups).map(([groupName, attrs]) => (
36
+ <GroupTable
37
+ key={groupName}
38
+ groupName={groupName}
39
+ attributes={attrs}
40
+ fields={fields}
41
+ />
42
+ ))}
43
+ </div>
44
+ );
45
+ }
46
+
47
+ function GroupTable({ groupName, attributes, fields }) {
48
+ const hasAny = attributes?.some((a) => fields[a.fieldName]);
49
+
50
+ const columns = useMemo(
51
+ () => [
52
+ { key: "attr", label: "Attribute", min: 120 },
53
+ { key: "desc", label: "Description", min: 380, flex: true },
54
+ { key: "mean", label: "Mean", min: 100 },
55
+ { key: "stddev", label: "Std Dev", min: 110 },
56
+ { key: "min", label: "Min", min: 90 },
57
+ { key: "max", label: "Max", min: 90 },
58
+ { key: "count", label: "Count", min: 90 },
59
+ { key: "dist", label: "Distribution",min: 220 },
60
+ ],
61
+ []
62
+ );
63
+
64
+ const initial = useMemo(
65
+ () => columns.map((c) => (c.flex ? Math.max(380, c.min) : c.min)),
66
+ [columns]
67
+ );
68
+
69
+ const { colWidths, startResize, resizing } = useResizableColumns({
70
+ initialWidths: initial,
71
+ minWidths: columns.map((c) => c.min || 60),
72
+ });
73
+
74
+ const nfInt = useMemo(() => new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }), []);
75
+ const nfFloat = useMemo(
76
+ () => new Intl.NumberFormat("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 3 }),
77
+ []
78
+ );
79
+
80
+ return (
81
+ <section style={{ marginBottom: 24 }}>
82
+ <header style={{ display: "flex", alignItems: "baseline", gap: 12, marginBottom: 8 }}>
83
+ <h2 style={{ margin: 0, fontSize: 18 }}>{groupName}</h2>
84
+ {!hasAny && <span style={{ fontSize: 12, color: "#666" }}>No stats available for this group.</span>}
85
+ </header>
86
+
87
+ <div
88
+ style={{
89
+ overflowX: "auto",
90
+ border: "1px solid #eee",
91
+ borderRadius: 8,
92
+ boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
93
+ }}
94
+ >
95
+ <table style={tableStyle}>
96
+ <colgroup>
97
+ {colWidths.map((w, i) => (
98
+ <col key={i} style={{ width: w }} />
99
+ ))}
100
+ </colgroup>
101
+
102
+ <thead>
103
+ <tr>
104
+ {columns.map((col, i) => (
105
+ <ResizableTh
106
+ key={col.key}
107
+ label={col.label}
108
+ index={i}
109
+ onResizeStart={startResize}
110
+ />
111
+ ))}
112
+ </tr>
113
+ </thead>
114
+
115
+ <tbody>
116
+ {attributes.map((a) => {
117
+ const s = fields[a.fieldName] || {};
118
+ return (
119
+ <tr key={a.fieldName}>
120
+ <Td mono title={a.fieldName}>{a.name}</Td>
121
+ <Td title={a.description}>{a.description}</Td>
122
+ <Td align="right">{fmtValue(s.mean, a.dtype, true, nfInt, nfFloat)}</Td>
123
+ <Td align="right">{fmtValue(s.stddev, a.dtype, true, nfInt, nfFloat)}</Td>
124
+ <Td align="right">{fmtValue(s.min, a.dtype, false, nfInt, nfFloat)}</Td>
125
+ <Td align="right">{fmtValue(s.max, a.dtype, false, nfInt, nfFloat)}</Td>
126
+ <Td align="right">{fmtCount(s.count, nfInt)}</Td>
127
+ <Td>
128
+ <PercentileHeatmapNormalized
129
+ percentilesArr={s.percentiles}
130
+ min={s.min}
131
+ max={s.max}
132
+ />
133
+ </Td>
134
+ </tr>
135
+ );
136
+ })}
137
+ </tbody>
138
+ </table>
139
+ </div>
140
+
141
+ <div style={{ marginTop: 6, fontSize: 12, color: "#666" }}>
142
+ <em>Tip:</em> Drag the divider at the right edge of any header to resize.
143
+ {resizing && <span style={{ marginLeft: 8, color: "#999" }}>(resizing…)</span>}
144
+ </div>
145
+ </section>
146
+ );
147
+ }
148
+
149
+ /* ===================== Percentiles / Heatmap ===================== */
150
+
151
+ /**
152
+ * Convert alternating array into ordered pairs: [["10.0", v10], ["20.0", v20], ...]
153
+ * - Preserves the exact order provided by Solr
154
+ */
155
+ function percentilesArrayToPairs(arr) {
156
+ if (!Array.isArray(arr)) return [];
157
+ const out = [];
158
+ for (let i = 0; i < arr.length - 1; i += 2) {
159
+ out.push([String(arr[i]), arr[i + 1]]);
160
+ }
161
+ return out;
162
+ }
163
+
164
+ function normalizeBetween(value, min, max) {
165
+ if (!isFinite(min) || !isFinite(max) || max <= min || !isFinite(value)) return null;
166
+ return (value - min) / (max - min);
167
+ }
168
+
169
+ // White -> Blue (0 -> 1)
170
+ function rgbWhiteToBlue(vNorm) {
171
+ const t = clamp01(vNorm);
172
+ const rg = Math.round(255 * (1 - t));
173
+ return `rgb(${rg}, ${rg}, 255)`;
174
+ }
175
+ function clamp01(x) {
176
+ if (typeof x !== "number" || !isFinite(x)) return 0;
177
+ return Math.max(0, Math.min(1, x));
178
+ }
179
+ function fmtFloat(v) {
180
+ if (v == null || !isFinite(v)) return "n/a";
181
+ return v.toFixed(3);
182
+ }
183
+
184
+ /**
185
+ * PercentileHeatmapNormalized
186
+ * - Infers bins from percentilesArr (order preserved)
187
+ * - Colors normalized by row min/max
188
+ */
189
+ function PercentileHeatmapNormalized({ percentilesArr, min, max }) {
190
+ const pairs = useMemo(() => percentilesArrayToPairs(percentilesArr), [percentilesArr]);
191
+ const n = pairs.length || 1; // avoid zero columns
192
+ const validScale = isFinite(min) && isFinite(max) && max > min;
193
+
194
+ return (
195
+ <div style={{ ...heatRowDynamic, gridTemplateColumns: `repeat(${n}, 1fr)` }}>
196
+ {pairs.map(([pStr, raw], idx) => {
197
+ const rawNum = typeof raw === "number" && isFinite(raw) ? raw : null;
198
+ const norm = rawNum == null ? null : (validScale ? normalizeBetween(rawNum, min, max) : null);
199
+ const bg = norm == null ? "#f2f2f2" : rgbWhiteToBlue(norm);
200
+ const label = `${pStr} → raw ${fmtFloat(rawNum)} (norm ${fmtFloat(norm)})`;
201
+ return <HeatCell key={`${pStr}-${idx}`} background={bg} label={label} />;
202
+ })}
203
+ </div>
204
+ );
205
+ }
206
+
207
+ function HeatCell({ background, label }) {
208
+ const [hover, setHover] = useState(false);
209
+ return (
210
+ <div
211
+ style={{ ...heatCell, background }}
212
+ onMouseEnter={() => setHover(true)}
213
+ onMouseLeave={() => setHover(false)}
214
+ title={label}
215
+ >
216
+ {hover && <span style={popover}>{label}</span>}
217
+ </div>
218
+ );
219
+ }
220
+
221
+ /* ===================== Resizable columns ===================== */
222
+
223
+ function useResizableColumns({ initialWidths, minWidths }) {
224
+ const [colWidths, setColWidths] = useState(initialWidths);
225
+ const [resizing, setResizing] = useState(false);
226
+ const activeRef = useRef(null); // { index, startX, startW }
227
+
228
+ useEffect(() => {
229
+ if (initialWidths?.length) setColWidths(initialWidths);
230
+ }, [initialWidths?.length]); // eslint-disable-line react-hooks/exhaustive-deps
231
+
232
+ const onMouseMove = (e) => {
233
+ if (!activeRef.current) return;
234
+ const { index, startX, startW } = activeRef.current;
235
+ const dx = e.clientX - startX;
236
+ const next = [...colWidths];
237
+ const minW = minWidths?.[index] ?? 60;
238
+ next[index] = Math.max(minW, startW + dx);
239
+ setColWidths(next);
240
+ };
241
+
242
+ const endResize = () => {
243
+ activeRef.current = null;
244
+ setResizing(false);
245
+ document.body.style.cursor = "";
246
+ document.body.style.userSelect = "";
247
+ window.removeEventListener("mousemove", onMouseMove);
248
+ window.removeEventListener("mouseup", endResize);
249
+ };
250
+
251
+ const startResize = (index, ev) => {
252
+ const startX = ev.clientX;
253
+ const startW = colWidths[index];
254
+ activeRef.current = { index, startX, startW };
255
+ setResizing(true);
256
+ document.body.style.cursor = "col-resize";
257
+ document.body.style.userSelect = "none";
258
+ window.addEventListener("mousemove", onMouseMove);
259
+ window.addEventListener("mouseup", endResize);
260
+ };
261
+
262
+ useEffect(() => {
263
+ return () => {
264
+ window.removeEventListener("mousemove", onMouseMove);
265
+ window.removeEventListener("mouseup", endResize);
266
+ };
267
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
268
+
269
+ return { colWidths, setColWidths, startResize, resizing };
270
+ }
271
+
272
+ function ResizableTh({ label, index, onResizeStart }) {
273
+ return (
274
+ <th style={thStyle}>
275
+ <div style={{ position: "relative", width: "100%" }}>
276
+ <span>{label}</span>
277
+ <span
278
+ onMouseDown={(e) => onResizeStart(index, e)}
279
+ role="separator"
280
+ aria-orientation="vertical"
281
+ title="Drag to resize column"
282
+ style={resizerStyle}
283
+ />
284
+ </div>
285
+ </th>
286
+ );
287
+ }
288
+
289
+ /* ===================== Formatting helpers ===================== */
290
+
291
+ function fmtCount(v, nfInt) {
292
+ if (v === null || v === undefined) return "—";
293
+ return nfInt.format(v);
294
+ }
295
+
296
+ function fmtValue(v, dtype = "f", isStat = false, nfInt, nfFloat) {
297
+ if (v === null || v === undefined || Number.isNaN(v)) return "—";
298
+ const isIntType = dtype === "i";
299
+ return isIntType && !isStat ? nfInt.format(v) : nfFloat.format(v);
300
+ }
301
+
302
+ /* ===================== Cells & Styles ===================== */
303
+
304
+ function Td({ children, align = "left", mono = false, title }) {
305
+ return (
306
+ <td
307
+ title={title}
308
+ style={{
309
+ ...cellBase,
310
+ textAlign: align,
311
+ borderBottom: "1px solid #eee",
312
+ padding: "8px 10px",
313
+ fontFamily: mono ? "ui-monospace, SFMono-Regular, Menlo, monospace" : undefined,
314
+ whiteSpace: "nowrap",
315
+ overflow: "hidden",
316
+ textOverflow: "ellipsis",
317
+ verticalAlign: "top",
318
+ }}
319
+ >
320
+ {children}
321
+ </td>
322
+ );
323
+ }
324
+
325
+ const tableStyle = {
326
+ width: "100%",
327
+ borderCollapse: "collapse",
328
+ tableLayout: "fixed",
329
+ minWidth: 980,
330
+ };
331
+
332
+ const cellBase = {
333
+ fontSize: 13,
334
+ lineHeight: 1.35,
335
+ whiteSpace: "nowrap",
336
+ overflow: "hidden",
337
+ textOverflow: "ellipsis",
338
+ };
339
+
340
+ const thStyle = {
341
+ ...cellBase,
342
+ textAlign: "left",
343
+ borderBottom: "1px solid #ddd",
344
+ background: "#fafafa",
345
+ fontWeight: 600,
346
+ padding: "10px 10px",
347
+ position: "relative",
348
+ };
349
+
350
+ const resizerStyle = {
351
+ position: "absolute",
352
+ right: -5,
353
+ top: 0,
354
+ height: "100%",
355
+ width: 10,
356
+ cursor: "col-resize",
357
+ boxShadow: "inset -1px 0 0 rgba(0,0,0,0.08)",
358
+ };
359
+
360
+ const heatRowDynamic = {
361
+ display: "grid",
362
+ gap: 2,
363
+ alignItems: "center",
364
+ width: "100%",
365
+ minWidth: 200,
366
+ };
367
+
368
+ const heatCell = {
369
+ height: 16,
370
+ borderRadius: 3,
371
+ position: "relative",
372
+ boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06)",
373
+ };
374
+
375
+ const popover = {
376
+ position: "absolute",
377
+ left: "50%",
378
+ top: -26,
379
+ transform: "translateX(-50%)",
380
+ padding: "2px 6px",
381
+ fontSize: 11,
382
+ background: "rgba(0,0,0,0.8)",
383
+ color: "#fff",
384
+ borderRadius: 4,
385
+ whiteSpace: "nowrap",
386
+ pointerEvents: "none",
387
+ };