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.
@@ -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
- function downloadTsv(filename, rows, fields) {
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 headerLabels = ['id', 'name', ...fields.map(f => f.replace(/__expr$/, ''))];
133
- const lines = [headerLabels.join('\t')];
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
- <table className="exprviz-fields-table">
364
- <thead>
365
- <tr>
366
- <th>Field</th>
367
- {orderedSelectedKeys.map(k => <th key={k}>{labelForKey(k, propTree)}</th>)}
368
- </tr>
369
- </thead>
370
- <tbody>
371
- {matchingFields.map(f => (
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
- {matchingFields.length === 0 && (
378
- <tr><td colSpan={1 + orderedSelectedKeys.length}><em>No matching fields</em></td></tr>
379
- )}
380
- </tbody>
381
- </table>
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
- const label = ax.append('text')
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(f.replace(/__expr$/, ''));
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
  };