Skip to content

Commit 4234f72

Browse files
committed
feat: expand historical run plotting controls
Add grouping and data-source controls to the data explorer correlogram and make the plot builder resize with its container so historical run exploration is easier. Made-with: Cursor
1 parent d088072 commit 4234f72

2 files changed

Lines changed: 241 additions & 92 deletions

File tree

flexfoil-ui/src/components/panels/DataExplorerPanel.tsx

Lines changed: 209 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,16 @@ import {
2222
} from 'ag-grid-community';
2323
import Plot from 'react-plotly.js';
2424
import { useRunStore } from '../../stores/runStore';
25-
import { useRouteUiStore } from '../../stores/routeUiStore';
25+
import { AUTO_GROUP_KEY, useRouteUiStore } from '../../stores/routeUiStore';
2626
import { useTheme } from '../../contexts/ThemeContext';
27-
import type { RunRow } from '../../types';
27+
import type { DataSource, RunRow } from '../../types';
2828
import {
2929
ENCODING_PLOT_FIELDS,
3030
NUMERIC_PLOT_FIELDS,
3131
getPlotFieldLabel,
3232
isNumericPlotField,
3333
} from '../../lib/plotFields';
34+
import { detectSmartRunGroups } from '../../lib/polarDetection';
3435
import { buildMarkerEncoding, colorForKey } from '../../lib/plotStyling';
3536

3637
// ────────────────────────────────────────────────────────────
@@ -75,6 +76,31 @@ function computeNumericRange(rows: RunRow[], key: keyof RunRow): [number, number
7576
return [min - pad, max + pad];
7677
}
7778

79+
function buildRunHoverDetails(run: RunRow): Array<string | number> {
80+
return [
81+
run.airfoil_name,
82+
run.alpha,
83+
run.reynolds,
84+
run.mach,
85+
run.ncrit,
86+
run.n_panels,
87+
run.max_iter,
88+
run.solver_mode,
89+
];
90+
}
91+
92+
const RUN_HOVER_TEMPLATE = [
93+
'Run #%{meta}',
94+
'Airfoil=%{customdata[0]}',
95+
'Alpha=%{customdata[1]}',
96+
'Re=%{customdata[2]}',
97+
'Mach=%{customdata[3]}',
98+
'Ncrit=%{customdata[4]}',
99+
'Panels=%{customdata[5]}',
100+
'MaxIter=%{customdata[6]}',
101+
'Solver=%{customdata[7]}',
102+
].join('<br>');
103+
78104
// ────────────────────────────────────────────────────────────
79105
// AG Grid column definitions
80106
// ────────────────────────────────────────────────────────────
@@ -115,7 +141,16 @@ type GridFilterModel = ReturnType<GridApi<RunRow>['getFilterModel']>;
115141

