Spaces:
Running
Running
Download lib/chatgpt-chart-html.js from XWX-AI/api-server: direct link, hf CLI and curl.
- Browser
- Download file 18.3 kB
-
https://huggingface.co/spaces/XWX-AI/api-server/resolve/main/lib/chatgpt-chart-html.js
- Command line
-
hf download hf://spaces/XWX-AI/api-server/lib/chatgpt-chart-html.js
-
curl -L -o chatgpt-chart-html.js https://huggingface.co/spaces/XWX-AI/api-server/resolve/main/lib/chatgpt-chart-html.js
18.3 kB
| /** | |
| * buildChatGPTChartHtml — ChatGPT charts_widget_v2 数据 → 完整渲染 HTML | |
| * | |
| * 单一事实源:backend-service(本目录)与桌面 APP(xwx-backup/app/src-tauri/pdf-render/) | |
| * 共用同一份实现。桌面端通过 make/build-pdf-render-assets.mjs 同步并做指纹校验。 | |
| * 提取自 server.js WidgetRenderer.buildChatGPTChatHtml 原实现(函数体逐字节一致, | |
| * 仅类方法→独立函数)。修改时保持两端同步流程:先改这里,再跑 make 脚本同步。 | |
| */ | |
| function buildChatGPTChartHtml(chartType, series, data, xKey, nameKey, valueKey, formattedData, axes, title, description) { | |
| // ChatGPT official chart color palette (extracted from live ChatGPT web) | |
| // ChatGPT official chart color palette (first 4) + D3 schemeCategory10 extended (10 total) | |
| // D3 schemeCategory10: industry-standard categorical palette, colorblind-friendly | |
| const COLORS = ['#339CFF', '#40C977', '#FF8549', '#FFD240', '#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#06b6d4']; | |
| // Serialize data safely for inline script | |
| const dataJson = JSON.stringify(data || []) | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const seriesJson = JSON.stringify(series || []) | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const chartTypeJson = JSON.stringify(chartType || 'bar') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const titleJson = JSON.stringify(title || '') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const descJson = JSON.stringify(description || '') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const xKeyJson = JSON.stringify(xKey || 'x') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const nameKeyJson = JSON.stringify(nameKey || '') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const valueKeyJson = JSON.stringify(valueKey || '') | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const formattedDataJson = JSON.stringify(formattedData || []) | |
| .replace(/<\/script/gi, '<\\/script'); | |
| const axesJson = JSON.stringify(axes || []) | |
| .replace(/<\/script/gi, '<\\/script'); | |
| return `<!DOCTYPE html> | |
| <html><head><meta charset="UTF-8"> | |
| <style> | |
| body { margin: 0; padding: 12px; background: #ffffff; font-family: system-ui, -apple-system, sans-serif; } | |
| .chart-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; color: #111827; } | |
| .chart-subtitle { font-size: 12px; color: #6b7280; margin-bottom: 12px; } | |
| #widget-container { width: 720px; min-height: 300px; } | |
| /* Widen container to prevent last data point dot from overflowing SVG boundary. | |
| Recharts plots the last point at the plotting area edge; the dot (r=4-6) extends | |
| beyond. 720px gives ~70px extra right padding after the Y-axis. */ | |
| .recharts-wrapper { margin: 0 auto; } | |
| </style> | |
| <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"><\/script> | |
| <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"><\/script> | |
| <script src="https://unpkg.com/react-is@18.2.0/umd/react-is.production.min.js"><\/script> | |
| <script src="https://unpkg.com/prop-types@15.8.1/prop-types.min.js"><\/script> | |
| <script src="https://unpkg.com/recharts@2.12.7/umd/Recharts.js"><\/script> | |
| </head><body> | |
| <div id="widget-container"></div> | |
| <script> | |
| window._widgetRendered = false; | |
| (function() { | |
| try { | |
| var chartData = ${dataJson}; | |
| var chartSeries = ${seriesJson}; | |
| var chartTitle = ${titleJson}; | |
| var chartDesc = ${descJson}; | |
| var xKey = ${xKeyJson}; | |
| var chartType = ${chartTypeJson}; | |
| var nameKey = ${nameKeyJson}; | |
| var valueKey = ${valueKeyJson}; | |
| var formattedData = ${formattedDataJson}; | |
| var chartAxes = ${axesJson}; | |
| var COLORS = ${JSON.stringify(COLORS)}; | |
| var rc = Recharts; | |
| var h = React.createElement; | |
| // ── Scatter chart: detect x-axis key ── | |
| var scatterXKey = xKey; | |
| if (chartType === 'scatter' && chartData.length > 0) { | |
| var sFirstRow = chartData[0]; | |
| var sKeys = Object.keys(sFirstRow); | |
| var seriesDataKey = chartSeries[0] ? chartSeries[0].dataKey : null; | |
| // Find a non-series key for x-axis (prefer non-numeric for category axis) | |
| for (var si = 0; si < sKeys.length; si++) { | |
| if (sKeys[si] !== seriesDataKey) { | |
| scatterXKey = sKeys[si]; | |
| break; | |
| } | |
| } | |
| } | |
| // ── Universal axis configuration builder ── | |
| // Uses pre-calculated axis data from API JSON (axes[].ticks[].label) | |
| // instead of procedural formatting. This is the canonical approach: | |
| // ChatGPT's AI generates exact tick labels in the API JSON, and we | |
| // pass them directly to Recharts via the tick prop. | |
| function buildAxisConfig(axisId) { | |
| // Find matching axis from chartAxes by id | |
| var axisConfig = null; | |
| for (var ai = 0; ai < chartAxes.length; ai++) { | |
| if (chartAxes[ai].id === axisId) { | |
| axisConfig = chartAxes[ai]; | |
| break; | |
| } | |
| } | |
| if (!axisConfig) { | |
| // Fallback: use series-level formatting if no axes data | |
| var s = chartSeries[0] || {}; | |
| var prefix = s.valuePrefix || ''; | |
| var suffix = s.valueSuffix || ''; | |
| var format = s.valueFormat || 'standard'; | |
| return { | |
| tickFormatter: function(value) { | |
| var num = Number(value); | |
| if (isNaN(num)) return String(value); | |
| var display; | |
| if (format === 'compact') { | |
| if (Math.abs(num) >= 1e9) display = (num / 1e9).toFixed(0) + 'B'; | |
| else if (Math.abs(num) >= 1e6) display = (num / 1e6).toFixed(0) + 'M'; | |
| else if (Math.abs(num) >= 1e3) display = (num / 1e3).toFixed(0) + 'K'; | |
| else display = String(num); | |
| } else { | |
| display = String(num); | |
| } | |
| return prefix + display + suffix; | |
| } | |
| }; | |
| } | |
| // Build tick lookup map: value → label | |
| var tickMap = {}; | |
| for (var ti = 0; ti < axisConfig.ticks.length; ti++) { | |
| tickMap[String(axisConfig.ticks[ti].value)] = axisConfig.ticks[ti].label; | |
| } | |
| var tickValues = axisConfig.ticks.map(function(t) { return t.value; }); | |
| return { | |
| domain: axisConfig.domain || undefined, | |
| ticks: tickValues, | |
| tickFormatter: function(value) { | |
| var key = String(value); | |
| // Direct lookup for exact match | |
| if (tickMap[key] !== undefined) return tickMap[key]; | |
| // Fallback: procedural formatting for interpolated values | |
| var prefix = ''; | |
| var suffix = ''; | |
| var s0 = chartSeries[0] || {}; | |
| prefix = s0.valuePrefix || ''; | |
| suffix = s0.valueSuffix || ''; | |
| var num = Number(value); | |
| if (isNaN(num)) return String(value); | |
| if (Math.abs(num) >= 1e9) return prefix + (num / 1e9).toFixed(0) + 'B' + suffix; | |
| if (Math.abs(num) >= 1e6) return prefix + (num / 1e6).toFixed(0) + 'M' + suffix; | |
| if (Math.abs(num) >= 1e3) return prefix + (num / 1e3).toFixed(0) + 'K' + suffix; | |
| return prefix + String(num) + suffix; | |
| }, | |
| label: axisConfig.label || undefined | |
| }; | |
| } | |
| // Build series elements | |
| var seriesEls = []; | |
| // Each series may carry additional Recharts props (stackId, radius, type, etc.) | |
| // that AI could generate. We pass them through to the component props. | |
| if (chartType === 'bar') { | |
| chartSeries.forEach(function(s, i) { | |
| var props = { | |
| dataKey: s.dataKey, | |
| name: s.label || s.dataKey, | |
| fill: s.color || COLORS[i % COLORS.length] | |
| }; | |
| if (s.stackId !== undefined) props.stackId = s.stackId; | |
| if (s.radius !== undefined) props.radius = s.radius; | |
| if (s.barSize !== undefined) props.barSize = s.barSize; | |
| if (s.maxBarSize !== undefined) props.maxBarSize = s.maxBarSize; | |
| if (s.stroke !== undefined) props.stroke = s.stroke; | |
| if (s.strokeWidth !== undefined) props.strokeWidth = s.strokeWidth; | |
| if (s.unit !== undefined) props.unit = s.unit; | |
| if (s.label !== undefined) props.label = s.label; | |
| seriesEls.push(h(rc.Bar, props)); | |
| }); | |
| } else if (chartType === 'line') { | |
| chartSeries.forEach(function(s, i) { | |
| var props = { | |
| type: s.type || 'monotone', | |
| dataKey: s.dataKey, | |
| name: s.label || s.dataKey, | |
| stroke: s.color || COLORS[i % COLORS.length], | |
| strokeWidth: 2, | |
| dot: s.dot !== undefined ? s.dot : { r: 4 }, | |
| connectNulls: s.connectNulls !== undefined ? s.connectNulls : true, | |
| isAnimationActive: false | |
| }; | |
| if (s.strokeDasharray !== undefined) props.strokeDasharray = s.strokeDasharray; | |
| if (s.activeDot !== undefined) props.activeDot = s.activeDot; | |
| if (s.label !== undefined) props.label = s.label; | |
| if (s.unit !== undefined) props.unit = s.unit; | |
| if (s.fillOpacity !== undefined) props.fillOpacity = s.fillOpacity; | |
| seriesEls.push(h(rc.Line, props)); | |
| }); | |
| } else if (chartType === 'area') { | |
| chartSeries.forEach(function(s, i) { | |
| var props = { | |
| type: s.type || 'monotone', | |
| dataKey: s.dataKey, | |
| name: s.label || s.dataKey, | |
| fill: s.color || COLORS[i % COLORS.length], | |
| stroke: s.color || COLORS[i % COLORS.length], | |
| strokeWidth: 2, | |
| connectNulls: s.connectNulls !== undefined ? s.connectNulls : true, | |
| isAnimationActive: false | |
| }; | |
| if (s.fillOpacity !== undefined) props.fillOpacity = s.fillOpacity; | |
| if (s.baseValue !== undefined) props.baseValue = s.baseValue; | |
| if (s.strokeDasharray !== undefined) props.strokeDasharray = s.strokeDasharray; | |
| if (s.dot !== undefined) props.dot = s.dot; | |
| if (s.activeDot !== undefined) props.activeDot = s.activeDot; | |
| if (s.label !== undefined) props.label = s.label; | |
| if (s.unit !== undefined) props.unit = s.unit; | |
| if (s.stackId !== undefined) props.stackId = s.stackId; | |
| seriesEls.push(h(rc.Area, props)); | |
| }); | |
| } else if (chartType === 'scatter') { | |
| chartSeries.forEach(function(s, i) { | |
| var props = { | |
| name: s.label || s.dataKey, | |
| dataKey: s.dataKey, | |
| fill: s.color || COLORS[i % COLORS.length] | |
| }; | |
| if (s.shape !== undefined) props.shape = s.shape; | |
| if (s.activeDot !== undefined) props.activeDot = s.activeDot; | |
| if (s.line !== undefined) props.line = s.line; | |
| if (s.lineJointType !== undefined) props.lineJointType = s.lineJointType; | |
| if (s.unit !== undefined) props.unit = s.unit; | |
| seriesEls.push(h(rc.Scatter, props)); | |
| }); | |
| } else if (chartType === 'pie' || chartType === 'donut') { | |
| chartSeries.forEach(function(s, i) { | |
| var actualDataKey = valueKey || s.dataKey || 'value'; | |
| var actualNameKey = nameKey || 'name'; | |
| // Inject fill color into data items (Recharts v2 UMD: Cell children don't work reliably) | |
| var pieColoredData = chartData.map(function(row, ri) { | |
| var copy = {}; | |
| for (var _k in row) { copy[_k] = row[_k]; } | |
| copy.fill = COLORS[ri % COLORS.length]; | |
| return copy; | |
| }); | |
| // Pie label formatter: use formattedData for display values | |
| var pieRawToFormatted = {}; | |
| if (chartData && formattedData && chartData.length === formattedData.length) { | |
| for (var _pi = 0; _pi < chartData.length; _pi++) { | |
| var _pRaw = chartData[_pi]; | |
| var _pFmt = formattedData[_pi]; | |
| var _pRawVal = _pRaw[actualDataKey]; | |
| if (_pRawVal !== undefined && _pRawVal !== null && _pFmt) { | |
| pieRawToFormatted[String(_pRawVal)] = { | |
| name: _pFmt[actualNameKey] || _pRaw[actualNameKey], | |
| formatted: _pFmt[s.dataKey] | |
| }; | |
| } | |
| } | |
| } | |
| var labelFormatter = function(entry) { | |
| var rawVal = String(entry.value); | |
| var matched = pieRawToFormatted[rawVal]; | |
| if (matched) { | |
| return matched.name + ' ' + matched.formatted; | |
| } | |
| return String(entry.name) + ' ' + entry.value; | |
| }; | |
| var pieProps = { | |
| data: pieColoredData, | |
| dataKey: actualDataKey, | |
| nameKey: actualNameKey, | |
| name: s.label || s.dataKey, | |
| cx: '50%', cy: '50%', | |
| outerRadius: 100, | |
| innerRadius: 60, | |
| isAnimationActive: false, | |
| label: labelFormatter | |
| }; | |
| if (s.paddingAngle !== undefined) pieProps.paddingAngle = s.paddingAngle; | |
| if (s.cornerRadius !== undefined) pieProps.cornerRadius = s.cornerRadius; | |
| if (s.startAngle !== undefined) pieProps.startAngle = s.startAngle; | |
| if (s.endAngle !== undefined) pieProps.endAngle = s.endAngle; | |
| if (s.minAngle !== undefined) pieProps.minAngle = s.minAngle; | |
| seriesEls.push(h(rc.Pie, pieProps)); | |
| }); | |
| } else if (chartType === 'radar') { | |
| chartSeries.forEach(function(s, i) { | |
| var props = { | |
| dataKey: s.dataKey, | |
| name: s.label || s.dataKey, | |
| stroke: s.color || COLORS[i % COLORS.length], | |
| fill: s.color || COLORS[i % COLORS.length], | |
| fillOpacity: 0.3 | |
| }; | |
| if (s.dot !== undefined) props.dot = s.dot; | |
| if (s.activeDot !== undefined) props.activeDot = s.activeDot; | |
| if (s.strokeWidth !== undefined) props.strokeWidth = s.strokeWidth; | |
| if (s.unit !== undefined) props.unit = s.unit; | |
| seriesEls.push(h(rc.Radar, props)); | |
| }); | |
| } else { | |
| // Fallback: unsupported chart type | |
| document.getElementById('widget-container').innerHTML = '<div style="padding:20px;color:#666;">Unsupported chart type: ' + chartType + '</div>'; | |
| window._widgetRendered = true; | |
| return; | |
| } | |
| // Select chart component | |
| var ChartComponent; | |
| switch (chartType) { | |
| case 'bar': ChartComponent = rc.BarChart; break; | |
| case 'line': ChartComponent = rc.LineChart; break; | |
| case 'area': ChartComponent = rc.AreaChart; break; | |
| case 'scatter': ChartComponent = rc.ScatterChart; break; | |
| case 'pie': case 'donut': ChartComponent = rc.PieChart; break; | |
| case 'radar': ChartComponent = rc.RadarChart; break; | |
| default: ChartComponent = rc.BarChart; | |
| } | |
| var chartProps = { data: chartData, width: 720, height: 300 }; | |
| if (chartType === 'radar') { | |
| chartProps.cx = '50%'; | |
| chartProps.cy = '50%'; | |
| chartProps.outerRadius = 100; | |
| } else if (chartType === 'scatter') { | |
| chartProps.xAxisId = 0; | |
| } | |
| var gridEls = []; | |
| if (chartType !== 'pie' && chartType !== 'donut' && chartType !== 'radar') { | |
| gridEls.push(h(rc.CartesianGrid, { strokeDasharray: '3 3', key: 'grid' })); | |
| var axisKey = chartType === 'scatter' ? scatterXKey : xKey; | |
| // ── XAxis: use axes[].dimension from API JSON ── | |
| // Find X axis config from axes array by dimension="x" | |
| var xAxisConfig = null; | |
| for (var _xi = 0; _xi < chartAxes.length; _xi++) { | |
| if (chartAxes[_xi].dimension === 'x') { | |
| xAxisConfig = chartAxes[_xi]; | |
| break; | |
| } | |
| } | |
| var xAxisProps = { dataKey: axisKey, key: 'x' }; | |
| if (xAxisConfig && xAxisConfig.domain) { xAxisProps.domain = xAxisConfig.domain; } | |
| if (xAxisConfig && xAxisConfig.ticks) { | |
| var _xTickMap = {}; | |
| for (var _xti = 0; _xti < xAxisConfig.ticks.length; _xti++) { | |
| _xTickMap[String(xAxisConfig.ticks[_xti].value)] = xAxisConfig.ticks[_xti].label; | |
| } | |
| xAxisProps.tickFormatter = function(v) { var k = String(v); return _xTickMap[k] !== undefined ? _xTickMap[k] : String(v); }; | |
| } | |
| if (xAxisConfig && xAxisConfig.label) { xAxisProps.label = xAxisConfig.label; } | |
| gridEls.push(h(rc.XAxis, xAxisProps)); | |
| // ── YAxis: use axes[].dimension from API JSON, linked by series[].axisId ── | |
| var yAxisId = chartSeries[0] ? (chartSeries[0].axisId || null) : null; | |
| var yAxisConfig = yAxisId ? buildAxisConfig(yAxisId) : buildAxisConfig(null); | |
| var yAxisProps = { key: 'y', tickFormatter: yAxisConfig.tickFormatter }; | |
| if (yAxisConfig.domain) { yAxisProps.domain = yAxisConfig.domain; } | |
| if (yAxisConfig.ticks) { yAxisProps.ticks = yAxisConfig.ticks; } | |
| // Use axes[].label from API JSON for axis title (e.g., "用户数") | |
| if (yAxisConfig.label) { yAxisProps.label = yAxisConfig.label; } | |
| gridEls.push(h(rc.YAxis, yAxisProps)); | |
| } | |
| gridEls.push(h(rc.Tooltip, { key: 'tooltip' })); | |
| // Legend: for pie/donut charts, use custom payload to show category names | |
| // instead of the series name ("占比") | |
| if (chartType === 'pie' || chartType === 'donut') { | |
| var pieActualNameKey = nameKey || 'name'; | |
| var legendPayload = chartData.map(function(row, i) { | |
| var fmt = formattedData[i]; | |
| return { | |
| id: i, | |
| value: fmt ? (fmt[pieActualNameKey] || row[pieActualNameKey]) : (row[pieActualNameKey] || 'Item'), | |
| type: 'rect', | |
| color: COLORS[i % COLORS.length] | |
| }; | |
| }); | |
| gridEls.push(h(rc.Legend, { key: 'legend', payload: legendPayload })); | |
| } else { | |
| gridEls.push(h(rc.Legend, { key: 'legend' })); | |
| } | |
| var chart = h(ChartComponent, chartProps, | |
| gridEls.concat(seriesEls) | |
| ); | |
| // Use ReactDOM.render for Recharts v2.12.7 compatibility | |
| // Render chart directly without ResponsiveContainer to avoid coordinate scaling issues | |
| var el = h('div', {}, | |
| chartTitle && h('div', { className: 'chart-title' }, chartTitle), | |
| chartDesc && h('div', { className: 'chart-subtitle' }, chartDesc), | |
| chart | |
| ); | |
| if (ReactDOM.render) { | |
| ReactDOM.render(el, document.getElementById('widget-container')); | |
| } else { | |
| ReactDOM.createRoot(document.getElementById('widget-container')).render(el); | |
| } | |
| window._widgetRendered = true; | |
| } catch (err) { | |
| document.getElementById('widget-container').innerHTML = '<div style="padding:20px;color:#c00;">Chart render error: ' + err.message + '</div>'; | |
| window._widgetRendered = true; | |
| } | |
| })(); | |
| <\/script> | |
| </body></html>`; | |
| } | |
| module.exports = { buildChatGPTChartHtml }; | |