gramene-search 2.0.2 → 2.0.5

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,407 @@
1
+ import React, { useEffect, useRef, useState } 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
+ axisLabels = null
67
+ }) => {
68
+ const svgRef = useRef(null);
69
+ const containerRef = useRef(null);
70
+ // selections in data domain: { [field]: [lo, hi] }
71
+ const selectionsRef = useRef({});
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
+ }, []);
97
+
98
+ useEffect(() => {
99
+ if (clearVersion !== lastClearRef.current) {
100
+ selectionsRef.current = {};
101
+ lastClearRef.current = clearVersion;
102
+ if (onBrushChange) onBrushChange({});
103
+ }
104
+ Object.keys(selectionsRef.current).forEach(f => {
105
+ if (!fields || !fields.includes(f)) delete selectionsRef.current[f];
106
+ });
107
+
108
+ const svg = d3.select(svgRef.current);
109
+ svg.selectAll('*').remove();
110
+ if (!fields || fields.length === 0 || !rows || rows.length === 0) return;
111
+
112
+ const el = containerRef.current;
113
+ const width = (el && el.clientWidth) || 600;
114
+ const height = (el && el.clientHeight) || 300;
115
+ const innerW = width - MARGIN.left - MARGIN.right;
116
+ const innerH = height - MARGIN.top - MARGIN.bottom;
117
+
118
+ svg.attr('viewBox', `0 0 ${width} ${height}`);
119
+
120
+ const g = svg.append('g').attr('transform', `translate(${MARGIN.left},${MARGIN.top})`);
121
+
122
+ // Mutable order during drag — starts as a copy of fields.
123
+ let order = fields.slice();
124
+ const x = d3.scalePoint().range([0, innerW]).padding(0.5).domain(order);
125
+
126
+ const yByField = {};
127
+ let globalExt = null;
128
+ if (scale === 'log') {
129
+ const all = [];
130
+ fields.forEach(f => {
131
+ rows.forEach(r => {
132
+ const v = r[f];
133
+ if (isNumeric(v)) all.push(+v);
134
+ });
135
+ });
136
+ globalExt = all.length ? d3.extent(all) : [0, 1];
137
+ }
138
+ fields.forEach(f => {
139
+ if (scale === 'log') {
140
+ yByField[f] = d3.scaleSymlog().domain(globalExt).range([innerH, 0]).nice();
141
+ } else {
142
+ const vals = rows.map(r => r[f]).filter(isNumeric).map(Number);
143
+ const ext = vals.length ? d3.extent(vals) : [0, 1];
144
+ yByField[f] = d3.scaleLinear().domain(ext).range([innerH, 0]).nice();
145
+ }
146
+ });
147
+
148
+ function pathForRow(row, posOf) {
149
+ const pts = order.map(f => {
150
+ const v = row[f];
151
+ if (!isNumeric(v)) return null;
152
+ return [posOf(f), yByField[f](Number(v))];
153
+ });
154
+ return line(pts);
155
+ }
156
+
157
+ const line = d3.line()
158
+ .defined(d => d != null && Number.isFinite(d[1]))
159
+ .x(d => d[0])
160
+ .y(d => d[1]);
161
+
162
+ const linesG = g.append('g').attr('class', 'exprviz-pc-lines');
163
+ const paths = linesG
164
+ .selectAll('path')
165
+ .data(rows)
166
+ .enter()
167
+ .append('path')
168
+ .attr('fill', 'none')
169
+ .attr('stroke', 'steelblue')
170
+ .attr('stroke-width', 1)
171
+ .attr('data-id', d => d && d.id != null ? String(d.id) : null)
172
+ .attr('d', row => pathForRow(row, f => x(f)));
173
+
174
+ function isBrushedIn(row) {
175
+ for (const f of order) {
176
+ const sel = selectionsRef.current[f];
177
+ if (!sel) continue;
178
+ const v = row[f];
179
+ if (!isNumeric(v)) return false;
180
+ const n = Number(v);
181
+ const [lo, hi] = sel;
182
+ if (n < lo || n > hi) return false;
183
+ }
184
+ return true;
185
+ }
186
+
187
+ function applyBrushStyles() {
188
+ const anyActive = Object.keys(selectionsRef.current).length > 0;
189
+ paths
190
+ .classed('exprviz-pc-line-in', d => !anyActive || isBrushedIn(d))
191
+ .classed('exprviz-pc-line-out', d => anyActive && !isBrushedIn(d));
192
+ }
193
+ applyBrushStyles();
194
+
195
+ // axis groups, keyed by field name so D3 can match them across reorders
196
+ const axisG = g.selectAll('.exprviz-pc-axis')
197
+ .data(order, d => d)
198
+ .enter()
199
+ .append('g')
200
+ .attr('class', 'exprviz-pc-axis')
201
+ .attr('transform', d => `translate(${x(d)},0)`);
202
+
203
+ axisG.each(function(f) {
204
+ const ax = d3.select(this);
205
+ const axisGen = d3.axisLeft(yByField[f]);
206
+ if (scale === 'log') {
207
+ axisGen.tickValues(logTickValues(yByField[f].domain())).tickFormat(logTickFormat);
208
+ } else {
209
+ axisGen.ticks(5);
210
+ }
211
+ ax.call(axisGen);
212
+
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')
229
+ .attr('class', 'exprviz-pc-axis-label')
230
+ .attr('x', 4).attr('y', -4)
231
+ .attr('text-anchor', 'start')
232
+ .attr('transform', `rotate(${LABEL_ROTATION}, 0, -4)`)
233
+ .attr('fill', '#333')
234
+ .style('font-size', '10px')
235
+ .style('cursor', 'grab')
236
+ .text(labelInfo.short)
237
+ .on('mouseenter', showTip)
238
+ .on('mousemove', moveTip)
239
+ .on('mouseleave', hideTip);
240
+
241
+ // hit area for grabbing — sits along the rotated label
242
+ ax.append('rect')
243
+ .attr('class', 'exprviz-pc-axis-handle')
244
+ .attr('x', 0).attr('y', -11)
245
+ .attr('width', 140).attr('height', 14)
246
+ .attr('transform', `rotate(${LABEL_ROTATION}, 0, -4)`)
247
+ .attr('fill', 'transparent')
248
+ .style('cursor', 'grab')
249
+ .on('mouseenter', showTip)
250
+ .on('mousemove', moveTip)
251
+ .on('mouseleave', hideTip);
252
+
253
+ const brush = d3.brushY()
254
+ .extent([[-BRUSH_WIDTH / 2, 0], [BRUSH_WIDTH / 2, innerH]])
255
+ .on('brush end', (event) => {
256
+ const s = event.selection;
257
+ if (!s) {
258
+ delete selectionsRef.current[f];
259
+ } else {
260
+ const y = yByField[f];
261
+ const a = y.invert(s[0]);
262
+ const b = y.invert(s[1]);
263
+ selectionsRef.current[f] = [Math.min(a, b), Math.max(a, b)];
264
+ }
265
+ applyBrushStyles();
266
+ // event.sourceEvent is null when brush.move is called programmatically
267
+ // (e.g. when this effect re-runs and we restore prior selections).
268
+ // Skipping that case avoids a re-render loop with the parent.
269
+ if (event.type === 'end' && event.sourceEvent && onBrushChange) {
270
+ onBrushChange({ ...selectionsRef.current });
271
+ }
272
+ });
273
+
274
+ const brushG = ax.append('g').attr('class', 'exprviz-pc-brush').call(brush);
275
+
276
+ const prior = selectionsRef.current[f];
277
+ if (prior) {
278
+ const y = yByField[f];
279
+ const py0 = y(prior[1]);
280
+ const py1 = y(prior[0]);
281
+ if (Number.isFinite(py0) && Number.isFinite(py1)) {
282
+ brushG.call(brush.move, [py0, py1]);
283
+ }
284
+ }
285
+ });
286
+
287
+ // Drag-to-reorder: while dragging, only the dragged axis moves and the
288
+ // line segments connecting to it are recomputed. Other axes stay put.
289
+ // The new order is computed once at drag end and emitted via onReorder.
290
+ const drag = d3.drag()
291
+ .container(function() { return g.node(); })
292
+ .subject(function(event, d) { return { x: x(d), y: 0 }; })
293
+ .on('start', function(event, d) {
294
+ const axNode = this.parentNode;
295
+ d3.select(axNode).raise().classed('exprviz-pc-axis-dragging', true);
296
+ d3.select(axNode).select('.exprviz-pc-axis-label').style('cursor', 'grabbing');
297
+ linesG.classed('exprviz-pc-lines-dragging', true);
298
+ })
299
+ .on('drag', function(event, d) {
300
+ const axNode = this.parentNode;
301
+ const newX = Math.max(0, Math.min(innerW, event.x));
302
+ d3.select(axNode).attr('transform', `translate(${newX},0)`);
303
+ paths.attr('d', row => pathForRow(row, f => f === d ? newX : x(f)));
304
+ })
305
+ .on('end', function(event, d) {
306
+ const axNode = this.parentNode;
307
+ const newX = Math.max(0, Math.min(innerW, event.x));
308
+ d3.select(axNode).classed('exprviz-pc-axis-dragging', false);
309
+ d3.select(axNode).select('.exprviz-pc-axis-label').style('cursor', 'grab');
310
+ linesG.classed('exprviz-pc-lines-dragging', false);
311
+
312
+ const newOrder = order.slice().sort((a, b) => {
313
+ const xa = a === d ? newX : x(a);
314
+ const xb = b === d ? newX : x(b);
315
+ return xa - xb;
316
+ });
317
+
318
+ if (onReorder && !arraysEqual(newOrder, fields)) {
319
+ // Snap the dragged axis to its target slot for the brief moment
320
+ // before the parent re-renders with the new order.
321
+ x.domain(newOrder);
322
+ d3.select(axNode).attr('transform', `translate(${x(d)},0)`);
323
+ paths.attr('d', row => pathForRow(row, f => x(f)));
324
+ onReorder(newOrder);
325
+ } else {
326
+ // No order change — restore the dragged axis to its original slot.
327
+ d3.select(axNode).attr('transform', `translate(${x(d)},0)`);
328
+ paths.attr('d', row => pathForRow(row, f => x(f)));
329
+ }
330
+ });
331
+
332
+ axisG.selectAll('.exprviz-pc-axis-label, .exprviz-pc-axis-handle').call(drag);
333
+ }, [rows, fields, scale, onBrushChange, onReorder, clearVersion, axisLabels, size.w, size.h]);
334
+
335
+ // Highlight the polyline matching the hovered row id without rebuilding the
336
+ // SVG. Raises the highlighted path so it draws above its neighbors.
337
+ useEffect(() => {
338
+ const svg = d3.select(svgRef.current);
339
+ if (svg.empty()) return;
340
+ const paths = svg.selectAll('.exprviz-pc-lines path');
341
+ paths.classed('exprviz-pc-line-hover', false);
342
+ if (hoveredId == null) return;
343
+ const target = paths.filter(function() {
344
+ return this.getAttribute('data-id') === String(hoveredId);
345
+ });
346
+ target.classed('exprviz-pc-line-hover', true).raise();
347
+ }, [hoveredId, rows, fields, scale]);
348
+
349
+ if (!fields || fields.length === 0) {
350
+ return <div className="exprviz-plot-empty"><em>Select fields to plot.</em></div>;
351
+ }
352
+
353
+ return (
354
+ <div ref={containerRef} className="exprviz-pc-container">
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
+ )}
403
+ </div>
404
+ );
405
+ };
406
+
407
+ export default ParallelCoordsPlot;