@@ -22,15 +22,16 @@ import {
2222} from 'ag-grid-community' ;
2323import Plot from 'react-plotly.js' ;
2424import { useRunStore } from '../../stores/runStore' ;
25- import { useRouteUiStore } from '../../stores/routeUiStore' ;
25+ import { AUTO_GROUP_KEY , useRouteUiStore } from '../../stores/routeUiStore' ;
2626import { useTheme } from '../../contexts/ThemeContext' ;
27- import type { RunRow } from '../../types' ;
27+ import type { DataSource , RunRow } from '../../types' ;
2828import {
2929 ENCODING_PLOT_FIELDS ,
3030 NUMERIC_PLOT_FIELDS ,
3131 getPlotFieldLabel ,
3232 isNumericPlotField ,
3333} from '../../lib/plotFields' ;
34+ import { detectSmartRunGroups } from '../../lib/polarDetection' ;
3435import { 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
116142export 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