116142
export function DataExplorerPanel() {
117143
const { isDark } = useTheme();
118-
const { allRuns, setFilteredRuns, clearAll, exportDb, importDb, restoreRunById, selectedRunId } = useRunStore();
144+
const {
145+
allRuns,
146+
filteredRuns,
147+
setFilteredRuns,
148+
clearAll,
149+
exportDb,
150+
importDb,
151+
restoreRunById,
152+
selectedRunId,
153+
} = useRunStore();
119154

120155
const view = useRouteUiStore((state) => state.dataExplorerView);
121156
const setView = useRouteUiStore((state) => state.setDataExplorerView);
@@ -125,6 +160,8 @@ export function DataExplorerPanel() {
125160
const setColorBy = useRouteUiStore((state) => state.setDataExplorerColorBy);
126161
const filterModel = useRouteUiStore((state) => state.dataExplorerFilterModel);
127162
const setFilterModel = useRouteUiStore((state) => state.setDataExplorerFilterModel);
163+
const [dataSource, setDataSource] = useState<DataSource>('full');
164+
const [groupBy, setGroupBy] = useState<keyof RunRow | '' | typeof AUTO_GROUP_KEY>(AUTO_GROUP_KEY);
128165
const [sizeBy, setSizeBy] = useState<keyof RunRow | ''>('');
129166
const [symbolBy, setSymbolBy] = useState<keyof RunRow | ''>('');
130167

@@ -159,7 +196,8 @@ export function DataExplorerPanel() {
159196
}, [setFilterModel, setFilteredRuns]);
160197

161198
// ── Correlogram ──
162-
const successOnly = useMemo(() => allRuns.filter(r => r.success), [allRuns]);
199+
const data = dataSource === 'full' ? allRuns : filteredRuns;
200+
const successOnly = useMemo(() => data.filter((r) => r.success), [data]);
163201

164202
const toggleSplomKey = useCallback((key: keyof RunRow) => {
165203
if (splomKeys.includes(key)) {
@@ -174,6 +212,41 @@ export function DataExplorerPanel() {
174212
apiRef.current.setFilterModel((filterModel as GridFilterModel) ?? null);
175213
}, [filterModel]);
176214

215+
const groupedRows = useMemo(() => {
216+
if (successOnly.length === 0) return [];
217+
if (groupBy === AUTO_GROUP_KEY) {
218+
return detectSmartRunGroups(successOnly, {
219+
sortField: 'alpha',
220+
plottedFields: splomKeys,
221+
encodingFields: [colorBy, sizeBy, symbolBy],
222+
});
223+
}
224+
if (!groupBy) {
225+
return [{
226+
key: 'all',
227+
label: `All (${successOnly.length})`,
228+
rows: successOnly,
229+
isSinglePoint: successOnly.length === 1,
230+
}];
231+
}
232+
233+
const manualGroups = new Map<string, RunRow[]>();
234+
for (const row of successOnly) {
235+
const key = String(row[groupBy] ?? 'null');
236+
if (!manualGroups.has(key)) manualGroups.set(key, []);
237+
manualGroups.get(key)!.push(row);
238+
}
239+
240+
return [...manualGroups.entries()]
241+
.sort(([a], [b]) => a.localeCompare(b))
242+
.map(([key, rows]) => ({
243+
key: `${String(groupBy)}=${key}`,
244+
label: `${getLabel(groupBy)}: ${key}`,
245+
rows,
246+
isSinglePoint: rows.length === 1,
247+
}));
248+
}, [successOnly, groupBy, splomKeys, colorBy, sizeBy, symbolBy]);
249+
177250
const splomResult = useMemo(() => {
178251
if (successOnly.length === 0 || splomKeys.length < 2) return null;
179252

@@ -233,50 +306,62 @@ export function DataExplorerPanel() {
233306
};
234307

235308
if (row === col) {
236-
const rows = successOnly.filter((run) => run[xKey] != null);
309+
groupedRows.forEach((group) => {
310+
const rows = group.rows.filter((run) => run[xKey] != null);
311+
if (rows.length === 0) return;
312+
traces.push({
313+
type: 'histogram',
314+
x: rows.map((run) => run[xKey]) as number[],
315+
xaxis: xRef,
316+
yaxis: yRef,
317+
name: group.label,
318+
legendgroup: group.key,
319+
marker: { color: colorForKey(group.key || String(xKey)), opacity: groupedRows.length > 1 ? 0.55 : 0.85 },
320+
showlegend: row === 0 && col === 0 && groupedRows.length > 1,
321+
hovertemplate: `${getLabel(xKey)}=%{x}<br>Count=%{y}<extra>${group.label}</extra>`,
322+
});
323+
});
324+
continue;
325+
}
326+
327+
const cellRows = successOnly.filter((run) => run[xKey] != null && run[yKey] != null);
328+
groupedRows.forEach((group, groupIndex) => {
329+
const rows = group.rows.filter((run) => run[xKey] != null && run[yKey] != null);
330+
if (rows.length === 0) return;
331+
const { marker } = buildMarkerEncoding({
332+
rows,
333+
colorBy,
334+
sizeBy,
335+
symbolBy,
336+
defaultColor: colorForKey(group.key || `${String(xKey)}|${String(yKey)}`),
337+
defaultSize: 5,
338+
opacity: 0.65,
339+
minSize: 3,
340+
maxSize: 11,
341+
showColorScale: !colorScaleShown && groupIndex === 0,
342+
});
343+
if (colorBy && isNumericPlotField(colorBy) && !colorScaleShown && groupIndex === 0) {
344+
colorScaleShown = true;
345+
}
237346
traces.push({
238-
type: 'histogram',
347+
type: 'scatter',
348+
mode: 'markers',
239349
x: rows.map((run) => run[xKey]) as number[],
350+
y: rows.map((run) => run[yKey]) as number[],
240351
xaxis: xRef,
241352
yaxis: yRef,
242-
marker: { color: colorForKey(String(xKey)), opacity: 0.85 },
353+
name: group.label,
354+
legendgroup: group.key,
355+
marker,
356+
meta: rows.map((run) => run.id),
357+
customdata: rows.map(buildRunHoverDetails),
243358
showlegend: false,
244-
hovertemplate: `${getLabel(xKey)}=%{x}<br>Count=%{y}<extra></extra>`,
359+
hovertemplate: `${getLabel(xKey)}=%{x}<br>${getLabel(yKey)}=%{y}<br>${RUN_HOVER_TEMPLATE}<extra>${group.label}</extra>`,
245360
});
246-
continue;
247-
}
248-
249-
const rows = successOnly.filter((run) => run[xKey] != null && run[yKey] != null);
250-
const { marker } = buildMarkerEncoding({
251-
rows,
252-
colorBy,
253-
sizeBy,
254-
symbolBy,
255-
defaultColor: colorForKey(`${String(xKey)}|${String(yKey)}`),
256-
defaultSize: 5,
257-
opacity: 0.65,
258-
minSize: 3,
259-
maxSize: 11,
260-
showColorScale: !colorScaleShown,
261-
});
262-
if (colorBy && isNumericPlotField(colorBy) && !colorScaleShown) {
263-
colorScaleShown = true;
264-
}
265-
traces.push({
266-
type: 'scatter',
267-
mode: 'markers',
268-
x: rows.map((run) => run[xKey]) as number[],
269-
y: rows.map((run) => run[yKey]) as number[],
270-
xaxis: xRef,
271-
yaxis: yRef,
272-
marker,
273-
customdata: rows.map((run) => run.id),
274-
showlegend: false,
275-
hovertemplate: `${getLabel(xKey)}=%{x}<br>${getLabel(yKey)}=%{y}<br>Run #%{customdata}<extra></extra>`,
276361
});
277362

278-
const xValues = rows.map((run) => run[xKey] as number);
279-
const yValues = rows.map((run) => run[yKey] as number);
363+
const xValues = cellRows.map((run) => run[xKey] as number);
364+
const yValues = cellRows.map((run) => run[yKey] as number);
280365
const r = pearsonR(xValues, yValues);
281366
if (r != null) {
282367
annotations.push({
@@ -296,15 +381,18 @@ export function DataExplorerPanel() {
296381

297382
const layout: Partial<Plotly.Layout> = {
298383
autosize: true,
299-
margin: { l: 80, r: 30, t: 20, b: 60 },
384+
margin: { l: 80, r: 30, t: 20, b: groupedRows.length > 1 ? 90 : 60 },
300385
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
301386
font: { color: isDark ? '#ccc' : '#333', size: 10 },
302-
showlegend: false, dragmode: 'select' as const,
387+
showlegend: groupedRows.length > 1,
388+
legend: { orientation: 'h', y: -0.12 },
389+
barmode: groupedRows.length > 1 ? 'overlay' : undefined,
390+
dragmode: 'select' as const,
303391
annotations, ...axisOverrides,
304392
};
305393

306394
return { traces, layout };
307-
}, [successOnly, splomKeys, colorBy, sizeBy, symbolBy, isDark]);
395+
}, [successOnly, splomKeys, colorBy, sizeBy, symbolBy, isDark, groupedRows]);
308396

309397
const handlePlotClick = useCallback((event: Readonly<Plotly.PlotMouseEvent>) => {
310398
const rawRunId = event.points?.[0]?.customdata;
@@ -332,6 +420,16 @@ export function DataExplorerPanel() {
332420
background: active ? (isDark ? 'rgba(0,212,170,0.15)' : 'rgba(0,212,170,0.1)') : 'transparent',
333421
color: active ? colorForKey('accent-active') : 'var(--text-secondary)', cursor: 'pointer', whiteSpace: 'nowrap',
334422
});
423+
const selectStyle: React.CSSProperties = {
424+
padding: '3px 6px', fontSize: '11px',
425+
background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)',
426+
borderRadius: '3px', color: 'var(--text-primary)', minWidth: '110px',
427+
};
428+
const labelStyle: React.CSSProperties = {
429+
fontSize: '10px',
430+
color: 'var(--text-muted)',
431+
marginBottom: '2px',
432+
};
335433

336434
return (
337435
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
@@ -422,57 +520,78 @@ export function DataExplorerPanel() {
422520
{/* ── Correlogram view ── */}
423521
{view === 'correlogram' && (
424522
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
425-
{/* Column & color-by selectors */}
523+
{/* Column & plot selectors */}
426524
<div style={{
427525
padding: '6px 10px', borderBottom: '1px solid var(--border-color)',
428526
display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center', flexShrink: 0,
429527
}}>
430-
<span style={{ fontSize: '10px', color: 'var(--text-muted)', marginRight: '2px' }}>Columns:</span>
431-
{NUMERIC_PLOT_FIELDS.map(f => (
432-
<button key={f.key as string} onClick={() => toggleSplomKey(f.key)} style={chipStyle(splomKeys.includes(f.key))}>
433-
{f.label}
434-
</button>
435-
))}
436-
<span style={{ borderLeft: '1px solid var(--border-color)', height: '16px', margin: '0 4px' }} />
437-
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Color:</span>
438-
<select
439-
value={colorBy as string}
440-
onChange={e => setColorBy((e.target.value || '') as keyof RunRow | '')}
441-
style={{
442-
padding: '2px 6px', fontSize: '10px',
443-
background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)',
444-
borderRadius: '3px', color: 'var(--text-primary)',
445-
}}
446-
>
447-
<option value="">Color Auto</option>
448-
{ENCODING_PLOT_FIELDS.map(f => <option key={f.key as string} value={f.key as string}>{f.label}</option>)}
449-
</select>
450-
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Size:</span>
451-
<select
452-
value={sizeBy as string}
453-
onChange={e => setSizeBy((e.target.value || '') as keyof RunRow | '')}
454-
style={{
455-
padding: '2px 6px', fontSize: '10px',
456-
background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)',
457-
borderRadius: '3px', color: 'var(--text-primary)',
458-
}}
459-
>
460-
<option value="">None</option>
461-
{ENCODING_PLOT_FIELDS.map(f => <option key={f.key as string} value={f.key as string}>{f.label}</option>)}
462-
</select>
463-
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Type:</span>
464-
<select
465-
value={symbolBy as string}
466-
onChange={e => setSymbolBy((e.target.value || '') as keyof RunRow | '')}
467-
style={{
468-
padding: '2px 6px', fontSize: '10px',
469-
background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)',
470-
borderRadius: '3px', color: 'var(--text-primary)',
471-
}}
472-
>
473-
<option value="">None</option>
474-
{ENCODING_PLOT_FIELDS.map(f => <option key={f.key as string} value={f.key as string}>{f.label}</option>)}
475-
</select>
528+
<div style={{ display: 'flex', flexDirection: 'column', minWidth: '280px', flex: 1 }}>
529+
<span style={labelStyle}>Columns</span>
530+
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
531+
{NUMERIC_PLOT_FIELDS.map((f) => (
532+
<button key={f.key as string} onClick={() => toggleSplomKey(f.key)} style={chipStyle(splomKeys.includes(f.key))}>
533+
{f.label}
534+
</button>
535+
))}
536+
</div>
537+
</div>
538+
539+
<div style={{ display: 'flex', flexDirection: 'column' }}>
540+
<span style={labelStyle}>Data</span>
541+
<select value={dataSource} onChange={(e) => setDataSource(e.target.value as DataSource)} style={selectStyle}>
542+
<option value="full">All ({allRuns.length})</option>
543+
<option value="filtered">Grid Filter ({filteredRuns.length})</option>
544+
</select>
545+
</div>
546+
547+
<div style={{ display: 'flex', flexDirection: 'column' }}>
548+
<span style={labelStyle}>Group By</span>
549+
<select
550+
value={groupBy as string}
551+
onChange={(e) => setGroupBy((e.target.value || '') as keyof RunRow | '' | typeof AUTO_GROUP_KEY)}
552+
style={selectStyle}
553+
>
554+
<option value={AUTO_GROUP_KEY}>Auto (Smart)</option>
555+
<option value="">None</option>
556+
{ENCODING_PLOT_FIELDS.map((f) => <option key={f.key as string} value={f.key as string}>{f.label}</option>)}
557+
</select>
558+
</div>
559+
560+
<div style={{ display: 'flex', flexDirection: 'column' }}>
561+
<span style={labelStyle}>Color</span>
562+
<select
563+
value={colorBy as string}
564+
onChange={(e) => setColorBy((e.target.value || '') as keyof RunRow | '')}
565+
style={selectStyle}
566+
>
567+
<option value="">Color Auto</option>
568+
{ENCODING_PLOT_FIELDS.map((f) => <option key={f.key as string} value={f.key as string}>{f.label}</option>)}
569+
</select>
570+
</div>
571+
572+
<div style={{ display: 'flex', flexDirection: 'column' }}>
573+
<span style={labelStyle}>Marker Size</span>
574+
<select
575+
value={sizeBy as string}
576+
onChange={(e) => setSizeBy((e.target.value || '') as keyof RunRow | '')}
577+
style={selectStyle}
578+
>
579+
<option value="">None</option>
580+
{ENCODING_PLOT_FIELDS.map((f) => <option key={f.key as string} value={f.key as string}>{f.label}</option>)}
581+
</select>
582+
</div>
583+
584+
<div style={{ display: 'flex', flexDirection: 'column' }}>
585+
<span style={labelStyle}>Marker Type</span>
586+
<select
587+
value={symbolBy as string}
588+
onChange={(e) => setSymbolBy((e.target.value || '') as keyof RunRow | '')}
589+
style={selectStyle}
590+
>
591+
<option value="">None</option>
592+
{ENCODING_PLOT_FIELDS.map((f) => <option key={f.key as string} value={f.key as string}>{f.label}</option>)}
593+
</select>
594+
</div>
476595
</div>
477596

478597
{/* SPLOM plot */}

0 commit comments

Comments
 (0)