|
| 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