/* Copyright 2026 Marimo. All rights reserved. */ import { zodResolver } from "@hookform/resolvers/zod"; import { useAtom } from "jotai"; import { AlertTriangle, ChartColumnIcon, ChevronLeftIcon, ChevronRightIcon, CodeIcon, DatabaseIcon, PaintRollerIcon, XIcon, } from "lucide-react"; import type { JSX } from "react"; import React, { useMemo, useRef, useState } from "react"; import { type UseFormReturn, useForm } from "react-hook-form"; import useResizeObserver from "use-resize-observer"; import { PythonIcon } from "@/components/editor/cell/code/icons"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Form } from "@/components/ui/form"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { CellId } from "@/core/cells/ids"; import { useAsyncData } from "@/hooks/useAsyncData"; import { useDebouncedCallback } from "@/hooks/useDebounce"; import type { GetDataUrl } from "@/plugins/impl/DataTablePlugin"; import { vegaLoadData } from "@/plugins/impl/vega/loader"; import { useTheme } from "@/theme/useTheme"; import { inferFieldTypes } from "../columns"; import { type FieldTypesWithExternalType, TOO_MANY_ROWS, type TooManyRows, } from "../types"; import { generateAltairChartSnippet } from "./chart-spec/altair-generator"; import { createSpecWithoutData } from "./chart-spec/spec"; import { ChartTypeSelect } from "./components/chart-items"; import { ChartErrorState, ChartLoadingState } from "./components/chart-states"; import type { Field } from "./components/form-fields"; import { CodeSnippet, TabContainer } from "./components/layouts"; import { ChartFormContext } from "./context"; import { CommonChartForm, StyleForm } from "./forms/common-chart"; import { HeatmapForm } from "./forms/heatmap"; import { PieForm } from "./forms/pie"; import { LazyChart } from "./lazy-chart"; import { ChartSchema, type ChartSchemaType, getChartDefaults } from "./schemas"; import { getChartTabName, type TabName, tabsStorageAtom } from "./storage"; import { ChartType } from "./types"; const NEW_CHART_TYPE = "bar" as ChartType; const DEFAULT_TAB_NAME = "table" as TabName; const CHART_HEIGHT = 290; const CHART_MAX_ROWS = 50_000; const CHART_MAX_COLUMNS = 50; export interface TablePanelProps { cellId: CellId | null; data: unknown[]; dataTable: JSX.Element; totalRows: number | TooManyRows; columns: number; displayHeader: boolean; onCloseChartBuilder?: () => void; getDataUrl?: GetDataUrl; fieldTypes?: FieldTypesWithExternalType | null; } export const TablePanel: React.FC = ({ cellId, data, dataTable, totalRows, columns, getDataUrl, fieldTypes, displayHeader, onCloseChartBuilder, }) => { const [tabsMap, saveTabsMap] = useAtom(tabsStorageAtom); const tabs = cellId ? (tabsMap.get(cellId) ?? []) : []; const [selectedTab, setSelectedTab] = useState(DEFAULT_TAB_NAME); const [tabCounter, setTabCounter] = useState(tabs.length); const prevDisplayHeader = useRef(displayHeader); // Auto-create a default chart tab when chart builder opens with no tabs if ( displayHeader && !prevDisplayHeader.current && tabs.length === 0 && cellId ) { prevDisplayHeader.current = displayHeader; const tabName = getChartTabName(0, NEW_CHART_TYPE); const newTabs = new Map(tabsMap); newTabs.set(cellId, [ { tabName, chartType: NEW_CHART_TYPE, config: getChartDefaults() }, ]); saveTabsMap(newTabs); setTabCounter(1); setSelectedTab(tabName); } prevDisplayHeader.current = displayHeader; if (!displayHeader || (tabs.length === 0 && !displayHeader)) { return dataTable; } const handleAddTab = () => { if (!cellId) { return; } const tabName = getChartTabName(tabCounter, NEW_CHART_TYPE); const newTabs = new Map(tabsMap); newTabs.set(cellId, [ ...tabs, { tabName, chartType: NEW_CHART_TYPE, config: getChartDefaults(), }, ]); saveTabsMap(newTabs); setTabCounter(tabCounter + 1); setSelectedTab(tabName); }; const handleDeleteTab = (tabName: TabName) => { if (!cellId) { return; } const deletedIndex = tabs.findIndex((tab) => tab.tabName === tabName); const remaining = tabs.filter((tab) => tab.tabName !== tabName); const newTabs = new Map(tabsMap); newTabs.set(cellId, remaining); saveTabsMap(newTabs); if (remaining.length === 0) { onCloseChartBuilder?.(); } else if (tabName === selectedTab) { if (deletedIndex < remaining.length) { setSelectedTab(remaining[deletedIndex].tabName); } else { setSelectedTab(remaining[remaining.length - 1].tabName); } } }; const saveTabChart = ({ tabName, chartType, chartConfig, }: { tabName: TabName; chartType: ChartType; chartConfig: ChartSchemaType; }) => { if (!cellId) { return; } const updatedTabs = new Map(tabsMap); updatedTabs.set( cellId, tabs.map((tab) => tab.tabName === tabName ? { ...tab, chartType, config: chartConfig } : tab, ), ); saveTabsMap(updatedTabs); }; const saveTabChartType = (tabName: TabName, chartType: ChartType) => { if (!cellId) { return; } const tabs = tabsMap.get(cellId) ?? []; const tabIndex = tabs.findIndex((tab) => tab.tabName === tabName); if (tabIndex === -1) { return; } const newTabs = tabs.map((tab) => tab.tabName === tabName ? { ...tab, chartType, tabName: getChartTabName(tabIndex, chartType), } : tab, ); const newTabsMap = new Map(tabsMap).set(cellId, newTabs); saveTabsMap(newTabsMap); setSelectedTab(newTabs[tabIndex].tabName); }; const isLargeDataset = totalRows === TOO_MANY_ROWS || totalRows > CHART_MAX_ROWS || columns > CHART_MAX_COLUMNS; return ( setSelectedTab(DEFAULT_TAB_NAME)} > Table {tabs.map((tab, idx) => ( setSelectedTab(tab.tabName)} > {tab.tabName} { e.stopPropagation(); handleDeleteTab(tab.tabName); }} /> ))} {dataTable} {tabs.map((tab, idx) => { const saveChart = (formValues: ChartSchemaType) => { saveTabChart({ tabName: tab.tabName, chartType: tab.chartType, chartConfig: formValues, }); }; const saveChartType = (chartType: ChartType) => { saveTabChartType(tab.tabName, chartType); }; return ( ); })} ); }; const CHART_PLACEHOLDER_CODE = "X and Y columns are not set"; export const ChartPanel: React.FC<{ tableData: unknown[]; chartConfig: ChartSchemaType | null; chartType: ChartType; saveChart: (formValues: ChartSchemaType) => void; saveChartType: (chartType: ChartType) => void; getDataUrl?: GetDataUrl; fieldTypes?: FieldTypesWithExternalType | null; isLargeDataset: boolean; }> = ({ tableData, chartConfig, chartType, saveChart, saveChartType, getDataUrl, fieldTypes, isLargeDataset, }) => { const { theme } = useTheme(); const form = useForm({ defaultValues: chartConfig ?? getChartDefaults(), resolver: zodResolver(ChartSchema), }); const [selectedChartType, setSelectedChartType] = useState(chartType); const [formCollapsed, setFormCollapsed] = useState(false); const [renderLargeCharts, setRenderLargeCharts] = useState(!isLargeDataset); const { ref: chartContainerRef } = useResizeObserver(); const { data, isPending, error } = useAsyncData(async () => { if (!getDataUrl || tableData.length === 0 || !renderLargeCharts) { return []; } const response = await getDataUrl({}); if (Array.isArray(response.data_url)) { return response.data_url; } const chartData = await vegaLoadData( response.data_url, response.format === "arrow" ? { type: "arrow" } : response.format === "json" ? { type: "json" } : { type: "csv", parse: "auto" }, { replacePeriod: true, }, ); return chartData; // Re-run when the data table changes }, [tableData, renderLargeCharts]); const formValues = form.watch(); // This ensures the chart re-renders when the actual values change const memoizedFormValues = useMemo(() => { return structuredClone(formValues); }, [formValues]); const specWithoutData = createSpecWithoutData( selectedChartType, memoizedFormValues, theme, "container", CHART_HEIGHT, ); // Prevent unnecessary re-renders of the chart const memoizedChart = useMemo(() => { if (isPending) { return ; } if (error) { return ; } if (!renderLargeCharts) { return ( Rendering large datasets is not well supported and may crash the browser ); } return ( ); }, [isPending, error, renderLargeCharts, specWithoutData, data]); const developmentMode = import.meta.env.DEV; const renderChartDisplay = () => { let altairCodeSnippet = CHART_PLACEHOLDER_CODE; if (typeof specWithoutData !== "string") { altairCodeSnippet = generateAltairChartSnippet( specWithoutData, "_df", "_chart", ); } return (
Chart Python code {developmentMode && ( <> Form values (debug) Vega spec (debug) )}
{memoizedChart} {developmentMode && ( <> )}
); }; const chartForm = ( <> { setSelectedChartType(value); saveChartType(value); }} /> ); return (
{!formCollapsed && chartForm}
{renderChartDisplay()}
); }; const ChartFormContainer = ({ form, saveChart, fieldTypes, chartType, }: { form: UseFormReturn; chartType: ChartType; saveChart: (formValues: ChartSchemaType) => void; fieldTypes?: FieldTypesWithExternalType | null; }) => { let fields: Field[] = []; if (fieldTypes) { fields = fieldTypes.map((field) => { return { name: field[0], type: field[1][0], }; }); } const debouncedSave = useDebouncedCallback(() => { const values = form.getValues(); saveChart(values); }, 300); let ChartForm = CommonChartForm; if (chartType === ChartType.PIE) { ChartForm = PieForm; } else if (chartType === ChartType.HEATMAP) { ChartForm = HeatmapForm; } return (
e.preventDefault()} onChange={debouncedSave}> Data Style

); };