import { useState, useEffect, useMemo } from 'react'; import Plot from 'react-plotly.js'; import { Play, AlertTriangle, Grid3x3, Layers, BarChart3 } from 'lucide-react'; import { api } from '../../api/client'; import { Panel } from '../common/Panel'; import { LoadingSpinner } from '../common/LoadingSpinner'; import { XGBoostResults } from './XGBoostResults'; import type { CramersVResponse, ContingencyResponse, ColumnGroup, XGBoostResult } from '../../types'; interface Props { source: 'dataset' | 'parsed'; // When provided, XGBoost feature importance computed from the selected columns // is handed off to the parent (e.g. the Feature Importance tab) instead of // rendering inline. onXgboost?: (results: Record) => void; } export function CramersVExplorer({ source, onXgboost }: Props) { const [report, setReport] = useState(null); const [contingency, setContingency] = useState(null); const [pair, setPair] = useState<{ a: string; b: string } | null>(null); const [dropMissing, setDropMissing] = useState(false); const [excludeTrivial, setExcludeTrivial] = useState(true); const [strong, setStrong] = useState(0.3); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // XGBoost feature importance run directly on the selected columns. const [xgbLoading, setXgbLoading] = useState(false); const [localXgb, setLocalXgb] = useState | null>(null); // Eligible categorical columns, grouped by their dotted parent (e.g. craft.*). const [groups, setGroups] = useState([]); const [selected, setSelected] = useState>(new Set()); const [groupsError, setGroupsError] = useState(null); // Load the parent groups up front — cheap (cardinality only, no matrix), so the // selector is usable before the first Compute. Defaults to all eligible columns, // which matches the explorer's prior "compute everything" behavior. useEffect(() => { let cancelled = false; setGroupsError(null); api .columnGroups({ source }) .then((res) => { if (cancelled) return; setGroups(res.groups); setSelected(new Set(res.eligible)); }) .catch((e) => { if (!cancelled) setGroupsError(e instanceof Error ? e.message : 'Could not load columns'); }); return () => { cancelled = true; }; }, [source]); // Flattened in grouped order so the matrix keeps related columns adjacent. const orderedEligible = useMemo(() => groups.flatMap((g) => g.columns), [groups]); const nestedGroups = groups.filter((g) => g.nested); const standaloneCols = groups.filter((g) => !g.nested).flatMap((g) => g.columns); const toggleCol = (c: string) => setSelected((prev) => { const next = new Set(prev); if (next.has(c)) next.delete(c); else next.add(c); return next; }); const toggleGroup = (g: ColumnGroup) => setSelected((prev) => { const next = new Set(prev); const allOn = g.columns.every((c) => next.has(c)); for (const c of g.columns) { if (allOn) next.delete(c); else next.add(c); } return next; }); const selectAll = () => setSelected(new Set(orderedEligible)); const clearAll = () => setSelected(new Set()); const run = async () => { if (orderedEligible.length && selected.size < 2) { setError('Select at least two columns (or whole parent groups) to compute associations.'); return; } setLoading(true); setError(null); setContingency(null); setPair(null); try { const cols = orderedEligible.filter((c) => selected.has(c)); const res = await api.cramersV({ source, columns: cols.length ? cols : undefined, drop_missing: dropMissing, exclude_trivial: excludeTrivial, strong_threshold: strong, }); setReport(res); } catch (e) { setError(e instanceof Error ? e.message : 'Cramér’s V failed'); } finally { setLoading(false); } }; const runXgboost = async () => { const cols = orderedEligible.filter((c) => selected.has(c)); if (cols.length < 2) { setError('Select at least two columns to run feature importance.'); return; } setXgbLoading(true); setError(null); try { const res = await api.xgboostImportance(cols, source); if (!Object.keys(res.results).length) { setError(res.message || 'No feature-importance results (need ≥2 non-constant columns).'); return; } if (onXgboost) onXgboost(res.results); else setLocalXgb(res.results); } catch (e) { setError(e instanceof Error ? e.message : 'Feature importance failed'); } finally { setXgbLoading(false); } }; const loadContingency = async (a: string, b: string) => { setPair({ a, b }); try { const res = await api.contingency({ col1: a, col2: b, drop_missing: dropMissing, source }); setContingency(res); } catch (e) { setError(e instanceof Error ? e.message : 'Contingency failed'); } }; // Lower-triangle masked matrix for the heatmap const masked = report ? report.matrix.map((row, i) => row.map((val, j) => (j > i ? null : val))) : []; return (
} >
{/* Column / parent-group selector */} {selected.size}/{orderedEligible.length} selected } > {groupsError &&

{groupsError}

} {!groupsError && orderedEligible.length === 0 && (

No categorical-eligible columns found for this source (binary/low/medium cardinality).

)}
{nestedGroups.map((g) => { const sel = g.columns.filter((c) => selected.has(c)).length; const all = sel === g.columns.length; return (
{g.columns.map((c, i) => ( ))}
); })} {standaloneCols.length > 0 && (
{nestedGroups.length > 0 && (

Ungrouped columns

)}
{standaloneCols.map((c) => ( ))}
)}
{error && (
{error}
)} {loading && } {xgbLoading && } {localXgb && ( )} {report && report.labels.length < 2 && (

Fewer than two suitable categorical columns were selected (binary/low/medium cardinality). High-cardinality, free-text and constant columns are excluded automatically.

)} {report && report.labels.length >= 2 && (
{/* Heatmap */}
}>) => { const pt = e.points?.[0]; if (pt && pt.x != null && pt.y != null) { loadContingency(String(pt.y), String(pt.x)); } }} />
{/* Contingency drilldown */} {pair && contingency && (
{contingency.col_labels.map((c) => ( ))} {contingency.row_labels.map((r, i) => ( {contingency.matrix[i].map((v, j) => ( ))} ))}
{pair.a} \ {pair.b}
{c}
{r}
{v || ''}
)}
{/* Pairs + high-corr columns */}
{report.pairs.slice(0, 40).map((p, i) => ( ))} {report.pairs.length === 0 && (

No non-trivial pairs found.

)}
{report.high_correlation_columns.length > 0 && (
{report.high_correlation_columns.map((c) => ( {c} ))}
)}
)} {!report && !loading && (

Pick parent groups / columns above, then click{' '} Compute to score categorical associations across the {source === 'parsed' ? 'parsed' : 'loaded'} dataset.

)} ); }