gramene-search 2.0.2 → 2.0.4

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.
@@ -0,0 +1,314 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import * as d3 from 'd3';
3
+
4
+ // Parallel-coordinates plot with per-axis brushing and drag-to-reorder axes.
5
+ // - Axis labels are draggable horizontally; on drop, onReorder(newOrder) fires.
6
+ // - Brushes on axes intersect (AND): a row is "in" only when every active brush contains it.
7
+ // - Numeric fields use a linear or symlog scale; non-numeric values are skipped.
8
+
9
+ const MARGIN = { top: 100, right: 24, bottom: 24, left: 32 };
10
+ const LABEL_ROTATION = -40;
11
+ const BRUSH_WIDTH = 16;
12
+
13
+ function isNumeric(v) {
14
+ if (v == null) return false;
15
+ if (Array.isArray(v)) return false;
16
+ const n = +v;
17
+ return Number.isFinite(n);
18
+ }
19
+
20
+ function arraysEqual(a, b) {
21
+ if (a.length !== b.length) return false;
22
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
23
+ return true;
24
+ }
25
+
26
+ // Powers-of-10 tick values spanning [lo, hi]; includes 0 if the range crosses zero.
27
+ // Clamped to |v| >= 0.1 to keep low-magnitude tick labels from overlapping near 0.
28
+ const MIN_LOG_TICK = 0.1;
29
+ function logTickValues([lo, hi]) {
30
+ const ticks = new Set();
31
+ if (lo <= 0 && hi >= 0) ticks.add(0);
32
+ if (hi >= MIN_LOG_TICK) {
33
+ const start = Math.max(-1, Math.floor(Math.log10(lo > 0 ? lo : MIN_LOG_TICK)));
34
+ const end = Math.ceil(Math.log10(hi));
35
+ for (let p = start; p <= end; p++) {
36
+ const v = Math.pow(10, p);
37
+ if (v >= MIN_LOG_TICK && v <= hi) ticks.add(v);
38
+ }
39
+ }
40
+ if (lo <= -MIN_LOG_TICK) {
41
+ const start = Math.max(-1, Math.floor(Math.log10(hi < 0 ? -hi : MIN_LOG_TICK)));
42
+ const end = Math.ceil(Math.log10(-lo));
43
+ for (let p = start; p <= end; p++) {
44
+ const v = -Math.pow(10, p);
45
+ if (-v >= MIN_LOG_TICK && v >= lo) ticks.add(v);
46
+ }
47
+ }
48
+ return Array.from(ticks).sort((a, b) => a - b);
49
+ }
50
+
51
+ function logTickFormat(v) {
52
+ if (v === 0) return '0';
53
+ const a = Math.abs(v);
54
+ if (a >= 0.01 && a < 10000) return d3.format('~g')(v);
55
+ return d3.format('.0e')(v);
56
+ }
57
+
58
+ const ParallelCoordsPlot = ({
59
+ rows,
60
+ fields,
61
+ scale = 'linear',
62
+ onBrushChange,
63
+ onReorder,
64
+ clearVersion = 0,
65
+ hoveredId = null
66
+ }) => {
67
+ const svgRef = useRef(null);
68
+ const containerRef = useRef(null);
69
+ // selections in data domain: { [field]: [lo, hi] }
70
+ const selectionsRef = useRef({});
71
+ const lastClearRef = useRef(0);
72
+
73
+ useEffect(() => {
74
+ if (clearVersion !== lastClearRef.current) {
75
+ selectionsRef.current = {};
76
+ lastClearRef.current = clearVersion;
77
+ if (onBrushChange) onBrushChange({});
78
+ }
79
+ Object.keys(selectionsRef.current).forEach(f => {
80
+ if (!fields || !fields.includes(f)) delete selectionsRef.current[f];
81
+ });
82
+
83
+ const svg = d3.select(svgRef.current);
84
+ svg.selectAll('*').remove();
85
+ if (!fields || fields.length === 0 || !rows || rows.length === 0) return;
86
+
87
+ const el = containerRef.current;
88
+ const width = (el && el.clientWidth) || 600;
89
+ const height = (el && el.clientHeight) || 300;
90
+ const innerW = width - MARGIN.left - MARGIN.right;
91
+ const innerH = height - MARGIN.top - MARGIN.bottom;
92
+
93
+ svg.attr('viewBox', `0 0 ${width} ${height}`);
94
+
95
+ const g = svg.append('g').attr('transform', `translate(${MARGIN.left},${MARGIN.top})`);
96
+
97
+ // Mutable order during drag — starts as a copy of fields.
98
+ let order = fields.slice();
99
+ const x = d3.scalePoint().range([0, innerW]).padding(0.5).domain(order);
100
+
101
+ const yByField = {};
102
+ let globalExt = null;
103
+ if (scale === 'log') {
104
+ const all = [];
105
+ fields.forEach(f => {
106
+ rows.forEach(r => {
107
+ const v = r[f];
108
+ if (isNumeric(v)) all.push(+v);
109
+ });
110
+ });
111
+ globalExt = all.length ? d3.extent(all) : [0, 1];
112
+ }
113
+ fields.forEach(f => {
114
+ if (scale === 'log') {
115
+ yByField[f] = d3.scaleSymlog().domain(globalExt).range([innerH, 0]).nice();
116
+ } else {
117
+ const vals = rows.map(r => r[f]).filter(isNumeric).map(Number);
118
+ const ext = vals.length ? d3.extent(vals) : [0, 1];
119
+ yByField[f] = d3.scaleLinear().domain(ext).range([innerH, 0]).nice();
120
+ }
121
+ });
122
+
123
+ function pathForRow(row, posOf) {
124
+ const pts = order.map(f => {
125
+ const v = row[f];
126
+ if (!isNumeric(v)) return null;
127
+ return [posOf(f), yByField[f](Number(v))];
128
+ });
129
+ return line(pts);
130
+ }
131
+
132
+ const line = d3.line()
133
+ .defined(d => d != null && Number.isFinite(d[1]))
134
+ .x(d => d[0])
135
+ .y(d => d[1]);
136
+
137
+ const linesG = g.append('g').attr('class', 'exprviz-pc-lines');
138
+ const paths = linesG
139
+ .selectAll('path')
140
+ .data(rows)
141
+ .enter()
142
+ .append('path')
143
+ .attr('fill', 'none')
144
+ .attr('stroke', 'steelblue')
145
+ .attr('stroke-width', 1)
146
+ .attr('data-id', d => d && d.id != null ? String(d.id) : null)
147
+ .attr('d', row => pathForRow(row, f => x(f)));
148
+
149
+ function isBrushedIn(row) {
150
+ for (const f of order) {
151
+ const sel = selectionsRef.current[f];
152
+ if (!sel) continue;
153
+ const v = row[f];
154
+ if (!isNumeric(v)) return false;
155
+ const n = Number(v);
156
+ const [lo, hi] = sel;
157
+ if (n < lo || n > hi) return false;
158
+ }
159
+ return true;
160
+ }
161
+
162
+ function applyBrushStyles() {
163
+ const anyActive = Object.keys(selectionsRef.current).length > 0;
164
+ paths
165
+ .classed('exprviz-pc-line-in', d => !anyActive || isBrushedIn(d))
166
+ .classed('exprviz-pc-line-out', d => anyActive && !isBrushedIn(d));
167
+ }
168
+ applyBrushStyles();
169
+
170
+ // axis groups, keyed by field name so D3 can match them across reorders
171
+ const axisG = g.selectAll('.exprviz-pc-axis')
172
+ .data(order, d => d)
173
+ .enter()
174
+ .append('g')
175
+ .attr('class', 'exprviz-pc-axis')
176
+ .attr('transform', d => `translate(${x(d)},0)`);
177
+
178
+ axisG.each(function(f) {
179
+ const ax = d3.select(this);
180
+ const axisGen = d3.axisLeft(yByField[f]);
181
+ if (scale === 'log') {
182
+ axisGen.tickValues(logTickValues(yByField[f].domain())).tickFormat(logTickFormat);
183
+ } else {
184
+ axisGen.ticks(5);
185
+ }
186
+ ax.call(axisGen);
187
+
188
+ const label = ax.append('text')
189
+ .attr('class', 'exprviz-pc-axis-label')
190
+ .attr('x', 4).attr('y', -4)
191
+ .attr('text-anchor', 'start')
192
+ .attr('transform', `rotate(${LABEL_ROTATION}, 0, -4)`)
193
+ .attr('fill', '#333')
194
+ .style('font-size', '10px')
195
+ .style('cursor', 'grab')
196
+ .text(f.replace(/__expr$/, ''));
197
+
198
+ // hit area for grabbing — sits along the rotated label
199
+ ax.append('rect')
200
+ .attr('class', 'exprviz-pc-axis-handle')
201
+ .attr('x', 0).attr('y', -11)
202
+ .attr('width', 140).attr('height', 14)
203
+ .attr('transform', `rotate(${LABEL_ROTATION}, 0, -4)`)
204
+ .attr('fill', 'transparent')
205
+ .style('cursor', 'grab');
206
+
207
+ const brush = d3.brushY()
208
+ .extent([[-BRUSH_WIDTH / 2, 0], [BRUSH_WIDTH / 2, innerH]])
209
+ .on('brush end', (event) => {
210
+ const s = event.selection;
211
+ if (!s) {
212
+ delete selectionsRef.current[f];
213
+ } else {
214
+ const y = yByField[f];
215
+ const a = y.invert(s[0]);
216
+ const b = y.invert(s[1]);
217
+ selectionsRef.current[f] = [Math.min(a, b), Math.max(a, b)];
218
+ }
219
+ applyBrushStyles();
220
+ // event.sourceEvent is null when brush.move is called programmatically
221
+ // (e.g. when this effect re-runs and we restore prior selections).
222
+ // Skipping that case avoids a re-render loop with the parent.
223
+ if (event.type === 'end' && event.sourceEvent && onBrushChange) {
224
+ onBrushChange({ ...selectionsRef.current });
225
+ }
226
+ });
227
+
228
+ const brushG = ax.append('g').attr('class', 'exprviz-pc-brush').call(brush);
229
+
230
+ const prior = selectionsRef.current[f];
231
+ if (prior) {
232
+ const y = yByField[f];
233
+ const py0 = y(prior[1]);
234
+ const py1 = y(prior[0]);
235
+ if (Number.isFinite(py0) && Number.isFinite(py1)) {
236
+ brushG.call(brush.move, [py0, py1]);
237
+ }
238
+ }
239
+ });
240
+
241
+ // Drag-to-reorder: while dragging, only the dragged axis moves and the
242
+ // line segments connecting to it are recomputed. Other axes stay put.
243
+ // The new order is computed once at drag end and emitted via onReorder.
244
+ const drag = d3.drag()
245
+ .container(function() { return g.node(); })
246
+ .subject(function(event, d) { return { x: x(d), y: 0 }; })
247
+ .on('start', function(event, d) {
248
+ const axNode = this.parentNode;
249
+ d3.select(axNode).raise().classed('exprviz-pc-axis-dragging', true);
250
+ d3.select(axNode).select('.exprviz-pc-axis-label').style('cursor', 'grabbing');
251
+ linesG.classed('exprviz-pc-lines-dragging', true);
252
+ })
253
+ .on('drag', function(event, d) {
254
+ const axNode = this.parentNode;
255
+ const newX = Math.max(0, Math.min(innerW, event.x));
256
+ d3.select(axNode).attr('transform', `translate(${newX},0)`);
257
+ paths.attr('d', row => pathForRow(row, f => f === d ? newX : x(f)));
258
+ })
259
+ .on('end', function(event, d) {
260
+ const axNode = this.parentNode;
261
+ const newX = Math.max(0, Math.min(innerW, event.x));
262
+ d3.select(axNode).classed('exprviz-pc-axis-dragging', false);
263
+ d3.select(axNode).select('.exprviz-pc-axis-label').style('cursor', 'grab');
264
+ linesG.classed('exprviz-pc-lines-dragging', false);
265
+
266
+ const newOrder = order.slice().sort((a, b) => {
267
+ const xa = a === d ? newX : x(a);
268
+ const xb = b === d ? newX : x(b);
269
+ return xa - xb;
270
+ });
271
+
272
+ if (onReorder && !arraysEqual(newOrder, fields)) {
273
+ // Snap the dragged axis to its target slot for the brief moment
274
+ // before the parent re-renders with the new order.
275
+ x.domain(newOrder);
276
+ d3.select(axNode).attr('transform', `translate(${x(d)},0)`);
277
+ paths.attr('d', row => pathForRow(row, f => x(f)));
278
+ onReorder(newOrder);
279
+ } else {
280
+ // No order change — restore the dragged axis to its original slot.
281
+ d3.select(axNode).attr('transform', `translate(${x(d)},0)`);
282
+ paths.attr('d', row => pathForRow(row, f => x(f)));
283
+ }
284
+ });
285
+
286
+ axisG.selectAll('.exprviz-pc-axis-label, .exprviz-pc-axis-handle').call(drag);
287
+ }, [rows, fields, scale, onBrushChange, onReorder, clearVersion]);
288
+
289
+ // Highlight the polyline matching the hovered row id without rebuilding the
290
+ // SVG. Raises the highlighted path so it draws above its neighbors.
291
+ useEffect(() => {
292
+ const svg = d3.select(svgRef.current);
293
+ if (svg.empty()) return;
294
+ const paths = svg.selectAll('.exprviz-pc-lines path');
295
+ paths.classed('exprviz-pc-line-hover', false);
296
+ if (hoveredId == null) return;
297
+ const target = paths.filter(function() {
298
+ return this.getAttribute('data-id') === String(hoveredId);
299
+ });
300
+ target.classed('exprviz-pc-line-hover', true).raise();
301
+ }, [hoveredId, rows, fields, scale]);
302
+
303
+ if (!fields || fields.length === 0) {
304
+ return <div className="exprviz-plot-empty"><em>Select fields to plot.</em></div>;
305
+ }
306
+
307
+ return (
308
+ <div ref={containerRef} className="exprviz-pc-container">
309
+ <svg ref={svgRef} width="100%" height="100%" preserveAspectRatio="none"/>
310
+ </div>
311
+ );
312
+ };
313
+
314
+ export default ParallelCoordsPlot;
@@ -0,0 +1,316 @@
1
+ .exprviz-view {
2
+ padding: 0.5rem;
3
+ }
4
+
5
+ .exprviz-tabs {
6
+ margin-bottom: 0.5rem;
7
+ }
8
+
9
+ .exprviz-tab-panel {
10
+ display: flex;
11
+ flex-direction: column;
12
+ gap: 0.5rem;
13
+ padding: 0.5rem 0;
14
+ }
15
+
16
+ .exprviz-toolbar {
17
+ display: flex;
18
+ align-items: center;
19
+ gap: 0.5rem;
20
+ }
21
+
22
+ .exprviz-status {
23
+ color: #666;
24
+ font-size: 0.85rem;
25
+ margin-left: 0.5rem;
26
+ }
27
+
28
+ .exprviz-warning {
29
+ color: #b06000;
30
+ background: #fff8e0;
31
+ border: 1px solid #f0d080;
32
+ border-radius: 3px;
33
+ padding: 2px 6px;
34
+ font-size: 0.8rem;
35
+ cursor: help;
36
+ }
37
+
38
+ .exprviz-body {
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 0.75rem;
42
+ }
43
+
44
+ .exprviz-plot {
45
+ height: 320px;
46
+ border: 1px solid #ddd;
47
+ background: #fff;
48
+ }
49
+
50
+ .exprviz-pc-container {
51
+ width: 100%;
52
+ height: 100%;
53
+ }
54
+
55
+ .exprviz-pc-container svg {
56
+ display: block;
57
+ }
58
+
59
+ .exprviz-pc-lines path {
60
+ fill: none;
61
+ stroke: steelblue;
62
+ }
63
+
64
+ .exprviz-pc-line-in {
65
+ stroke-opacity: 0.4;
66
+ }
67
+
68
+ .exprviz-pc-line-out {
69
+ stroke-opacity: 0.04;
70
+ }
71
+
72
+ .exprviz-pc-lines path:not(.exprviz-pc-line-in):not(.exprviz-pc-line-out) {
73
+ stroke-opacity: 0.25;
74
+ }
75
+
76
+ .exprviz-pc-lines path.exprviz-pc-line-hover {
77
+ stroke: #d62728;
78
+ stroke-opacity: 1;
79
+ stroke-width: 2;
80
+ }
81
+
82
+ .exprviz-pc-axis-dragging .domain,
83
+ .exprviz-pc-axis-dragging .tick line {
84
+ stroke: #555;
85
+ }
86
+
87
+ .exprviz-pc-axis-dragging .exprviz-pc-axis-label {
88
+ font-weight: bold;
89
+ }
90
+
91
+ .exprviz-plot-empty,
92
+ .exprviz-table-empty {
93
+ padding: 1rem;
94
+ color: #888;
95
+ }
96
+
97
+ .exprviz-table {
98
+ height: 480px;
99
+ }
100
+
101
+ .exprviz-aggrid {
102
+ width: 100%;
103
+ height: 100%;
104
+ }
105
+
106
+ .exprviz-fields-layout {
107
+ display: grid;
108
+ grid-template-columns: 320px 1fr;
109
+ gap: 0.75rem;
110
+ height: 60vh;
111
+ }
112
+
113
+ .exprviz-tree {
114
+ overflow-y: auto;
115
+ border: 1px solid #eee;
116
+ padding: 0.4rem 0.5rem;
117
+ font-size: 0.85rem;
118
+ }
119
+
120
+ .exprviz-tree-search {
121
+ width: 100%;
122
+ box-sizing: border-box;
123
+ padding: 4px 6px;
124
+ margin-bottom: 0.4rem;
125
+ border: 1px solid #ccc;
126
+ border-radius: 3px;
127
+ font-size: 0.85rem;
128
+ }
129
+
130
+ .exprviz-tree-group {
131
+ margin-bottom: 0.5rem;
132
+ }
133
+
134
+ .exprviz-tree-group-header {
135
+ cursor: pointer;
136
+ user-select: none;
137
+ padding: 2px 0;
138
+ }
139
+
140
+ .exprviz-tree-caret {
141
+ display: inline-block;
142
+ width: 1em;
143
+ color: #888;
144
+ }
145
+
146
+ .exprviz-tree-type {
147
+ margin-left: 0.5rem;
148
+ border-left: 2px solid transparent;
149
+ padding-left: 0.25rem;
150
+ }
151
+
152
+ .exprviz-tree-type.is-active {
153
+ border-left-color: #2a6ebb;
154
+ }
155
+
156
+ .exprviz-tree-type-header {
157
+ cursor: pointer;
158
+ user-select: none;
159
+ display: flex;
160
+ align-items: center;
161
+ padding: 2px 0;
162
+ }
163
+
164
+ .exprviz-tree-type-header:hover {
165
+ background: #f5f8ff;
166
+ }
167
+
168
+ .exprviz-tree-type-label {
169
+ flex: 1;
170
+ overflow: hidden;
171
+ text-overflow: ellipsis;
172
+ white-space: nowrap;
173
+ }
174
+
175
+ .exprviz-tree-type-count {
176
+ color: #888;
177
+ font-variant-numeric: tabular-nums;
178
+ margin-left: 0.5rem;
179
+ }
180
+
181
+ .exprviz-tree-values {
182
+ margin: 0.25rem 0 0.5rem 1.25rem;
183
+ padding-left: 0.25rem;
184
+ border-left: 1px dashed #e0e0e0;
185
+ }
186
+
187
+ .exprviz-values-toolbar {
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 0.5rem;
191
+ margin-bottom: 0.25rem;
192
+ }
193
+
194
+ .exprviz-tree-value {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 0.4rem;
198
+ padding: 1px 0;
199
+ cursor: pointer;
200
+ }
201
+
202
+ .exprviz-tree-value:hover {
203
+ background: #fafaff;
204
+ }
205
+
206
+ .exprviz-tree-value-label {
207
+ flex: 1;
208
+ overflow: hidden;
209
+ text-overflow: ellipsis;
210
+ white-space: nowrap;
211
+ }
212
+
213
+ .exprviz-tree-value-count {
214
+ color: #888;
215
+ font-variant-numeric: tabular-nums;
216
+ font-size: 0.8em;
217
+ }
218
+
219
+ .exprviz-tree-empty {
220
+ color: #999;
221
+ margin-left: 1rem;
222
+ }
223
+
224
+ .exprviz-fields-preview {
225
+ overflow: auto;
226
+ border: 1px solid #eee;
227
+ }
228
+
229
+ .exprviz-fields-table {
230
+ width: 100%;
231
+ border-collapse: collapse;
232
+ font-size: 0.85rem;
233
+ }
234
+
235
+ .exprviz-fields-table th,
236
+ .exprviz-fields-table td {
237
+ padding: 4px 8px;
238
+ border-bottom: 1px solid #f0f0f0;
239
+ white-space: nowrap;
240
+ text-align: left;
241
+ }
242
+
243
+ .exprviz-fields-table thead th {
244
+ position: sticky;
245
+ top: 0;
246
+ background: #fafafa;
247
+ z-index: 1;
248
+ border-bottom: 1px solid #ddd;
249
+ }
250
+
251
+ .exprviz-col-factor {
252
+ background: #eef6ff;
253
+ }
254
+
255
+ .exprviz-col-char {
256
+ background: #f5f5f0;
257
+ }
258
+
259
+ .exprviz-fields-table tbody tr:hover {
260
+ background: #fafaff;
261
+ }
262
+
263
+ .exprviz-header {
264
+ display: flex;
265
+ align-items: center;
266
+ width: 100%;
267
+ gap: 4px;
268
+ }
269
+
270
+ .exprviz-header-text {
271
+ flex: 1;
272
+ overflow: hidden;
273
+ text-overflow: ellipsis;
274
+ white-space: nowrap;
275
+ }
276
+
277
+ .exprviz-header-sort {
278
+ margin-left: 2px;
279
+ font-size: 0.8em;
280
+ color: #666;
281
+ }
282
+
283
+ .exprviz-header-info {
284
+ cursor: help;
285
+ color: #2a6ebb;
286
+ font-size: 0.95em;
287
+ flex: none;
288
+ }
289
+
290
+ .exprviz-header-menu {
291
+ cursor: pointer;
292
+ flex: none;
293
+ opacity: 0.7;
294
+ }
295
+
296
+ .exprviz-header-menu:hover {
297
+ opacity: 1;
298
+ }
299
+
300
+ .exprviz-header-popover {
301
+ max-width: 360px;
302
+ font-size: 0.85rem;
303
+ }
304
+
305
+ .exprviz-header-popover ul {
306
+ margin: 0.25rem 0 0 0;
307
+ padding-left: 1rem;
308
+ }
309
+
310
+ .exprviz-header-popover li {
311
+ margin: 0;
312
+ }
313
+
314
+ .exprviz-header-section {
315
+ margin-top: 0.4rem;
316
+ }
@@ -12,6 +12,7 @@ import TaxonomyModal from './TaxonomyModal'
12
12
  import Expression from './results/Expression'
13
13
  import UserGeneLists from './results/UserGeneLists'
14
14
  import ExporterView from './exporter/ExporterView'
15
+ import ExprVizView from './exprViz/ExprVizView'
15
16
  import Auth from './Auth'
16
17
  import ReactGA from 'react-ga4'
17
18
  import './styles.css';
@@ -40,6 +41,7 @@ const inventory = {
40
41
  taxonomy: TaxDist,
41
42
  attribs: GeneAttribs,
42
43
  expression: Expression,
44
+ exprViz: ExprVizView,
43
45
  userLists: UserGeneLists,
44
46
  export: ExporterView
45
47
  };