gramene-search 2.0.4 → 2.0.6
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/.claude/settings.local.json +4 -1
- package/.parcel-cache/83e7562660f7cc15-BundleGraph +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.css +174 -57
- package/dist/index.css.map +1 -1
- package/dist/index.js +710 -252
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/bundles/api.js +33 -26
- package/src/bundles/exporter.js +4 -1
- package/src/bundles/swaggerFields.js +18 -6
- package/src/bundles/views.js +12 -0
- package/src/components/exporter/expressionResolver.js +2 -2
- package/src/components/exprViz/ExprTable.js +257 -101
- package/src/components/exprViz/ExprVizView.js +151 -7
- package/src/components/exprViz/FieldsModal.js +45 -18
- package/src/components/exprViz/ParallelCoordsPlot.js +99 -6
- package/src/components/exprViz/styles.css +211 -56
- package/src/fieldCatalog.overlay.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import React, { useEffect, useMemo, useState } from 'react';
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { connect } from 'redux-bundler-react';
|
|
3
3
|
import { Tabs, Tab, Button, ToggleButton, ToggleButtonGroup } from 'react-bootstrap';
|
|
4
4
|
import FieldsModal from './FieldsModal';
|
|
5
|
-
import ExprTable from './ExprTable';
|
|
5
|
+
import ExprTable, { buildFieldInfo } from './ExprTable';
|
|
6
6
|
import ParallelCoordsPlot from './ParallelCoordsPlot';
|
|
7
7
|
import './styles.css';
|
|
8
8
|
|
|
@@ -101,6 +101,72 @@ const ExprVizViewCmp = props => {
|
|
|
101
101
|
);
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
+
// Compact axis labels for the parallel-coords plot. The raw Solr field name
|
|
105
|
+
// (e.g. "E_CURD_148_g5__expr") is uninformative; we prefer the assay's
|
|
106
|
+
// factor labels, falling back to "organism part" then to the group id.
|
|
107
|
+
// `full` is exposed via an SVG <title> so the user can hover to see study,
|
|
108
|
+
// group, and every factor/characteristic.
|
|
109
|
+
const AXIS_LABEL_MAX = 22;
|
|
110
|
+
|
|
111
|
+
function truncateLabel(s, n) {
|
|
112
|
+
if (!s) return '';
|
|
113
|
+
return s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function compactAssayLabel(assay, group) {
|
|
117
|
+
if (!assay) return group || '';
|
|
118
|
+
const factorVals = (assay.factor || []).map(f => f && f.label).filter(Boolean);
|
|
119
|
+
if (factorVals.length) return factorVals.join('; ');
|
|
120
|
+
const chars = assay.characteristic || [];
|
|
121
|
+
const organ = chars.find(c => c && c.type === 'organism part');
|
|
122
|
+
if (organ && organ.label) return organ.label;
|
|
123
|
+
const firstChar = chars.find(c => c && c.label);
|
|
124
|
+
if (firstChar) return firstChar.label;
|
|
125
|
+
return group || '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function assayPairs(list) {
|
|
129
|
+
return (list || [])
|
|
130
|
+
.filter(x => x && x.label)
|
|
131
|
+
.map(x => ({ name: x.type || '', value: x.label }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildAxisLabels(fields, studies, expressionSamples) {
|
|
135
|
+
const labels = {};
|
|
136
|
+
if (!fields) return labels;
|
|
137
|
+
const studyById = {};
|
|
138
|
+
(studies || []).forEach(s => { if (s && s._id) studyById[s._id] = s; });
|
|
139
|
+
const findAssay = (studyId, group) => {
|
|
140
|
+
const arr = expressionSamples && expressionSamples[studyId];
|
|
141
|
+
return arr ? arr.find(a => a.group === group) : null;
|
|
142
|
+
};
|
|
143
|
+
for (const f of fields) {
|
|
144
|
+
const m = f.match(/^(.+?)_g(\d+)__expr$/);
|
|
145
|
+
if (!m) {
|
|
146
|
+
labels[f] = {
|
|
147
|
+
short: truncateLabel(f.replace(/__expr$/, ''), AXIS_LABEL_MAX),
|
|
148
|
+
structured: { studyTitle: f, group: '', factors: [], characteristics: [] }
|
|
149
|
+
};
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const expId = m[1].replace(/_/g, '-');
|
|
153
|
+
const group = 'g' + m[2];
|
|
154
|
+
const assay = findAssay(expId, group);
|
|
155
|
+
const study = studyById[expId];
|
|
156
|
+
const studyName = (study && study.description) || expId;
|
|
157
|
+
labels[f] = {
|
|
158
|
+
short: truncateLabel(compactAssayLabel(assay, group), AXIS_LABEL_MAX),
|
|
159
|
+
structured: {
|
|
160
|
+
studyTitle: studyName,
|
|
161
|
+
group,
|
|
162
|
+
factors: assayPairs(assay && assay.factor),
|
|
163
|
+
characteristics: assayPairs(assay && assay.characteristic)
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return labels;
|
|
168
|
+
}
|
|
169
|
+
|
|
104
170
|
function rowMatchesSelections(row, selections) {
|
|
105
171
|
for (const f of Object.keys(selections)) {
|
|
106
172
|
const v = row[f];
|
|
@@ -127,11 +193,49 @@ function tsvCell(v) {
|
|
|
127
193
|
return s.replace(/[\t\r\n]+/g, ' ');
|
|
128
194
|
}
|
|
129
195
|
|
|
130
|
-
|
|
196
|
+
// Mirror the on-screen table header in the TSV: one row per metadata level
|
|
197
|
+
// the table is showing (Study, then one row per distinct factor type, then
|
|
198
|
+
// one row per distinct characteristic type), followed by the leaf header
|
|
199
|
+
// (Gene ID / Name / per-sample group). The first two columns are repurposed
|
|
200
|
+
// to carry the row category and the row's specific name, matching the
|
|
201
|
+
// pinned-column labels in ExprTable.
|
|
202
|
+
function downloadTsv(filename, rows, fields, studies, expressionSamples) {
|
|
131
203
|
const cols = ['id', 'name', ...fields];
|
|
132
|
-
const
|
|
133
|
-
|
|
204
|
+
const fieldInfo = buildFieldInfo(fields, studies, expressionSamples);
|
|
205
|
+
|
|
206
|
+
const factorTypes = new Set();
|
|
207
|
+
const charTypes = new Set();
|
|
208
|
+
for (const f of fields) {
|
|
209
|
+
const info = fieldInfo[f];
|
|
210
|
+
if (!info) continue;
|
|
211
|
+
Object.keys(info.factors || {}).forEach(t => factorTypes.add(t));
|
|
212
|
+
Object.keys(info.characteristics || {}).forEach(t => charTypes.add(t));
|
|
213
|
+
}
|
|
214
|
+
const factorTypeList = Array.from(factorTypes).sort();
|
|
215
|
+
const charTypeList = Array.from(charTypes).sort();
|
|
216
|
+
|
|
217
|
+
const metaRow = (cat, label, getValue) => {
|
|
218
|
+
const cells = [cat, label];
|
|
219
|
+
for (const f of fields) cells.push(tsvCell(getValue(fieldInfo[f] || {})));
|
|
220
|
+
return cells.join('\t');
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const lines = [];
|
|
224
|
+
lines.push(metaRow('Study', 'Title', info => info.studyDescription || ''));
|
|
225
|
+
for (const t of factorTypeList) {
|
|
226
|
+
lines.push(metaRow('Factor', t, info => (info.factors && info.factors[t]) || ''));
|
|
227
|
+
}
|
|
228
|
+
for (const t of charTypeList) {
|
|
229
|
+
lines.push(metaRow('Characteristic', t, info => (info.characteristics && info.characteristics[t]) || ''));
|
|
230
|
+
}
|
|
231
|
+
// Leaf header — column ids for the data rows.
|
|
232
|
+
lines.push(['Gene ID', 'Name', ...fields.map(f => {
|
|
233
|
+
const info = fieldInfo[f];
|
|
234
|
+
return (info && info.group) || f.replace(/__expr$/, '');
|
|
235
|
+
})].join('\t'));
|
|
236
|
+
|
|
134
237
|
for (const r of rows) lines.push(cols.map(c => tsvCell(r[c])).join('\t'));
|
|
238
|
+
|
|
135
239
|
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/tab-separated-values' });
|
|
136
240
|
const url = URL.createObjectURL(blob);
|
|
137
241
|
const a = document.createElement('a');
|
|
@@ -151,6 +255,32 @@ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields,
|
|
|
151
255
|
const [selections, setSelections] = useState({});
|
|
152
256
|
const [clearVersion, setClearVersion] = useState(0);
|
|
153
257
|
const [hoveredId, setHoveredId] = useState(null);
|
|
258
|
+
const [plotHeight, setPlotHeight] = useState(320);
|
|
259
|
+
const resizeStateRef = useRef(null);
|
|
260
|
+
|
|
261
|
+
// Drag the horizontal separator between the plot and the table to retune
|
|
262
|
+
// their relative sizes. Bounded so neither pane disappears entirely.
|
|
263
|
+
const startResize = (e) => {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
resizeStateRef.current = { startY: e.clientY, startHeight: plotHeight };
|
|
266
|
+
const onMove = (ev) => {
|
|
267
|
+
const s = resizeStateRef.current;
|
|
268
|
+
if (!s) return;
|
|
269
|
+
const next = Math.max(120, Math.min(1200, s.startHeight + (ev.clientY - s.startY)));
|
|
270
|
+
setPlotHeight(next);
|
|
271
|
+
};
|
|
272
|
+
const onUp = () => {
|
|
273
|
+
resizeStateRef.current = null;
|
|
274
|
+
document.removeEventListener('mousemove', onMove);
|
|
275
|
+
document.removeEventListener('mouseup', onUp);
|
|
276
|
+
document.body.style.cursor = '';
|
|
277
|
+
document.body.style.userSelect = '';
|
|
278
|
+
};
|
|
279
|
+
document.body.style.cursor = 'row-resize';
|
|
280
|
+
document.body.style.userSelect = 'none';
|
|
281
|
+
document.addEventListener('mousemove', onMove);
|
|
282
|
+
document.addEventListener('mouseup', onUp);
|
|
283
|
+
};
|
|
154
284
|
|
|
155
285
|
const hasBrush = Object.keys(selections).length > 0;
|
|
156
286
|
const filteredRows = useMemo(() => {
|
|
@@ -177,6 +307,11 @@ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields,
|
|
|
177
307
|
}
|
|
178
308
|
: undefined;
|
|
179
309
|
|
|
310
|
+
const axisLabels = useMemo(
|
|
311
|
+
() => buildAxisLabels(visibleFields, studies, expressionSamples),
|
|
312
|
+
[visibleFields, studies, expressionSamples]
|
|
313
|
+
);
|
|
314
|
+
|
|
180
315
|
useEffect(() => {
|
|
181
316
|
if (rows.length === 0 && hasBrush) {
|
|
182
317
|
setSelections({});
|
|
@@ -220,7 +355,7 @@ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields,
|
|
|
220
355
|
size="sm"
|
|
221
356
|
variant="outline-secondary"
|
|
222
357
|
disabled={filteredRows.length === 0 || visibleFields.length === 0}
|
|
223
|
-
onClick={() => downloadTsv(`expression_${taxon}.tsv`, filteredRows, visibleFields)}
|
|
358
|
+
onClick={() => downloadTsv(`expression_${taxon}.tsv`, filteredRows, visibleFields, studies, expressionSamples)}
|
|
224
359
|
title="Download the visible rows and columns as tab-delimited text"
|
|
225
360
|
>
|
|
226
361
|
Download TSV
|
|
@@ -252,7 +387,7 @@ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields,
|
|
|
252
387
|
</span>
|
|
253
388
|
</div>
|
|
254
389
|
<div className="exprviz-body">
|
|
255
|
-
<div className="exprviz-plot">
|
|
390
|
+
<div className="exprviz-plot" style={{ height: plotHeight }}>
|
|
256
391
|
<ParallelCoordsPlot
|
|
257
392
|
rows={rows}
|
|
258
393
|
fields={visibleFields}
|
|
@@ -261,8 +396,17 @@ const TaxonPanel = ({ taxon, studies, expressionSamples, tabState, onOpenFields,
|
|
|
261
396
|
onReorder={handleReorder}
|
|
262
397
|
clearVersion={clearVersion}
|
|
263
398
|
hoveredId={hoveredId}
|
|
399
|
+
axisLabels={axisLabels}
|
|
264
400
|
/>
|
|
265
401
|
</div>
|
|
402
|
+
<div
|
|
403
|
+
className="exprviz-resizer"
|
|
404
|
+
role="separator"
|
|
405
|
+
aria-orientation="horizontal"
|
|
406
|
+
aria-label="Resize plot"
|
|
407
|
+
onMouseDown={startResize}
|
|
408
|
+
title="Drag to resize"
|
|
409
|
+
/>
|
|
266
410
|
<div className="exprviz-table">
|
|
267
411
|
<ExprTable
|
|
268
412
|
rows={filteredRows}
|
|
@@ -202,6 +202,22 @@ const FieldsModalCmp = props => {
|
|
|
202
202
|
return m;
|
|
203
203
|
}, [records, selections]);
|
|
204
204
|
|
|
205
|
+
// Distinct values per property type across the unfiltered candidate set.
|
|
206
|
+
// A type with a single value across every record can't discriminate (e.g.
|
|
207
|
+
// "Organism" when every field belongs to the same species), so we hide it
|
|
208
|
+
// from the property tree.
|
|
209
|
+
const totalValueCountByKey = useMemo(() => {
|
|
210
|
+
const m = {};
|
|
211
|
+
for (const r of records) {
|
|
212
|
+
for (const [k, v] of Object.entries(r.props)) {
|
|
213
|
+
if (v == null) continue;
|
|
214
|
+
if (!m[k]) m[k] = new Set();
|
|
215
|
+
m[k].add(v);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return m;
|
|
219
|
+
}, [records]);
|
|
220
|
+
|
|
205
221
|
// Reset state when modal (re)opens for a taxon.
|
|
206
222
|
useEffect(() => {
|
|
207
223
|
if (open && taxon) {
|
|
@@ -314,6 +330,13 @@ const FieldsModalCmp = props => {
|
|
|
314
330
|
/>
|
|
315
331
|
{propTree.map(grp => {
|
|
316
332
|
const typeRows = grp.types.map(t => {
|
|
333
|
+
// Hide property types that can't discriminate among the
|
|
334
|
+
// candidates: zero values, or a single value shared by every
|
|
335
|
+
// record. The selection-aware count (valueSetByKey) is what
|
|
336
|
+
// we display; the unfiltered count gates visibility so a type
|
|
337
|
+
// doesn't pop in/out as the user clicks values.
|
|
338
|
+
const totalDistinct = (totalValueCountByKey[t.key] && totalValueCountByKey[t.key].size) || 0;
|
|
339
|
+
if (totalDistinct < 2) return null;
|
|
317
340
|
const numValues = (valueSetByKey[t.key] && valueSetByKey[t.key].size) || 0;
|
|
318
341
|
if (numValues === 0) return null;
|
|
319
342
|
const typeLabelMatches = isSearching && t.label.toLowerCase().includes(searchLc);
|
|
@@ -360,25 +383,29 @@ const FieldsModalCmp = props => {
|
|
|
360
383
|
})}
|
|
361
384
|
</div>
|
|
362
385
|
<div className="exprviz-fields-preview">
|
|
363
|
-
|
|
364
|
-
<
|
|
365
|
-
<
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
<tr key={f.fieldName}>
|
|
373
|
-
<td title={f.fieldName}>{f.fieldName.replace(/__expr$/, '')}</td>
|
|
374
|
-
{orderedSelectedKeys.map(k => <td key={k}>{f.props[k] || ''}</td>)}
|
|
386
|
+
{orderedSelectedKeys.length === 0 ? (
|
|
387
|
+
<div className="exprviz-fields-placeholder">
|
|
388
|
+
<em>Select a property type to preview the property values for each matching field.</em>
|
|
389
|
+
</div>
|
|
390
|
+
) : (
|
|
391
|
+
<table className="exprviz-fields-table">
|
|
392
|
+
<thead>
|
|
393
|
+
<tr>
|
|
394
|
+
{orderedSelectedKeys.map(k => <th key={k}>{labelForKey(k, propTree)}</th>)}
|
|
375
395
|
</tr>
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
396
|
+
</thead>
|
|
397
|
+
<tbody>
|
|
398
|
+
{matchingFields.map(f => (
|
|
399
|
+
<tr key={f.fieldName} title={f.fieldName}>
|
|
400
|
+
{orderedSelectedKeys.map(k => <td key={k}>{f.props[k] || ''}</td>)}
|
|
401
|
+
</tr>
|
|
402
|
+
))}
|
|
403
|
+
{matchingFields.length === 0 && (
|
|
404
|
+
<tr><td colSpan={orderedSelectedKeys.length}><em>No matching fields</em></td></tr>
|
|
405
|
+
)}
|
|
406
|
+
</tbody>
|
|
407
|
+
</table>
|
|
408
|
+
)}
|
|
382
409
|
</div>
|
|
383
410
|
</div>
|
|
384
411
|
</Modal.Body>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useRef } from 'react';
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import * as d3 from 'd3';
|
|
3
3
|
|
|
4
4
|
// Parallel-coordinates plot with per-axis brushing and drag-to-reorder axes.
|
|
@@ -62,13 +62,38 @@ const ParallelCoordsPlot = ({
|
|
|
62
62
|
onBrushChange,
|
|
63
63
|
onReorder,
|
|
64
64
|
clearVersion = 0,
|
|
65
|
-
hoveredId = null
|
|
65
|
+
hoveredId = null,
|
|
66
|
+
axisLabels = null
|
|
66
67
|
}) => {
|
|
67
68
|
const svgRef = useRef(null);
|
|
68
69
|
const containerRef = useRef(null);
|
|
69
70
|
// selections in data domain: { [field]: [lo, hi] }
|
|
70
71
|
const selectionsRef = useRef({});
|
|
71
72
|
const lastClearRef = useRef(0);
|
|
73
|
+
// Track container size so the d3 render reruns when the user drags the
|
|
74
|
+
// pane resizer (or when the window is resized). The values themselves
|
|
75
|
+
// aren't read inside the effect — the effect always reads clientWidth/
|
|
76
|
+
// clientHeight — but listing them in the deps array is what triggers it.
|
|
77
|
+
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
78
|
+
// Custom HTML tooltip for axis labels — gives us bold labels and structured
|
|
79
|
+
// sections, which the native SVG <title> can't do.
|
|
80
|
+
const [tooltip, setTooltip] = useState(null);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const el = containerRef.current;
|
|
84
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
85
|
+
const ro = new ResizeObserver((entries) => {
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const { width, height } = entry.contentRect;
|
|
88
|
+
setSize((prev) => {
|
|
89
|
+
if (Math.abs(prev.w - width) < 1 && Math.abs(prev.h - height) < 1) return prev;
|
|
90
|
+
return { w: width, h: height };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
ro.observe(el);
|
|
95
|
+
return () => ro.disconnect();
|
|
96
|
+
}, []);
|
|
72
97
|
|
|
73
98
|
useEffect(() => {
|
|
74
99
|
if (clearVersion !== lastClearRef.current) {
|
|
@@ -185,7 +210,22 @@ const ParallelCoordsPlot = ({
|
|
|
185
210
|
}
|
|
186
211
|
ax.call(axisGen);
|
|
187
212
|
|
|
188
|
-
|
|
213
|
+
// Compact axis label. Hovering the label or its drag-handle rect shows
|
|
214
|
+
// a custom HTML tooltip (rendered outside the SVG by React) that can
|
|
215
|
+
// include bold labels and section headings.
|
|
216
|
+
const labelInfo = (axisLabels && axisLabels[f])
|
|
217
|
+
|| { short: f.replace(/__expr$/, ''), structured: { studyTitle: f, group: '', factors: [], characteristics: [] } };
|
|
218
|
+
const showTip = (event) => setTooltip({
|
|
219
|
+
x: event.clientX,
|
|
220
|
+
y: event.clientY,
|
|
221
|
+
info: labelInfo.structured
|
|
222
|
+
});
|
|
223
|
+
const moveTip = (event) => setTooltip(t =>
|
|
224
|
+
t ? { ...t, x: event.clientX, y: event.clientY } : null
|
|
225
|
+
);
|
|
226
|
+
const hideTip = () => setTooltip(null);
|
|
227
|
+
|
|
228
|
+
ax.append('text')
|
|
189
229
|
.attr('class', 'exprviz-pc-axis-label')
|
|
190
230
|
.attr('x', 4).attr('y', -4)
|
|
191
231
|
.attr('text-anchor', 'start')
|
|
@@ -193,7 +233,10 @@ const ParallelCoordsPlot = ({
|
|
|
193
233
|
.attr('fill', '#333')
|
|
194
234
|
.style('font-size', '10px')
|
|
195
235
|
.style('cursor', 'grab')
|
|
196
|
-
.text(
|
|
236
|
+
.text(labelInfo.short)
|
|
237
|
+
.on('mouseenter', showTip)
|
|
238
|
+
.on('mousemove', moveTip)
|
|
239
|
+
.on('mouseleave', hideTip);
|
|
197
240
|
|
|
198
241
|
// hit area for grabbing — sits along the rotated label
|
|
199
242
|
ax.append('rect')
|
|
@@ -202,7 +245,10 @@ const ParallelCoordsPlot = ({
|
|
|
202
245
|
.attr('width', 140).attr('height', 14)
|
|
203
246
|
.attr('transform', `rotate(${LABEL_ROTATION}, 0, -4)`)
|
|
204
247
|
.attr('fill', 'transparent')
|
|
205
|
-
.style('cursor', 'grab')
|
|
248
|
+
.style('cursor', 'grab')
|
|
249
|
+
.on('mouseenter', showTip)
|
|
250
|
+
.on('mousemove', moveTip)
|
|
251
|
+
.on('mouseleave', hideTip);
|
|
206
252
|
|
|
207
253
|
const brush = d3.brushY()
|
|
208
254
|
.extent([[-BRUSH_WIDTH / 2, 0], [BRUSH_WIDTH / 2, innerH]])
|
|
@@ -284,7 +330,7 @@ const ParallelCoordsPlot = ({
|
|
|
284
330
|
});
|
|
285
331
|
|
|
286
332
|
axisG.selectAll('.exprviz-pc-axis-label, .exprviz-pc-axis-handle').call(drag);
|
|
287
|
-
}, [rows, fields, scale, onBrushChange, onReorder, clearVersion]);
|
|
333
|
+
}, [rows, fields, scale, onBrushChange, onReorder, clearVersion, axisLabels, size.w, size.h]);
|
|
288
334
|
|
|
289
335
|
// Highlight the polyline matching the hovered row id without rebuilding the
|
|
290
336
|
// SVG. Raises the highlighted path so it draws above its neighbors.
|
|
@@ -307,6 +353,53 @@ const ParallelCoordsPlot = ({
|
|
|
307
353
|
return (
|
|
308
354
|
<div ref={containerRef} className="exprviz-pc-container">
|
|
309
355
|
<svg ref={svgRef} width="100%" height="100%" preserveAspectRatio="none"/>
|
|
356
|
+
{tooltip && <AxisTooltip x={tooltip.x} y={tooltip.y} info={tooltip.info}/>}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// Position-fixed so it can escape the plot pane's clipping. Offset slightly
|
|
362
|
+
// from the cursor and clamped to the viewport so it never spills off-screen.
|
|
363
|
+
const AxisTooltip = ({ x, y, info }) => {
|
|
364
|
+
const ref = useRef(null);
|
|
365
|
+
const [pos, setPos] = useState({ left: x + 12, top: y + 12 });
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
const el = ref.current;
|
|
368
|
+
if (!el) return;
|
|
369
|
+
const w = el.offsetWidth;
|
|
370
|
+
const h = el.offsetHeight;
|
|
371
|
+
const vw = window.innerWidth;
|
|
372
|
+
const vh = window.innerHeight;
|
|
373
|
+
let left = x + 12;
|
|
374
|
+
let top = y + 12;
|
|
375
|
+
if (left + w > vw - 4) left = Math.max(4, x - 12 - w);
|
|
376
|
+
if (top + h > vh - 4) top = Math.max(4, y - 12 - h);
|
|
377
|
+
setPos({ left, top });
|
|
378
|
+
}, [x, y, info]);
|
|
379
|
+
const { studyTitle, group, factors, characteristics } = info;
|
|
380
|
+
return (
|
|
381
|
+
<div ref={ref} className="exprviz-pc-tooltip" style={pos}>
|
|
382
|
+
<div><span className="exprviz-pc-tip-key">Study:</span> {studyTitle}{group ? ` (${group})` : ''}</div>
|
|
383
|
+
{factors.length > 0 && (
|
|
384
|
+
<>
|
|
385
|
+
<div className="exprviz-pc-tip-section">Factors</div>
|
|
386
|
+
{factors.map((p, i) => (
|
|
387
|
+
<div key={`f-${i}`} className="exprviz-pc-tip-row">
|
|
388
|
+
<span className="exprviz-pc-tip-key">{p.name}:</span> {p.value}
|
|
389
|
+
</div>
|
|
390
|
+
))}
|
|
391
|
+
</>
|
|
392
|
+
)}
|
|
393
|
+
{characteristics.length > 0 && (
|
|
394
|
+
<>
|
|
395
|
+
<div className="exprviz-pc-tip-section">Characteristics</div>
|
|
396
|
+
{characteristics.map((p, i) => (
|
|
397
|
+
<div key={`c-${i}`} className="exprviz-pc-tip-row">
|
|
398
|
+
<span className="exprviz-pc-tip-key">{p.name}:</span> {p.value}
|
|
399
|
+
</div>
|
|
400
|
+
))}
|
|
401
|
+
</>
|
|
402
|
+
)}
|
|
310
403
|
</div>
|
|
311
404
|
);
|
|
312
405
|
};
|