Skip to content

Commit 1e42112

Browse files
committed
feat: Smart Group, aggregated data across all plots, toFixed fix
- Add Smart Group button in Data Explorer that groups by polar configuration (airfoil + Re + Mach + Ncrit + flap config) and auto-shows alpha_stall and alpha @ L/D_max summary columns - Fix toFixed crash when grouping by non-numeric columns (all 11 valueFormatters now guard with typeof check) - Add flap_deflection and flap_hinge_x columns to Data Explorer - Add Aggregated data source to Data Explorer correlogram - Add aggregated scatter overlay (diamond markers) to Polar Plot - Auto-switch correlogram to Aggregated when Smart Group activates - Persist Smart Group state and data source in routeUiStore so they survive view switches and page reloads (ephemeral + URL) - Update changelog with all new features Made-with: Cursor
1 parent 68563a4 commit 1e42112

7 files changed

Lines changed: 112 additions & 10 deletions

File tree

flexfoil-ui/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ function AppContent() {
144144
dataExplorerSplomKeys: state.dataExplorerSplomKeys,
145145
dataExplorerColorBy: state.dataExplorerColorBy,
146146
dataExplorerFilterModel: state.dataExplorerFilterModel,
147+
dataExplorerSmartGroup: state.dataExplorerSmartGroup,
148+
dataExplorerDataSource: state.dataExplorerDataSource,
147149
activePanel: state.activePanel,
148150
layoutJson: state.layoutJson,
149151
viewport: state.viewport,

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ export function DataExplorerPanel() {
217217
const setColorBy = useRouteUiStore((state) => state.setDataExplorerColorBy);
218218
const filterModel = useRouteUiStore((state) => state.dataExplorerFilterModel);
219219
const setFilterModel = useRouteUiStore((state) => state.setDataExplorerFilterModel);
220-
const [dataSource, setDataSource] = useState<DataSource>('full');
220+
const dataSource = useRouteUiStore((state) => state.dataExplorerDataSource);
221+
const setDataSource = useRouteUiStore((state) => state.setDataExplorerDataSource);
221222
const [showColumnEditor, setShowColumnEditor] = useState(false);
222223
const customColumns = useCustomColumnStore((s) => s.columns);
223224
const { numericFields: mergedNumericFields, encodingFields: mergedEncodingFields, getFieldLabel: mergedGetLabel, getFieldValue } = useAllPlotFields();
@@ -265,7 +266,11 @@ export function DataExplorerPanel() {
265266
if (filterModel) {
266267
e.api.setFilterModel(filterModel as GridFilterModel);
267268
}
268-
}, [filterModel]);
269+
if (smartGroupActive) {
270+
e.api.setRowGroupColumns(['airfoil_name', 'reynolds', 'mach', 'ncrit', 'flap_deflection', 'flap_hinge_x']);
271+
e.api.setColumnsVisible(['alpha_stall', 'alpha_ldmax'], true);
272+
}
273+
}, [filterModel, smartGroupActive]);
269274
const onFilterChanged = useCallback((e: FilterChangedEvent<RunRow>) => {
270275
const filtered: RunRow[] = [];
271276
e.api.forEachNodeAfterFilterAndSort(node => { if (node.data) filtered.push(node.data); });
@@ -336,23 +341,26 @@ export function DataExplorerPanel() {
336341
const outlierFilter = useRouteUiStore((state) => state.outlierFilterEnabled);
337342
const setOutlierFilter = useRouteUiStore((state) => state.setOutlierFilterEnabled);
338343

339-
const [smartGroupActive, setSmartGroupActive] = useState(false);
344+
const smartGroupActive = useRouteUiStore((state) => state.dataExplorerSmartGroup);
345+
const setSmartGroupActive = useRouteUiStore((state) => state.setDataExplorerSmartGroup);
340346

341347
const applySmartGroup = useCallback(() => {
342348
const api = apiRef.current;
343349
if (!api) return;
344350
api.setRowGroupColumns(['airfoil_name', 'reynolds', 'mach', 'ncrit', 'flap_deflection', 'flap_hinge_x']);
345351
api.setColumnsVisible(['alpha_stall', 'alpha_ldmax'], true);
346352
setSmartGroupActive(true);
347-
}, []);
353+
setDataSource('aggregated');
354+
}, [setSmartGroupActive, setDataSource]);
348355

349356
const clearGroups = useCallback(() => {
350357
const api = apiRef.current;
351358
if (!api) return;
352359
api.setRowGroupColumns([]);
353360
api.setColumnsVisible(['alpha_stall', 'alpha_ldmax'], false);
354361
setSmartGroupActive(false);
355-
}, []);
362+
if (dataSource === 'aggregated') setDataSource('full');
363+
}, [dataSource, setSmartGroupActive, setDataSource]);
356364

