import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { observer } from 'mobx-react-lite'; import { toJS } from 'mobx'; import { useCompututaion, useVizStore } from '../../store'; import { useTranslation } from 'react-i18next'; import embed from 'vega-embed'; import { VegaGlobalConfig, IThemeKey, IRow } from '../../interfaces'; import { builtInThemes } from '../../vis/theme'; import { candidateTruncation, DEFAULT_CANDIDATE_LIMIT, defaultExplainers, explainMark, formatMeasureValue, type IExplainContext, type IExplainerResult, type IExplanation, type IExplanationType, } from '../../lib/explain'; import { Dialog, DialogContent, DialogTitle } from '../ui/dialog'; import { Button } from '../ui/button'; import { themeContext } from '@/store/theme'; import { cn } from '@/utils'; const EXPLANATION_ORDER: IExplanationType[] = ['extreme-value', 'contributing-dimension', 'contributing-measure', 'unique-mark']; const TYPE_KEY: Record = { 'extreme-value': 'extremeValue', 'contributing-dimension': 'contributingDimension', 'contributing-measure': 'contributingMeasure', 'unique-mark': 'uniqueMark', }; const STRENGTH_CLASS: Record = { strong: 'bg-primary text-primary-foreground', moderate: 'bg-accent text-accent-foreground', weak: 'bg-muted text-muted-foreground', }; const STRENGTH_ORDER: Record = { strong: 0, moderate: 1, weak: 2, }; type ExplanationListItem = { id: string; type: IExplanationType; explanation: IExplanation; }; type ExplanationSection = { type: IExplanationType; items: ExplanationListItem[]; }; function formatCell(value: unknown): string { if (value === null || value === undefined) return ''; if (Array.isArray(value)) return value.map(formatCell).join(' - '); if (typeof value === 'number') return formatMeasureValue(value); if (typeof value === 'object') return JSON.stringify(value); return String(value); } function fieldName(field: IExplanation['field'] | IExplanation['measure']): string { return field?.name ?? field?.fid ?? ''; } function explanationTitle(explanation: IExplanation): string { if (explanation.type === 'contributing-dimension' || explanation.type === 'contributing-measure') { return `${fieldName(explanation.field)}\u2009\u2192\u2009${fieldName(explanation.measure)}`; } if (explanation.type === 'extreme-value') { return fieldName(explanation.measure ?? explanation.field); } return fieldName(explanation.field); } function sortExplanations(explanations: IExplanation[]): IExplanation[] { return [...explanations].sort((a, b) => STRENGTH_ORDER[a.strength] - STRENGTH_ORDER[b.strength] || b.score - a.score); } function explanationId(type: IExplanationType, explanation: IExplanation, index: number): string { return `${type}:${explanation.field?.fid ?? ''}:${explanation.measure?.fid ?? ''}:${index}`; } const EvidenceChart: React.FC<{ spec: Record; config: VegaGlobalConfig; dark: string; }> = ({ spec, config, dark }) => { const chartRef = useRef(null); useEffect(() => { let disposed = false; let finalize: (() => void) | undefined; const node = chartRef.current; if (!node) return; embed(node, spec as any, { mode: 'vega-lite', actions: false, config, tooltip: { theme: dark, }, }).then((result) => { if (disposed) { result.finalize(); return; } finalize = () => result.finalize(); }); return () => { disposed = true; finalize?.(); }; }, [config, dark, spec]); return
; }; const EvidenceRows: React.FC<{ rows: IRow[]; }> = ({ rows }) => { const visibleRows = rows.slice(0, 10); const columns = Array.from(new Set(visibleRows.flatMap((row) => Object.keys(row)))).slice(0, 6); if (visibleRows.length === 0 || columns.length === 0) return null; return (
{columns.map((column) => ( ))} {visibleRows.map((row, rowIndex) => ( {columns.map((column) => ( ))} ))}
{column}
{formatCell(row[column])}
); }; const ExplanationList: React.FC<{ sections: ExplanationSection[]; selectedId?: string; empty?: boolean; onSelect: (id: string) => void; }> = ({ sections, selectedId, empty, onSelect }) => { const { t } = useTranslation(); return (
{sections.length > 0 ? (
{sections.map((section) => (

{t(`explain.type.${TYPE_KEY[section.type]}`)} · {section.items.length}

{section.items.map((item) => { const selected = item.id === selectedId; return ( ); })}
))}
) : ( empty &&
{t('explain.empty')}
)}
); }; const ExplanationDetail: React.FC<{ explanation: IExplanation; config: VegaGlobalConfig; dark: string; }> = ({ explanation, config, dark }) => { const { t } = useTranslation(); return (
{t(`explain.strength.${explanation.strength}`)}

{explanationTitle(explanation)}

{t(explanation.descriptionKey, explanation.descriptionParams)}

{explanation.evidence.chartSpec && (
)} {explanation.evidence.rows && }
); }; const OverviewChips: React.FC<{ counts: Map; total: number; activeType: IExplanationType | 'all'; onSelect: (type: IExplanationType | 'all') => void; }> = ({ counts, total, activeType, onSelect }) => { const { t } = useTranslation(); const chipClass = (active: boolean) => cn( 'inline-flex h-7 shrink-0 items-center rounded-full px-3 text-xs font-medium transition-colors', active ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground' ); return (
{EXPLANATION_ORDER.map((type) => { const count = counts.get(type) ?? 0; if (count === 0) return null; return ( ); })}
); }; const ExplainData: React.FC<{ themeKey: IThemeKey; }> = observer(({ themeKey }) => { const vizStore = useVizStore(); const dark = useContext(themeContext); const computationFunction = useCompututaion(); const { allFields, viewMeasures, viewDimensions, viewFilters, showInsightBoard, selectedMarkObject, config } = vizStore; const { timezoneDisplayOffset } = config; const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [candidateLimit, setCandidateLimit] = useState(DEFAULT_CANDIDATE_LIMIT); const [activeType, setActiveType] = useState('all'); const [selectedId, setSelectedId] = useState(); const selectedMarkKey = JSON.stringify(toJS(selectedMarkObject)); const vegaConfig = useMemo(() => { const config: VegaGlobalConfig = { ...builtInThemes[themeKey ?? 'vega']?.[dark], }; return config; }, [themeKey, dark]); const { t } = useTranslation(); useEffect(() => { setCandidateLimit(DEFAULT_CANDIDATE_LIMIT); }, [selectedMarkKey, showInsightBoard]); const ctx = useMemo(() => { const selectedMark = JSON.parse(selectedMarkKey) as IExplainContext['selectedMark']; return { allFields, viewDimensions, viewMeasures, viewFilters, selectedMark, timezoneDisplayOffset, candidateLimit }; }, [allFields, candidateLimit, selectedMarkKey, timezoneDisplayOffset, viewDimensions, viewFilters, viewMeasures]); const selectedMarkSummary = useMemo(() => { return viewDimensions .map((dimension) => ctx.selectedMark[dimension.fid]) .filter((value) => value !== undefined) .map(formatCell) .join(' · '); }, [ctx.selectedMark, viewDimensions]); useEffect(() => { let cancelled = false; if (!showInsightBoard || Object.keys(ctx.selectedMark).length === 0) { setResults([]); setLoading(false); return () => { cancelled = true; }; } setResults([]); setLoading(true); setActiveType('all'); setSelectedId(undefined); (async () => { try { for await (const result of explainMark(ctx, computationFunction, defaultExplainers)) { if (cancelled) return; setResults((prev) => [...prev.filter((item) => item.explainer !== result.explainer), result]); } } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [computationFunction, ctx, showInsightBoard]); const resultsByType = useMemo(() => { return new Map(results.map((result) => [result.explainer, result])); }, [results]); const insightCounts = useMemo(() => { return new Map(EXPLANATION_ORDER.map((type) => [type, resultsByType.get(type)?.explanations.length ?? 0])); }, [resultsByType]); const explanationSections = useMemo(() => { return EXPLANATION_ORDER.flatMap((type) => { if (activeType !== 'all' && activeType !== type) return []; const result = resultsByType.get(type); if (!result || result.status !== 'ok' || result.explanations.length === 0) return []; const items = sortExplanations(result.explanations).map((explanation, index) => ({ id: explanationId(type, explanation, index), type, explanation, })); return [{ type, items }]; }); }, [activeType, resultsByType]); const explanationItems = useMemo(() => explanationSections.flatMap((section) => section.items), [explanationSections]); const selectedItem = explanationItems.find((item) => item.id === selectedId); useEffect(() => { if (explanationItems.length === 0) { if (selectedId !== undefined) setSelectedId(undefined); return; } if (!selectedId || !explanationItems.some((item) => item.id === selectedId)) { setSelectedId(explanationItems[0].id); } }, [explanationItems, selectedId]); const hasExplanations = results.some((result) => result.explanations.length > 0); const totalInsights = results.reduce((sum, result) => sum + result.explanations.length, 0); const footerNotes = results.filter((result) => (result.status === 'skipped' || result.status === 'error') && result.reason); const showEmpty = !loading && results.length > 0 && !hasExplanations; const trunc = candidateTruncation(ctx); const showTruncation = !loading && results.length > 0 && trunc.truncated; return ( { vizStore.setShowInsightBoard(false); setResults([]); setLoading(false); setCandidateLimit(DEFAULT_CANDIDATE_LIMIT); setActiveType('all'); setSelectedId(undefined); }} >
{t('explain.title')} {selectedMarkSummary &&
{selectedMarkSummary}
} {results.length > 0 && ( )}
{selectedItem ? ( ) : (
{results.length > 0 ? t('explain.detail.empty') : null}
)}
{loading && (
{t('explain.loading')}
)} {showTruncation && (
{t('explain.truncation.notice', { scanned: trunc.scanned, total: trunc.total })}
)} {footerNotes.length > 0 && (
{t('explain.overview.skippedNote', { count: footerNotes.length })}
{footerNotes.map((result) => (

{result.status === 'skipped' ? t(result.reason ?? '') : result.reason}

))}
)}
); }); export default ExplainData;