import React, { useMemo } from 'react'; import { observer } from 'mobx-react-lite'; import { toJS } from 'mobx'; import { Trans, useTranslation } from 'react-i18next'; import { SparklesIcon } from '@heroicons/react/24/solid'; import { ChevronDoubleRightIcon, CursorArrowRaysIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { useVizStore } from '../../store'; import { IChart } from '../../interfaces'; import { COUNT_FIELD_ID, MEA_KEY_ID, MEA_VAL_ID } from '../../constants'; import { cn } from '../../utils'; import { recommend, IAutoVizDisableReason, IAutoVizItem } from '../../lib/autoViz'; import { Button } from '../ui/button'; import Tooltip from '../tooltip'; import { chartIcons, chartTypeLabels } from './chartIcons'; /** English fallbacks; overridden by the `autoviz.reason.*` i18n keys when present */ const reasonLabels: Record = { need_fields: 'Select at least one field', need_dimension: 'Needs at least 1 dimension', need_two_dimensions: 'Needs at least 2 dimensions', too_many_dimensions: 'Too many dimensions for this chart', need_measure: 'Needs at least 1 measure', need_two_measures: 'Needs at least 2 measures', need_single_measure: 'Needs exactly 1 measure', too_many_measures: 'Too many measures for this chart', need_temporal: 'Needs a date/time dimension', need_temporal_or_ordinal: 'Needs a date/time or ordinal dimension', need_raw_measure: 'Needs a raw measure, not row count', need_lon_lat: 'Needs longitude & latitude fields', need_geo_feature: 'Needs a configured GeoJSON boundary', }; const VIRTUAL_FIDS = [MEA_KEY_ID, MEA_VAL_ID]; const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform); /** keycap-styled modifier key; the visual anchor of the field-selection hint */ const ModKey: React.FC = () => ( {IS_MAC ? '⌘' : 'Ctrl'} ); /** * Tableau "Show Me"-style dock: a collapsible card on the right edge of the vis workspace. * Expanded state lives in the store (`showAutoVizPanel`) so the toolbar toggle stays in sync. * * Recommendation input cascades through three tiers: * 1. fields highlighted in the field list (ctrl/cmd+click) — merged with the view fields; * 2. no highlight → the fields already encoded in the current chart; * 3. empty chart too → cold start: every chart type reverse-matches usable fields * from the dataset, so clicking any lit type starts a chart from scratch. */ const AutoVizPanel: React.FC = observer(function AutoVizPanel() { const vizStore = useVizStore(); const { t } = useTranslation(); const expanded = vizStore.showAutoVizPanel; const selectedIds = vizStore.selectedFieldIds; const allFields = useMemo( () => vizStore.dimensions.concat(vizStore.measures).filter((f) => !VIRTUAL_FIDS.includes(f.fid)), [vizStore.dimensions, vizStore.measures] ); // tier 1 ∪ tier 2: view fields (count excluded — the chart encodes it implicitly) // merged with explicitly highlighted fields const inputFields = useMemo(() => { const viewFields = vizStore.viewDimensions .concat(vizStore.viewMeasures) .filter((f) => !VIRTUAL_FIDS.includes(f.fid) && f.fid !== COUNT_FIELD_ID); const fids = new Set(viewFields.map((f) => f.fid)); const picked = allFields.filter((f) => selectedIds.includes(f.fid) && !fids.has(f.fid)); return viewFields.concat(picked); }, [vizStore.viewDimensions, vizStore.viewMeasures, allFields, selectedIds]); const mode: 'selection' | 'view' | 'cold' = selectedIds.length > 0 ? 'selection' : inputFields.length > 0 ? 'view' : 'cold'; const result = useMemo(() => { if (!expanded) { return null; } const base = toJS(vizStore.currentVis) as IChart; const geoFeatureReady = Boolean(vizStore.layout.geojson || vizStore.layout.geoUrl); return recommend({ fields: inputFields.map((f) => toJS(f)), base, allFields: allFields.map((f) => toJS(f)), geoFeatureReady, }); }, [expanded, inputFields, allFields, vizStore.currentVis, vizStore.layout.geojson, vizStore.layout.geoUrl]); const apply = (item: IAutoVizItem | null) => { if (!item?.available || !item.chart) { return; } vizStore.applyChart(item.chart); }; if (!expanded) { return (
); } const availableCount = result?.items.filter((x) => x.available).length ?? 0; return (
{/* header */}
{t('autoviz.title', 'Auto Viz')}
{/* chart palette */}
{(result?.items ?? []).map((item) => { const Icon = chartIcons[item.chartType]; const label = t(`autoviz.charts.${item.chartType}`, chartTypeLabels[item.chartType]); const tooltip = !item.available ? t(`autoviz.reason.${item.reason}`, reasonLabels[item.reason!]) : item.matchedFields?.length ? t('autoviz.uses', 'Uses: {{fields}}', { fields: item.matchedFields.map((f) => f.name ?? f.fid).join(', ') }) : item.isDefault ? t('autoviz.recommended', 'Recommended') : label; const card = (
apply(item)} className={cn( 'relative flex flex-col items-center gap-1 rounded-lg border p-1.5 transition-all', item.available ? 'cursor-pointer text-foreground hover:border-primary hover:bg-primary/5 hover:shadow-sm' : 'cursor-not-allowed border-transparent text-muted-foreground opacity-35', item.isDefault && 'border-primary bg-primary/5 shadow-sm' )} > {item.isDefault && ( )} {label}
); return ( {card} ); })}
{/* footer */}
{mode === 'selection' && ( <>
{t('autoviz.selected_count', '{{count}} fields selected in the field list', { count: selectedIds.length })}
{t('autoviz.hint', '{{count}} chart types match your selection', { count: availableCount })} )} {mode === 'cold' && ( <> {/* the one thing worth reading: how to select fields — keycap as the visual anchor */} }} defaults="Pick fields on the left · +click to multi-select" /> {t('autoviz.empty', '…or click any lit chart type to start')} )} {mode === 'view' && ( <> {t('autoviz.based_on_view', 'Based on the fields in the current chart · {{count}} types match', { count: availableCount })} }} defaults="Click or +click fields on the left to refine" /> )}
); }); export default AutoVizPanel;