diff --git a/src/data/utils.ts b/src/data/utils.ts index af8dd2bd..8c09cfa2 100644 --- a/src/data/utils.ts +++ b/src/data/utils.ts @@ -207,3 +207,13 @@ export const loadBinaryDataWrapper = (title: string, arrayBuffer: ArrayBuffer): return []; } }; + +/** + * Exports a DictTable to DSV format using d3.dsvFormat + * @param table - The DictTable to export + * @returns DSV string representation of the table + */ +export const exportTableToDsv = (table: DictTable, delimiter: string): string => { + // Use d3.dsvFormat to convert the rows array to DSV + return d3.dsvFormat(delimiter).format(table.rows); +}; diff --git a/src/views/ChartifactDialog.tsx b/src/views/ChartifactDialog.tsx new file mode 100644 index 00000000..1bf0629c --- /dev/null +++ b/src/views/ChartifactDialog.tsx @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React, { FC, useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + Typography, + Box +} from '@mui/material'; +import { DictTable } from '../components/ComponentType'; +import { exportTableToDsv } from '../data/utils'; +import { assembleVegaChart } from '../app/utils'; + +export interface ChartElements { + tableId: string; + chartId: string; + element: any; +} + +export interface ChartifactDialogProps { + open: boolean; + handleCloseDialog: () => void; + tables: DictTable[]; + chartElements: ChartElements[]; +} + +export const ChartifactDialog: FC = function ChartifactDialog({ + open, + handleCloseDialog, + tables, + chartElements +}) { + // Generate initial title from table names + const getTablesFromChartElements = () => { + return chartElements + .map(ce => tables.find(t => t.id === ce.tableId)) + .filter(table => table) as DictTable[]; + }; + + const [title, setTitle] = useState(() => + generateTitleFromTables(getTablesFromChartElements()) + ); + + // Update title when chart elements change + useEffect(() => { + if (open) { + const newTitle = generateTitleFromTables(getTablesFromChartElements()); + setTitle(newTitle); + } + }, [open, chartElements, tables]); + + const handleDownload = () => { + + const content = createChartifact(chartElements, tables, title); + + // Create a blob and download the file + const blob = new Blob([content], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + // Use title as filename, replace bad chars and spaces with underscores + const sanitizedTitle = title.replace(/[^a-zA-Z0-9]/g, '_').replace(/_+/g, '_'); + link.download = `${sanitizedTitle}.idoc.md`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + // Close the dialog after download + handleCloseDialog(); + setTitle(''); // Reset the title + }; + + const handleClose = () => { + handleCloseDialog(); + setTitle(''); // Reset the title when closing + }; + + return ( + + + Create Chartifact Document + + + + setTitle(e.target.value)} + placeholder="Enter document title..." + variant="outlined" + size="small" + /> + + This will create a chartifact document with {chartElements.length} chart{chartElements.length !== 1 ? 's' : ''}. + {chartElements.length > 0 && ( + <> +
+ The document will include: +
+ • Table data and transformations +
+ • Sample data for each table +
+ • Chart references + + )} +
+
+
+ + + + +
+ ); +}; + +function createChartifact(chartElements: ChartElements[], tables: DictTable[], title: string) { + // Get actual table data from tables array using the IDs + const chartData = chartElements.map(ce => { + const table = tables.find(t => t.id === ce.tableId); + + const { chart, conceptShelfItems } = ce.element.props; + const vg = assembleVegaChart(chart.chartType, chart.encodingMap, conceptShelfItems, table?.rows!); + + delete vg.data.values; + vg.data.name = table?.id; + + vg.padding = 50; + + return { table, element: ce.element, chartId: ce.chartId, vg }; + }).filter(item => item.table); // Filter out any missing data + + + // Create more detailed chartifact content + let out = [`${tickWrap('#', 'View this document in the online Chartifact viewer: https://microsoft.github.io/chartifact/view/ \nor with the Chartifact VS Code extension: https://marketplace.visualstudio.com/items?itemName=msrvida.chartifact')} +# Data Formulator session: ${title} +`]; + + out.push(`This chartifact document contains ${chartData.length} visualization${chartData.length !== 1 ? 's' : ''}.\n`); + + chartData.forEach((item, index) => { + out.push(`## Chart ${index + 1}`); + out.push(`**Table:** ${item.table!.displayId || item.table!.id}`); + out.push(`**Chart ID:** ${item.chartId}`); + + // Add table info + if (item.table!.derive?.code) { + out.push(`\n**Transformation Code:**`); + out.push(tickWrap('python', item.table!.derive.code)); + } + + // Add Vega-Lite specification + out.push(`\n**Visualization:**`); + out.push(tickWrap('json vega-lite', JSON.stringify(item.vg, null, 2))); + + out.push('\n---\n'); + }); + + // Output unique CSV tables at the end + const uniqueTableIds = [...new Set(chartData.map(item => item.table!.id))]; + if (uniqueTableIds.length > 0) { + out.push(`## Data Tables\n\n`); + uniqueTableIds.forEach(tableId => { + const table = tables.find(t => t.id === tableId); + if (table && table.rows && table.rows.length > 0) { + out.push(`### ${table.displayId || table.id}`); + out.push(tickWrap(`csv ${table.id}`, exportTableToDsv(table, ','))); + out.push(tickWrap('json tabulator', JSON.stringify({ dataSourceName: table.id }, null, 2))); + } + }); + } + + return out.join('\n'); +} + +function tickWrap(plugin: string, content: string) { + return `\n\n\n\`\`\`${plugin}\n${content}\n\`\`\`\n\n\n`; +} + +function generateTitleFromTables(tables: DictTable[]): string { + if (tables.length === 0) return ''; + + const uniqueTableNames = [...new Set( + tables.map(t => { + const name = t.displayId || t.id || ''; + return name ? (name.charAt(0).toUpperCase() + name.slice(1)) : name; + }) + )]; + + if (uniqueTableNames.length <= 3) { + return uniqueTableNames.join(', '); + } else { + return uniqueTableNames.slice(0, 3).join(', ') + '...'; + } +} diff --git a/src/views/DataThread.tsx b/src/views/DataThread.tsx index fb0f8878..c1168bc9 100644 --- a/src/views/DataThread.tsx +++ b/src/views/DataThread.tsx @@ -32,6 +32,7 @@ import { Chart, DictTable, EncodingItem, FieldItem, Trigger } from "../component import DeleteIcon from '@mui/icons-material/Delete'; import AddchartIcon from '@mui/icons-material/Addchart'; +import CreateChartifact from '@mui/icons-material/Description'; import StarIcon from '@mui/icons-material/Star'; import SouthIcon from '@mui/icons-material/South'; import TableRowsIcon from '@mui/icons-material/TableRowsOutlined'; @@ -42,6 +43,7 @@ import CheckIcon from '@mui/icons-material/Check'; import _ from 'lodash'; import { getChartTemplate } from '../components/ChartTemplates'; +import { ChartElements, ChartifactDialog } from './ChartifactDialog'; import 'prismjs/components/prism-python' // Language import 'prismjs/components/prism-typescript' // Language @@ -197,6 +199,7 @@ let SingleThreadView: FC<{ leafTable: DictTable; chartElements: { tableId: string, chartId: string, element: any }[]; usedIntermediateTableIds: string[], + onOpenChartifactDialog: (chartElements: ChartElements[]) => void, sx?: SxProps }> = function ({ scrollRef, @@ -204,6 +207,7 @@ let SingleThreadView: FC<{ leafTable, chartElements, usedIntermediateTableIds, // tables that have been used + onOpenChartifactDialog, sx }) { let tables = useSelector((state: DataFormulatorState) => state.tables); @@ -468,6 +472,18 @@ let SingleThreadView: FC<{ }} /> + + + { + event.stopPropagation(); + onOpenChartifactDialog(relevantCharts); + }} /> + + @@ -783,6 +799,8 @@ export const DataThread: FC<{}> = function ({ }) { const conceptShelfItems = useSelector((state: DataFormulatorState) => state.conceptShelfItems); let [threadDrawerOpen, setThreadDrawerOpen] = useState(false); + let [chartifactDialogOpen, setChartifactDialogOpen] = useState(false); + let [currentChartElements, setCurrentChartElements] = useState([]); const scrollRef = useRef(null) @@ -791,6 +809,11 @@ export const DataThread: FC<{}> = function ({ }) { const dispatch = useDispatch(); + const handleOpenChartifactDialog = (chartElements: ChartElements[]) => { + setCurrentChartElements(chartElements); + setChartifactDialogOpen(true); + }; + useEffect(() => { executeScroll(); }, [threadDrawerOpen]) @@ -863,7 +886,8 @@ export const DataThread: FC<{}> = function ({ }) { threadIdx={i} leafTable={lt} chartElements={chartElements} - usedIntermediateTableIds={usedIntermediateTableIds} + usedIntermediateTableIds={usedIntermediateTableIds} + onOpenChartifactDialog={handleOpenChartifactDialog} sx={{ backgroundColor: (i % 2 == 1 ? "rgba(0, 0, 0, 0.03)" : 'white'), padding: '8px 8px', @@ -978,6 +1002,12 @@ export const DataThread: FC<{}> = function ({ }) { return {carousel} + setChartifactDialogOpen(false)} + tables={tables} + chartElements={currentChartElements} + /> ; }