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/.parcel-cache/83e7562660f7cc15-BundleGraph +0 -0
- package/.parcel-cache/9a0d07555444f4da-AssetGraph +0 -0
- package/.parcel-cache/d3a1b9507cb44047-AssetGraph +0 -0
- package/.parcel-cache/data.mdb +0 -0
- package/.parcel-cache/dc1da35000e13623-RequestGraph +0 -0
- package/.parcel-cache/lock.mdb +0 -0
- package/.parcel-cache/snapshot-dc1da35000e13623.txt +2 -2
- package/dist/index.js +754 -211
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/bundles/api.js +39 -49
- package/src/bundles/docs.js +1 -0
- package/src/bundles/views.js +1 -1
- package/src/components/results/Expression.js +24 -10
- package/src/components/results/GeneAttribs.js +11 -6
- package/src/components/results/StatsByGroup.js +387 -0
- package/src/components/results/Study.js +17 -3
- package/src/components/results/UserGeneLists.js +35 -24
- package/src/components/results/details/Expression.js +2 -2
- package/src/rice.html +1 -1
- package/dist/Study.826d318c.js +0 -137
- package/dist/Study.826d318c.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gramene-search",
|
|
3
|
-
"version": "1.6.
|
|
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",
|
package/src/bundles/api.js
CHANGED
|
@@ -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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
305
|
-
"
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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=
|
|
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,
|
|
571
|
+
export default [grameneSuggestions, grameneSearch, grameneGeneAttribs, grameneMaps, grameneTaxonomy, grameneTaxDist, grameneOrthologs, grameneParalogs, curatedGenes, grameneGermplasm, expressionSamples, expressionStudies];
|
package/src/bundles/docs.js
CHANGED
package/src/bundles/views.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 =>
|
|
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]
|
|
33
|
-
.sort((a,b) => props.grameneMaps[a
|
|
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
|
|
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
|
|
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={
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
};
|