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.
- package/.claude/settings.local.json +8 -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 +431 -0
- package/dist/index.css.map +1 -1
- package/dist/index.js +3555 -1119
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/bundles/api.js +33 -26
- package/src/bundles/exporter.js +4 -1
- package/src/bundles/exprViz.js +422 -0
- package/src/bundles/index.js +2 -1
- package/src/bundles/swaggerFields.js +13 -0
- package/src/bundles/views.js +30 -12
- package/src/components/exprViz/ExprTable.js +339 -0
- package/src/components/exprViz/ExprVizView.js +400 -0
- package/src/components/exprViz/FieldsModal.js +441 -0
- package/src/components/exprViz/ParallelCoordsPlot.js +407 -0
- package/src/components/exprViz/styles.css +471 -0
- package/src/components/geneSearchUI.js +2 -0
|
@@ -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;
|