357365
// ── Correlogram ──
358366
const data = dataSource === 'aggregated' ? aggregatedRuns

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

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77

88
import { useRef, useEffect, useCallback, useMemo, useState } from 'react';
99
import { useAirfoilStore } from '../../stores/airfoilStore';
10+
import { useRunStore } from '../../stores/runStore';
1011
import { useRouteUiStore } from '../../stores/routeUiStore';
1112
import { usePolarOutlierStore } from '../../stores/polarOutlierStore';
1213
import { colorForKey } from '../../lib/plotStyling';
1314
import { buildFence, isInlier } from '../../lib/outlierFilter';
1415
import { OutlierContextMenu } from '../OutlierContextMenu';
15-
import type { AxisVariable, PolarPoint } from '../../types';
16+
import type { AxisVariable, PolarPoint, RunRow } from '../../types';
1617

1718
const PLOT_MARGIN = { top: 20, right: 20, bottom: 40, left: 50 };
1819
const HIT_RADIUS = 8;
@@ -38,6 +39,22 @@ function getValue(point: PolarPoint, variable: AxisVariable): number {
3839
return (point as Record<string, number | undefined>)[variable] ?? 0;
3940
}
4041

42+
function getRunValue(row: RunRow, axis: AxisVariable): number | null {
43+
switch (axis) {
44+
case 'alpha': return row.alpha;
45+
case 'cl': return row.cl;
46+
case 'cd': return row.cd;
47+
case 'cm': return row.cm;
48+
case 'ld': return row.ld;
49+
case 'reynolds': return row.reynolds;
50+
case 'mach': return row.mach;
51+
case 'ncrit': return row.ncrit;
52+
case 'flapDeflection': return row.flap_deflection;
53+
case 'flapHingeX': return row.flap_hinge_x;
54+
default: return null;
55+
}
56+
}
57+
4158
function getAxisBounds(values: number[]): [number, number] {
4259
if (values.length === 0) return [0, 1];
4360
const min = Math.min(...values);
@@ -78,6 +95,7 @@ interface DisplaySeries {
7895

7996
export function PolarPanel() {
8097
const { polarData, removePolar, clearAllPolars } = useAirfoilStore();
98+
const aggregatedRuns = useRunStore((s) => s.aggregatedRuns);
8199
const canvasRef = useRef<HTMLCanvasElement>(null);
82100
const containerRef = useRef<HTMLDivElement>(null);
83101

@@ -88,6 +106,7 @@ export function PolarPanel() {
88106
const outlierFilter = useRouteUiStore((state) => state.outlierFilterEnabled);
89107
const setOutlierFilter = useRouteUiStore((state) => state.setOutlierFilterEnabled);
90108
const [canvasSize, setCanvasSize] = useState({ width: 400, height: 300 });
109+
const [showAggregated, setShowAggregated] = useState(false);
91110

92111
const outlierRevision = usePolarOutlierStore((s) => s.revision);
93112
const toggleFlag = usePolarOutlierStore((s) => s.toggleFlag);
@@ -141,6 +160,18 @@ export function PolarPanel() {
141160
);
142161
const removedCount = rawTotalPoints - totalVisible;
143162

163+
const aggOverlayPoints = useMemo(() => {
164+
if (!showAggregated || aggregatedRuns.length === 0) return [];
165+
return aggregatedRuns
166+
.map((row) => {
167+
const x = getRunValue(row, xAxis);
168+
const y = getRunValue(row, yAxis);
169+
if (x == null || y == null) return null;
170+
return { x, y, label: row.airfoil_name };
171+
})
172+
.filter((p): p is { x: number; y: number; label: string } => p !== null);
173+
}, [showAggregated, aggregatedRuns, xAxis, yAxis]);
174+
144175
const { xBounds, yBounds } = useMemo(() => {
145176
const allX: number[] = [];
146177
const allY: number[] = [];
@@ -150,11 +181,15 @@ export function PolarPanel() {
150181
allY.push(getValue(ap.point, yAxis));
151182
}
152183
}
184+
for (const pt of aggOverlayPoints) {
185+
allX.push(pt.x);
186+
allY.push(pt.y);
187+
}
153188
return {
154189
xBounds: getAxisBounds(allX),
155190
yBounds: getAxisBounds(allY),
156191
};
157-
}, [displayData, xAxis, yAxis]);
192+
}, [displayData, xAxis, yAxis, aggOverlayPoints]);
158193

159194
const makeToCanvasX = useCallback(
160195
(width: number) => {
@@ -256,6 +291,7 @@ export function PolarPanel() {
256291
ctx.restore();
257292

258293
// Draw each series
294+
const hasContent = totalVisible > 0 || aggOverlayPoints.length > 0;
259295
if (totalVisible > 0) {
260296
displayData.forEach((series) => {
261297
const color = colorForKey(series.key);
@@ -285,7 +321,6 @@ export function PolarPanel() {
285321
const cy = toCanvasY(getValue(ap.point, yAxis));
286322

287323
if (ap.isFlagged) {
288-
// Red X mark for manually flagged points
289324
const s = 4;
290325
ctx.strokeStyle = '#ef4444';
291326
ctx.lineWidth = 2;
@@ -303,15 +338,39 @@ export function PolarPanel() {
303338
}
304339
}
305340
});
306-
} else {
341+
}
342+
343+
// Draw aggregated overlay as diamond markers
344+
if (aggOverlayPoints.length > 0) {
345+
const accentColor = getComputedStyle(document.documentElement)
346+
.getPropertyValue('--accent-secondary').trim() || '#f59e0b';
347+
const s = 6;
348+
for (const pt of aggOverlayPoints) {
349+
const cx = toCanvasX(pt.x);
350+
const cy = toCanvasY(pt.y);
351+
ctx.fillStyle = accentColor;
352+
ctx.strokeStyle = '#fff';
353+
ctx.lineWidth = 1.5;
354+
ctx.beginPath();
355+
ctx.moveTo(cx, cy - s);
356+
ctx.lineTo(cx + s, cy);
357+
ctx.lineTo(cx, cy + s);
358+
ctx.lineTo(cx - s, cy);
359+
ctx.closePath();
360+
ctx.fill();
361+
ctx.stroke();
362+
}
363+
}
364+
365+
if (!hasContent) {
307366
ctx.fillStyle = getComputedStyle(document.documentElement)
308367
.getPropertyValue('--text-secondary').trim() || '#888888';
309368
ctx.font = '14px sans-serif';
310369
ctx.textAlign = 'center';
311370
ctx.textBaseline = 'middle';
312371
ctx.fillText('No polar data. Run a polar sweep in the Solve panel.', width / 2, height / 2);
313372
}
314-
}, [canvasSize, displayData, totalVisible, xAxis, yAxis, xBounds, yBounds, makeToCanvasX, makeToCanvasY]);
373+
}, [canvasSize, displayData, totalVisible, xAxis, yAxis, xBounds, yBounds, makeToCanvasX, makeToCanvasY, aggOverlayPoints]);
315374

316375
// Canvas hit-testing for right-click context menu
317376
const handleContextMenu = useCallback(
@@ -426,6 +485,22 @@ export function PolarPanel() {
426485
/>
427486
Remove Outliers
428487
</label>
488+
489+
{aggregatedRuns.length > 0 && (
490+
<label style={{
491+
display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px',
492+
color: showAggregated ? 'var(--accent-secondary, #f59e0b)' : 'var(--text-secondary)',
493+
cursor: 'pointer', userSelect: 'none',
494+
}}>
495+
<input
496+
type="checkbox"
497+
checked={showAggregated}
498+
onChange={(e) => setShowAggregated(e.target.checked)}
499+
style={{ margin: 0, accentColor: 'var(--accent-secondary, #f59e0b)' }}
500+
/>
501+
Aggregated ({aggregatedRuns.length})
502+
</label>
503+
)}
429504

430505
<span style={{
431506
marginLeft: 'auto',

flexfoil-ui/src/lib/ephemeralState.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export function saveEphemeralState(snapshot: {
4848
dataExplorerSplomKeys: snapshot.ui.dataExplorerSplomKeys,
4949
dataExplorerColorBy: snapshot.ui.dataExplorerColorBy,
5050
dataExplorerFilterModel: snapshot.ui.dataExplorerFilterModel,
51+
dataExplorerSmartGroup: snapshot.ui.dataExplorerSmartGroup,
52+
dataExplorerDataSource: snapshot.ui.dataExplorerDataSource,
5153
layoutJson: snapshot.layoutJson ?? snapshot.ui.layoutJson,
5254
viewport: snapshot.ui.viewport,
5355
},

flexfoil-ui/src/lib/routeState.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,8 @@ export function parseRouteStateFromLocation(
455455
dataExplorerSplomKeys: splom ? (splom.split(',').filter(Boolean) as (keyof RunRow)[]) : undefined,
456456
dataExplorerColorBy: (params.get('colorBy') as keyof RunRow | null) ?? undefined,
457457
dataExplorerFilterModel: filterModel,
458+
dataExplorerSmartGroup: params.get('smartGroup') === '1' ? true : undefined,
459+
dataExplorerDataSource: (params.get('deData') as RouteUiSnapshot['dataExplorerDataSource'] | null) ?? undefined,
458460
activePanel: panel,
459461
viewport: Object.keys(viewport).length > 0 ? (viewport as RouteViewportState) : undefined,
460462
layoutJson: layoutJson ?? undefined,

flexfoil-ui/src/lib/version.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ export const CHANGELOG: ChangelogEntry[] = [
3737
{ category: 'added', text: 'Python API: PolarResult now exposes cl_max, alpha_stall, ld_max, cd_min, and generic argmax/argmin/column_mean/column_median' },
3838
{ category: 'added', text: 'Selig airfoil database browser with ~1,600 airfoils from the UIUC database — search and load any airfoil' },
3939
{ category: 'added', text: 'Random Foil button to load a surprise airfoil from the Selig database' },
40+
{ category: 'added', text: 'Smart Group button in Data Explorer — one click groups by polar configuration (airfoil + Re + Mach + Ncrit + flap) to see CL_max, L/D_max, α_stall per group' },
41+
{ category: 'added', text: 'Aggregated data source in Data Explorer correlogram and Polar Plot overlay — plot group-level statistics across all plotting surfaces' },
42+
{ category: 'added', text: '"Show Me" interactive tutorials in the What\'s New dialog — guided walkthroughs for new features powered by the tour system' },
43+
{ category: 'fixed', text: 'Data Explorer crashed with toFixed error when grouping by airfoil or other non-numeric columns' },
44+
{ category: 'changed', text: 'Smart Group and correlogram data source now persist across view switches and page reloads' },
4045
],
4146
},
4247
{

flexfoil-ui/src/stores/routeUiStore.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface RouteUiSnapshot {
4545
dataExplorerSplomKeys: (keyof RunRow)[];
4646
dataExplorerColorBy: keyof RunRow | '';
4747
dataExplorerFilterModel: unknown | null;
48+
dataExplorerSmartGroup: boolean;
49+
dataExplorerDataSource: DataSource;
4850
outlierFilterEnabled: boolean;
4951
activePanel: PanelId;
5052
layoutJson: IJsonModel | null;
@@ -81,6 +83,8 @@ interface RouteUiStore extends RouteUiSnapshot {
8183
setDataExplorerSplomKeys: (value: (keyof RunRow)[]) => void;
8284
setDataExplorerColorBy: (value: keyof RunRow | '') => void;
8385
setDataExplorerFilterModel: (value: unknown | null) => void;
86+
setDataExplorerSmartGroup: (value: boolean) => void;
87+
setDataExplorerDataSource: (value: DataSource) => void;
8488
setOutlierFilterEnabled: (value: boolean) => void;
8589
setViewport: (value: RouteViewportState) => void;
8690
applyRouteViewport: (value: Partial<RouteViewportState>) => void;
@@ -116,6 +120,8 @@ export const DEFAULT_ROUTE_UI_STATE: RouteUiSnapshot = {
116120
dataExplorerSplomKeys: ['alpha', 'cl', 'cd', 'cm'],
117121
dataExplorerColorBy: '',
118122
dataExplorerFilterModel: null,
123+
dataExplorerSmartGroup: false,
124+
dataExplorerDataSource: 'full',
119125
outlierFilterEnabled: false,
120126
activePanel: 'canvas',
121127
layoutJson: null,
@@ -159,6 +165,8 @@ export const useRouteUiStore = create<RouteUiStore>((set) => ({
159165
setDataExplorerSplomKeys: (dataExplorerSplomKeys) => set({ dataExplorerSplomKeys }),
160166
setDataExplorerColorBy: (dataExplorerColorBy) => set({ dataExplorerColorBy }),
161167
setDataExplorerFilterModel: (dataExplorerFilterModel) => set({ dataExplorerFilterModel }),
168+
setDataExplorerSmartGroup: (dataExplorerSmartGroup) => set({ dataExplorerSmartGroup }),
169+
setDataExplorerDataSource: (dataExplorerDataSource) => set({ dataExplorerDataSource }),
162170
setOutlierFilterEnabled: (outlierFilterEnabled) => set({ outlierFilterEnabled }),
163171
setViewport: (viewport) => set({ viewport }),
164172
applyRouteViewport: (value) =>

0 commit comments

Comments
 (0)