Skip to content

Commit a9d7748

Browse files
authored
Merge pull request #200 from danmarshall/chartifact-popup
Chartifact popup
2 parents eb35b60 + 81f727d commit a9d7748

5 files changed

Lines changed: 353 additions & 13 deletions

File tree

src/app/dfSlice.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ export type ModelSlotType = typeof MODEL_SLOT_TYPES[number];
5959
// Derive ModelSlots interface from the constant
6060
export type ModelSlots = Partial<Record<ModelSlotType, string>>;
6161

62-
62+
export interface ClientConfig {
63+
formulateTimeoutSeconds: number;
64+
maxRepairAttempts: number;
65+
defaultChartWidth: number;
66+
defaultChartHeight: number;
67+
}
6368

6469
export interface GeneratedReport {
6570
id: string;
@@ -101,12 +106,7 @@ export interface DataFormulatorState {
101106

102107
serverConfig: ServerConfig;
103108

104-
config: {
105-
formulateTimeoutSeconds: number;
106-
maxRepairAttempts: number;
107-
defaultChartWidth: number;
108-
defaultChartHeight: number;
109-
}
109+
config: ClientConfig;
110110

111111
dataLoaderConnectParams: Record<string, Record<string, string>>; // {table_name: {param_name: param_value}}
112112

@@ -415,9 +415,7 @@ export const dataFormulatorSlice = createSlice({
415415
setServerConfig: (state, action: PayloadAction<ServerConfig>) => {
416416
state.serverConfig = action.payload;
417417
},
418-
setConfig: (state, action: PayloadAction<{
419-
formulateTimeoutSeconds: number, maxRepairAttempts: number,
420-
defaultChartWidth: number, defaultChartHeight: number}>) => {
418+
setConfig: (state, action: PayloadAction<ClientConfig>) => {
421419
state.config = action.payload;
422420
},
423421
setViewMode: (state, action: PayloadAction<'editor' | 'report'>) => {

src/app/utils.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -916,4 +916,4 @@ export function hashCode(str: string) {
916916
hash |= 0; // Convert to 32bit integer
917917
}
918918
return hash;
919-
}
919+
}

src/data/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,14 @@ export const loadBinaryDataWrapper = async (title: string, arrayBuffer: ArrayBuf
241241
return [];
242242
}
243243
};
244+
245+
/**
246+
* Exports a DictTable to DSV format using d3.dsvFormat
247+
* @param table - The DictTable to export
248+
* @param delimiter - The delimiter to use (e.g., "," for CSV, "\t" for TSV)
249+
* @returns DSV string representation of the table
250+
*/
251+
export const exportTableToDsv = (table: DictTable, delimiter: string): string => {
252+
// Use d3.dsvFormat to convert the rows array to DSV
253+
return d3.dsvFormat(delimiter).format(table.rows);
254+
};

src/views/ChartifactDialog.tsx

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { Chart, DictTable, FieldItem } from '../components/ComponentType';
5+
import { assembleVegaChart, prepVisTable } from '../app/utils';
6+
import { exportTableToDsv } from '../data/utils';
7+
import { ClientConfig } from '../app/dfSlice';
8+
9+
// Function to generate CSS styling based on report type
10+
const generateStyleCSS = (style: string): string => {
11+
// Font families
12+
const FONT_FAMILY_SYSTEM = '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif';
13+
const FONT_FAMILY_SERIF = 'Georgia, Cambria, "Times New Roman", Times, serif';
14+
const FONT_FAMILY_MONO = '"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
15+
16+
if (style === 'social post' || style === 'short note') {
17+
// Twitter/X style - compact, modern
18+
return `\`\`\`css
19+
body {
20+
margin: 20px;
21+
padding: 20px;
22+
background-color: white;
23+
border: 1px solid rgb(207, 217, 222);
24+
border-radius: 12px;
25+
font-family: ${FONT_FAMILY_SYSTEM};
26+
font-size: 0.875rem;
27+
font-weight: 400;
28+
line-height: 1.4;
29+
color: rgb(15, 20, 25);
30+
}
31+
32+
h1, h2, h3, h4, h5, h6 {
33+
color: rgb(15, 20, 25);
34+
font-weight: 700;
35+
}
36+
37+
code {
38+
background-color: rgba(29, 155, 240, 0.1);
39+
color: rgb(29, 155, 240);
40+
padding: 0.1em 0.25em;
41+
border-radius: 3px;
42+
font-size: 0.8125rem;
43+
font-weight: 500;
44+
font-family: ${FONT_FAMILY_MONO};
45+
}
46+
47+
strong {
48+
font-weight: 600;
49+
color: rgb(15, 20, 25);
50+
}
51+
\`\`\`
52+
53+
`;
54+
} else if (style === 'executive summary') {
55+
// Professional/business look
56+
return `\`\`\`css
57+
body {
58+
max-width: 700px;
59+
margin: 20px auto;
60+
padding: 20px;
61+
background-color: white;
62+
font-family: ${FONT_FAMILY_SERIF};
63+
font-size: 0.875rem;
64+
line-height: 1.5;
65+
color: rgb(33, 37, 41);
66+
}
67+
68+
h1, h2, h3, h4, h5, h6 {
69+
color: rgb(20, 24, 28);
70+
font-weight: 600;
71+
}
72+
73+
code {
74+
background-color: rgb(248, 249, 250);
75+
color: rgb(0, 123, 255);
76+
padding: 0.1em 0.25em;
77+
border-radius: 2px;
78+
font-size: 0.75rem;
79+
font-family: ${FONT_FAMILY_MONO};
80+
}
81+
82+
strong {
83+
font-weight: 600;
84+
color: rgb(20, 24, 28);
85+
}
86+
\`\`\`
87+
88+
`;
89+
} else {
90+
// Default "blog post" style - Notion-like
91+
return `\`\`\`css
92+
body {
93+
max-width: 800px;
94+
margin: 20px auto;
95+
padding: 0 48px;
96+
background-color: #ffffff;
97+
font-family: ${FONT_FAMILY_SYSTEM};
98+
font-size: 0.9375rem;
99+
line-height: 1.75;
100+
font-weight: 400;
101+
letter-spacing: 0.003em;
102+
color: rgb(55, 53, 47);
103+
}
104+
105+
h1, h2, h3, h4, h5, h6 {
106+
color: rgb(37, 37, 37);
107+
font-weight: 700;
108+
letter-spacing: -0.01em;
109+
}
110+
111+
code {
112+
background-color: rgba(135, 131, 120, 0.15);
113+
color: #eb5757;
114+
padding: 0.2em 0.4em;
115+
border-radius: 3px;
116+
font-size: 0.875rem;
117+
font-weight: 500;
118+
font-family: ${FONT_FAMILY_MONO};
119+
}
120+
121+
strong {
122+
font-weight: 600;
123+
color: rgb(37, 37, 37);
124+
}
125+
\`\`\`
126+
127+
`;
128+
}
129+
};
130+
131+
// Function to convert report markdown to Chartifact format
132+
export const convertToChartifact = (reportMarkdown: string, reportStyle: string, charts: Chart[], tables: DictTable[], conceptShelfItems: FieldItem[], config: ClientConfig) => {
133+
try {
134+
// Extract chart IDs from the report markdown images
135+
// Images are in format: [IMAGE(chart-id)]
136+
const imageRegex = /\[IMAGE\(([^)]+)\)\]/g;
137+
let result = reportMarkdown;
138+
let match;
139+
const chartReplacements: Array<{ original: string; specReplacement: string; dataName: string; csvContent: string }> = [];
140+
141+
while ((match = imageRegex.exec(reportMarkdown)) !== null) {
142+
const [fullMatch, chartId] = match;
143+
144+
// Find the chart in the store using the chart ID
145+
const chart = charts.find(c => c.id === chartId);
146+
if (!chart) {
147+
console.warn(`Chart with id ${chartId} not found in store`);
148+
continue;
149+
}
150+
151+
// Get the chart's data table from the store using chart.tableRef
152+
const chartTable = tables.find(t => t.id === chart.tableRef);
153+
if (!chartTable) {
154+
console.warn(`Table for chart ${chartId} not found`);
155+
continue;
156+
}
157+
158+
// Skip non-visual chart types
159+
if (chart.chartType === 'Table' || chart.chartType === '?') {
160+
continue;
161+
}
162+
163+
try {
164+
// Preprocess the data for aggregations
165+
const processedRows = prepVisTable(chartTable.rows, conceptShelfItems, chart.encodingMap);
166+
167+
// Assemble the Vega-Lite spec
168+
const vegaSpec = assembleVegaChart(
169+
chart.chartType,
170+
chart.encodingMap,
171+
conceptShelfItems,
172+
processedRows,
173+
chartTable.metadata,
174+
30,
175+
true,
176+
config.defaultChartWidth,
177+
config.defaultChartHeight,
178+
true
179+
);
180+
181+
// Convert the spec to use named data source
182+
const dataName = `chartData_${chartId.replace(/[^a-zA-Z0-9]/g, '_')}`;
183+
const modifiedSpec = {
184+
...vegaSpec,
185+
data: { name: dataName }
186+
};
187+
188+
// Convert table rows to CSV format using the utility function
189+
const csvContent = exportTableToDsv(chartTable, ',');
190+
191+
// Create the Chartifact spec replacement (without CSV)
192+
const specReplacement = `
193+
194+
\`\`\`json vega-lite
195+
${JSON.stringify(modifiedSpec, null, 2)}
196+
\`\`\`
197+
`;
198+
199+
chartReplacements.push({
200+
original: fullMatch,
201+
specReplacement,
202+
dataName,
203+
csvContent
204+
});
205+
} catch (error) {
206+
console.error(`Error processing chart ${chartId}:`, error);
207+
}
208+
}
209+
210+
// Apply spec replacements to the markdown
211+
for (const { original, specReplacement } of chartReplacements) {
212+
result = result.replace(original, specReplacement);
213+
}
214+
215+
result += '\n\n---\ncreated with AI using [Data Formulator](https://github.com/microsoft/data-formulator)\n\n';
216+
217+
// Prepend CSS styling based on report type
218+
const cssStyles = generateStyleCSS(reportStyle);
219+
result += cssStyles;
220+
221+
// Append all CSV data blocks at the bottom
222+
if (chartReplacements.length > 0) {
223+
result += '\n\n';
224+
for (const { dataName, csvContent } of chartReplacements) {
225+
result += `\n\`\`\`csv ${dataName}\n${csvContent}\n\`\`\`\n`;
226+
}
227+
}
228+
229+
return result;
230+
} catch (error) {
231+
console.error('Error converting to Chartifact:', error);
232+
throw error;
233+
}
234+
};
235+
236+
237+
// Function to open Chartifact in a new tab and send markdown via postMessage
238+
export const openChartifactViewer = async (chartifactMarkdown: string) => {
239+
try {
240+
// Open the Chartifact viewer in a new tab
241+
const chartifactWindow = window.open(
242+
'https://microsoft.github.io/chartifact/view/?post',
243+
'_blank'
244+
);
245+
246+
if (!chartifactWindow) {
247+
//showMessage('Failed to open Chartifact viewer. Please allow popups.', 'error');
248+
return;
249+
}
250+
251+
// Listen for hostStatus messages from the Chartifact viewer
252+
const handleMessage = (event: MessageEvent) => {
253+
// Verify the message is from the Chartifact viewer
254+
if (event.origin !== 'https://microsoft.github.io') {
255+
return;
256+
}
257+
258+
const message = event.data;
259+
260+
// Check if this is a hostStatus message
261+
if (message.type === 'hostStatus' && message.hostStatus === 'ready') {
262+
// Send the render request when the host is ready
263+
const renderRequest: {
264+
type: 'hostRenderRequest';
265+
title: string;
266+
markdown?: string;
267+
interactiveDocument?: any;
268+
} = {
269+
type: 'hostRenderRequest',
270+
title: 'Data Formulator Report',
271+
markdown: chartifactMarkdown
272+
};
273+
274+
chartifactWindow.postMessage(renderRequest, 'https://microsoft.github.io');
275+
276+
//Call here to show source
277+
const toolbarControl: {
278+
type: 'hostToolbarControl';
279+
showSource?: boolean;
280+
} = {
281+
type: 'hostToolbarControl',
282+
showSource: true
283+
};
284+
285+
chartifactWindow.postMessage(toolbarControl, 'https://microsoft.github.io');
286+
287+
// Remove the event listener after sending
288+
window.removeEventListener('message', handleMessage);
289+
}
290+
};
291+
292+
// Add event listener for messages from the Chartifact viewer
293+
window.addEventListener('message', handleMessage);
294+
295+
//showMessage('Opened Chartifact viewer in a new tab', 'success');
296+
} catch (error) {
297+
console.error('Error opening Chartifact viewer:', error);
298+
//showMessage('Failed to prepare Chartifact report', 'error');
299+
}
300+
};

0 commit comments

Comments
 (0)