XWX-AI commited on
Commit
6800ea9
·
1 Parent(s): 4574c25

refactor(chart): 提取buildChatGPTChartHtml为共享模块lib/chatgpt-chart-html.js(课题2,备忘录34)——server.js类方法改为转发,函数体逐字节一致仅类方法→独立函数;桌面APP经make/build-pdf-render-assets.mjs逐字节同步同一文件(漂移校验报错),chatgpt_chart渲染两端单一事实源

Browse files
Files changed (2) hide show
  1. lib/chatgpt-chart-html.js +418 -0
  2. server.js +6 -407
lib/chatgpt-chart-html.js ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * buildChatGPTChartHtml — ChatGPT charts_widget_v2 数据 → 完整渲染 HTML
3
+ *
4
+ * 单一事实源:backend-service(本目录)与桌面 APP(xwx-backup/app/src-tauri/pdf-render/)
5
+ * 共用同一份实现。桌面端通过 make/build-pdf-render-assets.mjs 同步并做指纹校验。
6
+ * 提取自 server.js WidgetRenderer.buildChatGPTChatHtml 原实现(函数体逐字节一致,
7
+ * 仅类方法→独立函数)。修改时保持两端同步流程:先改这里,再跑 make 脚本同步。
8
+ */
9
+ function buildChatGPTChartHtml(chartType, series, data, xKey, nameKey, valueKey, formattedData, axes, title, description) {
10
+ // ChatGPT official chart color palette (extracted from live ChatGPT web)
11
+ // ChatGPT official chart color palette (first 4) + D3 schemeCategory10 extended (10 total)
12
+ // D3 schemeCategory10: industry-standard categorical palette, colorblind-friendly
13
+ const COLORS = ['#339CFF', '#40C977', '#FF8549', '#FFD240', '#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#06b6d4'];
14
+
15
+ // Serialize data safely for inline script
16
+ const dataJson = JSON.stringify(data || [])
17
+ .replace(/<\/script/gi, '<\\/script');
18
+ const seriesJson = JSON.stringify(series || [])
19
+ .replace(/<\/script/gi, '<\\/script');
20
+ const chartTypeJson = JSON.stringify(chartType || 'bar')
21
+ .replace(/<\/script/gi, '<\\/script');
22
+ const titleJson = JSON.stringify(title || '')
23
+ .replace(/<\/script/gi, '<\\/script');
24
+ const descJson = JSON.stringify(description || '')
25
+ .replace(/<\/script/gi, '<\\/script');
26
+ const xKeyJson = JSON.stringify(xKey || 'x')
27
+ .replace(/<\/script/gi, '<\\/script');
28
+ const nameKeyJson = JSON.stringify(nameKey || '')
29
+ .replace(/<\/script/gi, '<\\/script');
30
+ const valueKeyJson = JSON.stringify(valueKey || '')
31
+ .replace(/<\/script/gi, '<\\/script');
32
+ const formattedDataJson = JSON.stringify(formattedData || [])
33
+ .replace(/<\/script/gi, '<\\/script');
34
+ const axesJson = JSON.stringify(axes || [])
35
+ .replace(/<\/script/gi, '<\\/script');
36
+
37
+ return `<!DOCTYPE html>
38
+ <html><head><meta charset="UTF-8">
39
+ <style>
40
+ body { margin: 0; padding: 12px; background: #ffffff; font-family: system-ui, -apple-system, sans-serif; }
41
+ .chart-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; color: #111827; }
42
+ .chart-subtitle { font-size: 12px; color: #6b7280; margin-bottom: 12px; }
43
+ #widget-container { width: 720px; min-height: 300px; }
44
+ /* Widen container to prevent last data point dot from overflowing SVG boundary.
45
+ Recharts plots the last point at the plotting area edge; the dot (r=4-6) extends
46
+ beyond. 720px gives ~70px extra right padding after the Y-axis. */
47
+ .recharts-wrapper { margin: 0 auto; }
48
+ </style>
49
+ <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"><\/script>
50
+ <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"><\/script>
51
+ <script src="https://unpkg.com/react-is@18.2.0/umd/react-is.production.min.js"><\/script>
52
+ <script src="https://unpkg.com/prop-types@15.8.1/prop-types.min.js"><\/script>
53
+ <script src="https://unpkg.com/recharts@2.12.7/umd/Recharts.js"><\/script>
54
+ </head><body>
55
+ <div id="widget-container"></div>
56
+ <script>
57
+ window._widgetRendered = false;
58
+ (function() {
59
+ try {
60
+ var chartData = ${dataJson};
61
+ var chartSeries = ${seriesJson};
62
+ var chartTitle = ${titleJson};
63
+ var chartDesc = ${descJson};
64
+ var xKey = ${xKeyJson};
65
+ var chartType = ${chartTypeJson};
66
+ var nameKey = ${nameKeyJson};
67
+ var valueKey = ${valueKeyJson};
68
+ var formattedData = ${formattedDataJson};
69
+ var chartAxes = ${axesJson};
70
+
71
+ var COLORS = ${JSON.stringify(COLORS)};
72
+
73
+ var rc = Recharts;
74
+ var h = React.createElement;
75
+
76
+ // ── Scatter chart: detect x-axis key ──
77
+ var scatterXKey = xKey;
78
+ if (chartType === 'scatter' && chartData.length > 0) {
79
+ var sFirstRow = chartData[0];
80
+ var sKeys = Object.keys(sFirstRow);
81
+ var seriesDataKey = chartSeries[0] ? chartSeries[0].dataKey : null;
82
+ // Find a non-series key for x-axis (prefer non-numeric for category axis)
83
+ for (var si = 0; si < sKeys.length; si++) {
84
+ if (sKeys[si] !== seriesDataKey) {
85
+ scatterXKey = sKeys[si];
86
+ break;
87
+ }
88
+ }
89
+ }
90
+
91
+ // ── Universal axis configuration builder ──
92
+ // Uses pre-calculated axis data from API JSON (axes[].ticks[].label)
93
+ // instead of procedural formatting. This is the canonical approach:
94
+ // ChatGPT's AI generates exact tick labels in the API JSON, and we
95
+ // pass them directly to Recharts via the tick prop.
96
+
97
+ function buildAxisConfig(axisId) {
98
+ // Find matching axis from chartAxes by id
99
+ var axisConfig = null;
100
+ for (var ai = 0; ai < chartAxes.length; ai++) {
101
+ if (chartAxes[ai].id === axisId) {
102
+ axisConfig = chartAxes[ai];
103
+ break;
104
+ }
105
+ }
106
+ if (!axisConfig) {
107
+ // Fallback: use series-level formatting if no axes data
108
+ var s = chartSeries[0] || {};
109
+ var prefix = s.valuePrefix || '';
110
+ var suffix = s.valueSuffix || '';
111
+ var format = s.valueFormat || 'standard';
112
+ return {
113
+ tickFormatter: function(value) {
114
+ var num = Number(value);
115
+ if (isNaN(num)) return String(value);
116
+ var display;
117
+ if (format === 'compact') {
118
+ if (Math.abs(num) >= 1e9) display = (num / 1e9).toFixed(0) + 'B';
119
+ else if (Math.abs(num) >= 1e6) display = (num / 1e6).toFixed(0) + 'M';
120
+ else if (Math.abs(num) >= 1e3) display = (num / 1e3).toFixed(0) + 'K';
121
+ else display = String(num);
122
+ } else {
123
+ display = String(num);
124
+ }
125
+ return prefix + display + suffix;
126
+ }
127
+ };
128
+ }
129
+
130
+ // Build tick lookup map: value → label
131
+ var tickMap = {};
132
+ for (var ti = 0; ti < axisConfig.ticks.length; ti++) {
133
+ tickMap[String(axisConfig.ticks[ti].value)] = axisConfig.ticks[ti].label;
134
+ }
135
+ var tickValues = axisConfig.ticks.map(function(t) { return t.value; });
136
+
137
+ return {
138
+ domain: axisConfig.domain || undefined,
139
+ ticks: tickValues,
140
+ tickFormatter: function(value) {
141
+ var key = String(value);
142
+ // Direct lookup for exact match
143
+ if (tickMap[key] !== undefined) return tickMap[key];
144
+ // Fallback: procedural formatting for interpolated values
145
+ var prefix = '';
146
+ var suffix = '';
147
+ var s0 = chartSeries[0] || {};
148
+ prefix = s0.valuePrefix || '';
149
+ suffix = s0.valueSuffix || '';
150
+ var num = Number(value);
151
+ if (isNaN(num)) return String(value);
152
+ if (Math.abs(num) >= 1e9) return prefix + (num / 1e9).toFixed(0) + 'B' + suffix;
153
+ if (Math.abs(num) >= 1e6) return prefix + (num / 1e6).toFixed(0) + 'M' + suffix;
154
+ if (Math.abs(num) >= 1e3) return prefix + (num / 1e3).toFixed(0) + 'K' + suffix;
155
+ return prefix + String(num) + suffix;
156
+ },
157
+ label: axisConfig.label || undefined
158
+ };
159
+ }
160
+
161
+ // Build series elements
162
+ var seriesEls = [];
163
+ // Each series may carry additional Recharts props (stackId, radius, type, etc.)
164
+ // that AI could generate. We pass them through to the component props.
165
+ if (chartType === 'bar') {
166
+ chartSeries.forEach(function(s, i) {
167
+ var props = {
168
+ dataKey: s.dataKey,
169
+ name: s.label || s.dataKey,
170
+ fill: s.color || COLORS[i % COLORS.length]
171
+ };
172
+ if (s.stackId !== undefined) props.stackId = s.stackId;
173
+ if (s.radius !== undefined) props.radius = s.radius;
174
+ if (s.barSize !== undefined) props.barSize = s.barSize;
175
+ if (s.maxBarSize !== undefined) props.maxBarSize = s.maxBarSize;
176
+ if (s.stroke !== undefined) props.stroke = s.stroke;
177
+ if (s.strokeWidth !== undefined) props.strokeWidth = s.strokeWidth;
178
+ if (s.unit !== undefined) props.unit = s.unit;
179
+ if (s.label !== undefined) props.label = s.label;
180
+ seriesEls.push(h(rc.Bar, props));
181
+ });
182
+ } else if (chartType === 'line') {
183
+ chartSeries.forEach(function(s, i) {
184
+ var props = {
185
+ type: s.type || 'monotone',
186
+ dataKey: s.dataKey,
187
+ name: s.label || s.dataKey,
188
+ stroke: s.color || COLORS[i % COLORS.length],
189
+ strokeWidth: 2,
190
+ dot: s.dot !== undefined ? s.dot : { r: 4 },
191
+ connectNulls: s.connectNulls !== undefined ? s.connectNulls : true,
192
+ isAnimationActive: false
193
+ };
194
+ if (s.strokeDasharray !== undefined) props.strokeDasharray = s.strokeDasharray;
195
+ if (s.activeDot !== undefined) props.activeDot = s.activeDot;
196
+ if (s.label !== undefined) props.label = s.label;
197
+ if (s.unit !== undefined) props.unit = s.unit;
198
+ if (s.fillOpacity !== undefined) props.fillOpacity = s.fillOpacity;
199
+ seriesEls.push(h(rc.Line, props));
200
+ });
201
+ } else if (chartType === 'area') {
202
+ chartSeries.forEach(function(s, i) {
203
+ var props = {
204
+ type: s.type || 'monotone',
205
+ dataKey: s.dataKey,
206
+ name: s.label || s.dataKey,
207
+ fill: s.color || COLORS[i % COLORS.length],
208
+ stroke: s.color || COLORS[i % COLORS.length],
209
+ strokeWidth: 2,
210
+ connectNulls: s.connectNulls !== undefined ? s.connectNulls : true,
211
+ isAnimationActive: false
212
+ };
213
+ if (s.fillOpacity !== undefined) props.fillOpacity = s.fillOpacity;
214
+ if (s.baseValue !== undefined) props.baseValue = s.baseValue;
215
+ if (s.strokeDasharray !== undefined) props.strokeDasharray = s.strokeDasharray;
216
+ if (s.dot !== undefined) props.dot = s.dot;
217
+ if (s.activeDot !== undefined) props.activeDot = s.activeDot;
218
+ if (s.label !== undefined) props.label = s.label;
219
+ if (s.unit !== undefined) props.unit = s.unit;
220
+ if (s.stackId !== undefined) props.stackId = s.stackId;
221
+ seriesEls.push(h(rc.Area, props));
222
+ });
223
+ } else if (chartType === 'scatter') {
224
+ chartSeries.forEach(function(s, i) {
225
+ var props = {
226
+ name: s.label || s.dataKey,
227
+ dataKey: s.dataKey,
228
+ fill: s.color || COLORS[i % COLORS.length]
229
+ };
230
+ if (s.shape !== undefined) props.shape = s.shape;
231
+ if (s.activeDot !== undefined) props.activeDot = s.activeDot;
232
+ if (s.line !== undefined) props.line = s.line;
233
+ if (s.lineJointType !== undefined) props.lineJointType = s.lineJointType;
234
+ if (s.unit !== undefined) props.unit = s.unit;
235
+ seriesEls.push(h(rc.Scatter, props));
236
+ });
237
+ } else if (chartType === 'pie' || chartType === 'donut') {
238
+ chartSeries.forEach(function(s, i) {
239
+ var actualDataKey = valueKey || s.dataKey || 'value';
240
+ var actualNameKey = nameKey || 'name';
241
+
242
+ // Inject fill color into data items (Recharts v2 UMD: Cell children don't work reliably)
243
+ var pieColoredData = chartData.map(function(row, ri) {
244
+ var copy = {};
245
+ for (var _k in row) { copy[_k] = row[_k]; }
246
+ copy.fill = COLORS[ri % COLORS.length];
247
+ return copy;
248
+ });
249
+
250
+ // Pie label formatter: use formattedData for display values
251
+ var pieRawToFormatted = {};
252
+ if (chartData && formattedData && chartData.length === formattedData.length) {
253
+ for (var _pi = 0; _pi < chartData.length; _pi++) {
254
+ var _pRaw = chartData[_pi];
255
+ var _pFmt = formattedData[_pi];
256
+ var _pRawVal = _pRaw[actualDataKey];
257
+ if (_pRawVal !== undefined && _pRawVal !== null && _pFmt) {
258
+ pieRawToFormatted[String(_pRawVal)] = {
259
+ name: _pFmt[actualNameKey] || _pRaw[actualNameKey],
260
+ formatted: _pFmt[s.dataKey]
261
+ };
262
+ }
263
+ }
264
+ }
265
+
266
+ var labelFormatter = function(entry) {
267
+ var rawVal = String(entry.value);
268
+ var matched = pieRawToFormatted[rawVal];
269
+ if (matched) {
270
+ return matched.name + ' ' + matched.formatted;
271
+ }
272
+ return String(entry.name) + ' ' + entry.value;
273
+ };
274
+
275
+ var pieProps = {
276
+ data: pieColoredData,
277
+ dataKey: actualDataKey,
278
+ nameKey: actualNameKey,
279
+ name: s.label || s.dataKey,
280
+ cx: '50%', cy: '50%',
281
+ outerRadius: 100,
282
+ innerRadius: 60,
283
+ isAnimationActive: false,
284
+ label: labelFormatter
285
+ };
286
+ if (s.paddingAngle !== undefined) pieProps.paddingAngle = s.paddingAngle;
287
+ if (s.cornerRadius !== undefined) pieProps.cornerRadius = s.cornerRadius;
288
+ if (s.startAngle !== undefined) pieProps.startAngle = s.startAngle;
289
+ if (s.endAngle !== undefined) pieProps.endAngle = s.endAngle;
290
+ if (s.minAngle !== undefined) pieProps.minAngle = s.minAngle;
291
+ seriesEls.push(h(rc.Pie, pieProps));
292
+ });
293
+ } else if (chartType === 'radar') {
294
+ chartSeries.forEach(function(s, i) {
295
+ var props = {
296
+ dataKey: s.dataKey,
297
+ name: s.label || s.dataKey,
298
+ stroke: s.color || COLORS[i % COLORS.length],
299
+ fill: s.color || COLORS[i % COLORS.length],
300
+ fillOpacity: 0.3
301
+ };
302
+ if (s.dot !== undefined) props.dot = s.dot;
303
+ if (s.activeDot !== undefined) props.activeDot = s.activeDot;
304
+ if (s.strokeWidth !== undefined) props.strokeWidth = s.strokeWidth;
305
+ if (s.unit !== undefined) props.unit = s.unit;
306
+ seriesEls.push(h(rc.Radar, props));
307
+ });
308
+ } else {
309
+ // Fallback: unsupported chart type
310
+ document.getElementById('widget-container').innerHTML = '<div style="padding:20px;color:#666;">Unsupported chart type: ' + chartType + '</div>';
311
+ window._widgetRendered = true;
312
+ return;
313
+ }
314
+
315
+ // Select chart component
316
+ var ChartComponent;
317
+ switch (chartType) {
318
+ case 'bar': ChartComponent = rc.BarChart; break;
319
+ case 'line': ChartComponent = rc.LineChart; break;
320
+ case 'area': ChartComponent = rc.AreaChart; break;
321
+ case 'scatter': ChartComponent = rc.ScatterChart; break;
322
+ case 'pie': case 'donut': ChartComponent = rc.PieChart; break;
323
+ case 'radar': ChartComponent = rc.RadarChart; break;
324
+ default: ChartComponent = rc.BarChart;
325
+ }
326
+
327
+ var chartProps = { data: chartData, width: 720, height: 300 };
328
+ if (chartType === 'radar') {
329
+ chartProps.cx = '50%';
330
+ chartProps.cy = '50%';
331
+ chartProps.outerRadius = 100;
332
+ } else if (chartType === 'scatter') {
333
+ chartProps.xAxisId = 0;
334
+ }
335
+
336
+ var gridEls = [];
337
+ if (chartType !== 'pie' && chartType !== 'donut' && chartType !== 'radar') {
338
+ gridEls.push(h(rc.CartesianGrid, { strokeDasharray: '3 3', key: 'grid' }));
339
+ var axisKey = chartType === 'scatter' ? scatterXKey : xKey;
340
+
341
+ // ── XAxis: use axes[].dimension from API JSON ──
342
+ // Find X axis config from axes array by dimension="x"
343
+ var xAxisConfig = null;
344
+ for (var _xi = 0; _xi < chartAxes.length; _xi++) {
345
+ if (chartAxes[_xi].dimension === 'x') {
346
+ xAxisConfig = chartAxes[_xi];
347
+ break;
348
+ }
349
+ }
350
+ var xAxisProps = { dataKey: axisKey, key: 'x' };
351
+ if (xAxisConfig && xAxisConfig.domain) { xAxisProps.domain = xAxisConfig.domain; }
352
+ if (xAxisConfig && xAxisConfig.ticks) {
353
+ var _xTickMap = {};
354
+ for (var _xti = 0; _xti < xAxisConfig.ticks.length; _xti++) {
355
+ _xTickMap[String(xAxisConfig.ticks[_xti].value)] = xAxisConfig.ticks[_xti].label;
356
+ }
357
+ xAxisProps.tickFormatter = function(v) { var k = String(v); return _xTickMap[k] !== undefined ? _xTickMap[k] : String(v); };
358
+ }
359
+ if (xAxisConfig && xAxisConfig.label) { xAxisProps.label = xAxisConfig.label; }
360
+ gridEls.push(h(rc.XAxis, xAxisProps));
361
+
362
+ // ── YAxis: use axes[].dimension from API JSON, linked by series[].axisId ──
363
+ var yAxisId = chartSeries[0] ? (chartSeries[0].axisId || null) : null;
364
+ var yAxisConfig = yAxisId ? buildAxisConfig(yAxisId) : buildAxisConfig(null);
365
+ var yAxisProps = { key: 'y', tickFormatter: yAxisConfig.tickFormatter };
366
+ if (yAxisConfig.domain) { yAxisProps.domain = yAxisConfig.domain; }
367
+ if (yAxisConfig.ticks) { yAxisProps.ticks = yAxisConfig.ticks; }
368
+ // Use axes[].label from API JSON for axis title (e.g., "用户数")
369
+ if (yAxisConfig.label) { yAxisProps.label = yAxisConfig.label; }
370
+ gridEls.push(h(rc.YAxis, yAxisProps));
371
+ }
372
+ gridEls.push(h(rc.Tooltip, { key: 'tooltip' }));
373
+
374
+ // Legend: for pie/donut charts, use custom payload to show category names
375
+ // instead of the series name ("占比")
376
+ if (chartType === 'pie' || chartType === 'donut') {
377
+ var pieActualNameKey = nameKey || 'name';
378
+ var legendPayload = chartData.map(function(row, i) {
379
+ var fmt = formattedData[i];
380
+ return {
381
+ id: i,
382
+ value: fmt ? (fmt[pieActualNameKey] || row[pieActualNameKey]) : (row[pieActualNameKey] || 'Item'),
383
+ type: 'rect',
384
+ color: COLORS[i % COLORS.length]
385
+ };
386
+ });
387
+ gridEls.push(h(rc.Legend, { key: 'legend', payload: legendPayload }));
388
+ } else {
389
+ gridEls.push(h(rc.Legend, { key: 'legend' }));
390
+ }
391
+
392
+ var chart = h(ChartComponent, chartProps,
393
+ gridEls.concat(seriesEls)
394
+ );
395
+
396
+ // Use ReactDOM.render for Recharts v2.12.7 compatibility
397
+ // Render chart directly without ResponsiveContainer to avoid coordinate scaling issues
398
+ var el = h('div', {},
399
+ chartTitle && h('div', { className: 'chart-title' }, chartTitle),
400
+ chartDesc && h('div', { className: 'chart-subtitle' }, chartDesc),
401
+ chart
402
+ );
403
+ if (ReactDOM.render) {
404
+ ReactDOM.render(el, document.getElementById('widget-container'));
405
+ } else {
406
+ ReactDOM.createRoot(document.getElementById('widget-container')).render(el);
407
+ }
408
+ window._widgetRendered = true;
409
+ } catch (err) {
410
+ document.getElementById('widget-container').innerHTML = '<div style="padding:20px;color:#c00;">Chart render error: ' + err.message + '</div>';
411
+ window._widgetRendered = true;
412
+ }
413
+ })();
414
+ <\/script>
415
+ </body></html>`;
416
+ }
417
+
418
+ module.exports = { buildChatGPTChartHtml };
server.js CHANGED
@@ -30,6 +30,8 @@ const fs = require('fs');
30
  const path = require('path');
31
  const os = require('os');
32
  const { BrowserPool } = require('./browser-pool');
 
 
33
 
34
  let ChartJSNodeCanvas = null;
35
  try {
@@ -1041,413 +1043,10 @@ class WidgetRenderer {
1041
  }
1042
  }
1043
 
1044
- buildChatGPTChartHtml(chartType, series, data, xKey, nameKey, valueKey, formattedData, axes, title, description) {
1045
- // ChatGPT official chart color palette (extracted from live ChatGPT web)
1046
- // ChatGPT official chart color palette (first 4) + D3 schemeCategory10 extended (10 total)
1047
- // D3 schemeCategory10: industry-standard categorical palette, colorblind-friendly
1048
- const COLORS = ['#339CFF', '#40C977', '#FF8549', '#FFD240', '#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#06b6d4'];
1049
-
1050
- // Serialize data safely for inline script
1051
- const dataJson = JSON.stringify(data || [])
1052
- .replace(/<\/script/gi, '<\\/script');
1053
- const seriesJson = JSON.stringify(series || [])
1054
- .replace(/<\/script/gi, '<\\/script');
1055
- const chartTypeJson = JSON.stringify(chartType || 'bar')
1056
- .replace(/<\/script/gi, '<\\/script');
1057
- const titleJson = JSON.stringify(title || '')
1058
- .replace(/<\/script/gi, '<\\/script');
1059
- const descJson = JSON.stringify(description || '')
1060
- .replace(/<\/script/gi, '<\\/script');
1061
- const xKeyJson = JSON.stringify(xKey || 'x')
1062
- .replace(/<\/script/gi, '<\\/script');
1063
- const nameKeyJson = JSON.stringify(nameKey || '')
1064
- .replace(/<\/script/gi, '<\\/script');
1065
- const valueKeyJson = JSON.stringify(valueKey || '')
1066
- .replace(/<\/script/gi, '<\\/script');
1067
- const formattedDataJson = JSON.stringify(formattedData || [])
1068
- .replace(/<\/script/gi, '<\\/script');
1069
- const axesJson = JSON.stringify(axes || [])
1070
- .replace(/<\/script/gi, '<\\/script');
1071
-
1072
- return `<!DOCTYPE html>
1073
- <html><head><meta charset="UTF-8">
1074
- <style>
1075
- body { margin: 0; padding: 12px; background: #ffffff; font-family: system-ui, -apple-system, sans-serif; }
1076
- .chart-title { font-size: 16px; font-weight: 600; margin-bottom: 4px; color: #111827; }
1077
- .chart-subtitle { font-size: 12px; color: #6b7280; margin-bottom: 12px; }
1078
- #widget-container { width: 720px; min-height: 300px; }
1079
- /* Widen container to prevent last data point dot from overflowing SVG boundary.
1080
- Recharts plots the last point at the plotting area edge; the dot (r=4-6) extends
1081
- beyond. 720px gives ~70px extra right padding after the Y-axis. */
1082
- .recharts-wrapper { margin: 0 auto; }
1083
- </style>
1084
- <script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"><\/script>
1085
- <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"><\/script>
1086
- <script src="https://unpkg.com/react-is@18.2.0/umd/react-is.production.min.js"><\/script>
1087
- <script src="https://unpkg.com/prop-types@15.8.1/prop-types.min.js"><\/script>
1088
- <script src="https://unpkg.com/recharts@2.12.7/umd/Recharts.js"><\/script>
1089
- </head><body>
1090
- <div id="widget-container"></div>
1091
- <script>
1092
- window._widgetRendered = false;
1093
- (function() {
1094
- try {
1095
- var chartData = ${dataJson};
1096
- var chartSeries = ${seriesJson};
1097
- var chartTitle = ${titleJson};
1098
- var chartDesc = ${descJson};
1099
- var xKey = ${xKeyJson};
1100
- var chartType = ${chartTypeJson};
1101
- var nameKey = ${nameKeyJson};
1102
- var valueKey = ${valueKeyJson};
1103
- var formattedData = ${formattedDataJson};
1104
- var chartAxes = ${axesJson};
1105
-
1106
- var COLORS = ${JSON.stringify(COLORS)};
1107
-
1108
- var rc = Recharts;
1109
- var h = React.createElement;
1110
-
1111
- // ── Scatter chart: detect x-axis key ──
1112
- var scatterXKey = xKey;
1113
- if (chartType === 'scatter' && chartData.length > 0) {
1114
- var sFirstRow = chartData[0];
1115
- var sKeys = Object.keys(sFirstRow);
1116
- var seriesDataKey = chartSeries[0] ? chartSeries[0].dataKey : null;
1117
- // Find a non-series key for x-axis (prefer non-numeric for category axis)
1118
- for (var si = 0; si < sKeys.length; si++) {
1119
- if (sKeys[si] !== seriesDataKey) {
1120
- scatterXKey = sKeys[si];
1121
- break;
1122
- }
1123
- }
1124
- }
1125
-
1126
- // ── Universal axis configuration builder ──
1127
- // Uses pre-calculated axis data from API JSON (axes[].ticks[].label)
1128
- // instead of procedural formatting. This is the canonical approach:
1129
- // ChatGPT's AI generates exact tick labels in the API JSON, and we
1130
- // pass them directly to Recharts via the tick prop.
1131
-
1132
- function buildAxisConfig(axisId) {
1133
- // Find matching axis from chartAxes by id
1134
- var axisConfig = null;
1135
- for (var ai = 0; ai < chartAxes.length; ai++) {
1136
- if (chartAxes[ai].id === axisId) {
1137
- axisConfig = chartAxes[ai];
1138
- break;
1139
- }
1140
- }
1141
- if (!axisConfig) {
1142
- // Fallback: use series-level formatting if no axes data
1143
- var s = chartSeries[0] || {};
1144
- var prefix = s.valuePrefix || '';
1145
- var suffix = s.valueSuffix || '';
1146
- var format = s.valueFormat || 'standard';
1147
- return {
1148
- tickFormatter: function(value) {
1149
- var num = Number(value);
1150
- if (isNaN(num)) return String(value);
1151
- var display;
1152
- if (format === 'compact') {
1153
- if (Math.abs(num) >= 1e9) display = (num / 1e9).toFixed(0) + 'B';
1154
- else if (Math.abs(num) >= 1e6) display = (num / 1e6).toFixed(0) + 'M';
1155
- else if (Math.abs(num) >= 1e3) display = (num / 1e3).toFixed(0) + 'K';
1156
- else display = String(num);
1157
- } else {
1158
- display = String(num);
1159
- }
1160
- return prefix + display + suffix;
1161
- }
1162
- };
1163
- }
1164
-
1165
- // Build tick lookup map: value → label
1166
- var tickMap = {};
1167
- for (var ti = 0; ti < axisConfig.ticks.length; ti++) {
1168
- tickMap[String(axisConfig.ticks[ti].value)] = axisConfig.ticks[ti].label;
1169
- }
1170
- var tickValues = axisConfig.ticks.map(function(t) { return t.value; });
1171
-
1172
- return {
1173
- domain: axisConfig.domain || undefined,
1174
- ticks: tickValues,
1175
- tickFormatter: function(value) {
1176
- var key = String(value);
1177
- // Direct lookup for exact match
1178
- if (tickMap[key] !== undefined) return tickMap[key];
1179
- // Fallback: procedural formatting for interpolated values
1180
- var prefix = '';
1181
- var suffix = '';
1182
- var s0 = chartSeries[0] || {};
1183
- prefix = s0.valuePrefix || '';
1184
- suffix = s0.valueSuffix || '';
1185
- var num = Number(value);
1186
- if (isNaN(num)) return String(value);
1187
- if (Math.abs(num) >= 1e9) return prefix + (num / 1e9).toFixed(0) + 'B' + suffix;
1188
- if (Math.abs(num) >= 1e6) return prefix + (num / 1e6).toFixed(0) + 'M' + suffix;
1189
- if (Math.abs(num) >= 1e3) return prefix + (num / 1e3).toFixed(0) + 'K' + suffix;
1190
- return prefix + String(num) + suffix;
1191
- },
1192
- label: axisConfig.label || undefined
1193
- };
1194
- }
1195
-
1196
- // Build series elements
1197
- var seriesEls = [];
1198
- // Each series may carry additional Recharts props (stackId, radius, type, etc.)
1199
- // that AI could generate. We pass them through to the component props.
1200
- if (chartType === 'bar') {
1201
- chartSeries.forEach(function(s, i) {
1202
- var props = {
1203
- dataKey: s.dataKey,
1204
- name: s.label || s.dataKey,
1205
- fill: s.color || COLORS[i % COLORS.length]
1206
- };
1207
- if (s.stackId !== undefined) props.stackId = s.stackId;
1208
- if (s.radius !== undefined) props.radius = s.radius;
1209
- if (s.barSize !== undefined) props.barSize = s.barSize;
1210
- if (s.maxBarSize !== undefined) props.maxBarSize = s.maxBarSize;
1211
- if (s.stroke !== undefined) props.stroke = s.stroke;
1212
- if (s.strokeWidth !== undefined) props.strokeWidth = s.strokeWidth;
1213
- if (s.unit !== undefined) props.unit = s.unit;
1214
- if (s.label !== undefined) props.label = s.label;
1215
- seriesEls.push(h(rc.Bar, props));
1216
- });
1217
- } else if (chartType === 'line') {
1218
- chartSeries.forEach(function(s, i) {
1219
- var props = {
1220
- type: s.type || 'monotone',
1221
- dataKey: s.dataKey,
1222
- name: s.label || s.dataKey,
1223
- stroke: s.color || COLORS[i % COLORS.length],
1224
- strokeWidth: 2,
1225
- dot: s.dot !== undefined ? s.dot : { r: 4 },
1226
- connectNulls: s.connectNulls !== undefined ? s.connectNulls : true,
1227
- isAnimationActive: false
1228
- };
1229
- if (s.strokeDasharray !== undefined) props.strokeDasharray = s.strokeDasharray;
1230
- if (s.activeDot !== undefined) props.activeDot = s.activeDot;
1231
- if (s.label !== undefined) props.label = s.label;
1232
- if (s.unit !== undefined) props.unit = s.unit;
1233
- if (s.fillOpacity !== undefined) props.fillOpacity = s.fillOpacity;
1234
- seriesEls.push(h(rc.Line, props));
1235
- });
1236
- } else if (chartType === 'area') {
1237
- chartSeries.forEach(function(s, i) {
1238
- var props = {
1239
- type: s.type || 'monotone',
1240
- dataKey: s.dataKey,
1241
- name: s.label || s.dataKey,
1242
- fill: s.color || COLORS[i % COLORS.length],
1243
- stroke: s.color || COLORS[i % COLORS.length],
1244
- strokeWidth: 2,
1245
- connectNulls: s.connectNulls !== undefined ? s.connectNulls : true,
1246
- isAnimationActive: false
1247
- };
1248
- if (s.fillOpacity !== undefined) props.fillOpacity = s.fillOpacity;
1249
- if (s.baseValue !== undefined) props.baseValue = s.baseValue;
1250
- if (s.strokeDasharray !== undefined) props.strokeDasharray = s.strokeDasharray;
1251
- if (s.dot !== undefined) props.dot = s.dot;
1252
- if (s.activeDot !== undefined) props.activeDot = s.activeDot;
1253
- if (s.label !== undefined) props.label = s.label;
1254
- if (s.unit !== undefined) props.unit = s.unit;
1255
- if (s.stackId !== undefined) props.stackId = s.stackId;
1256
- seriesEls.push(h(rc.Area, props));
1257
- });
1258
- } else if (chartType === 'scatter') {
1259
- chartSeries.forEach(function(s, i) {
1260
- var props = {
1261
- name: s.label || s.dataKey,
1262
- dataKey: s.dataKey,
1263
- fill: s.color || COLORS[i % COLORS.length]
1264
- };
1265
- if (s.shape !== undefined) props.shape = s.shape;
1266
- if (s.activeDot !== undefined) props.activeDot = s.activeDot;
1267
- if (s.line !== undefined) props.line = s.line;
1268
- if (s.lineJointType !== undefined) props.lineJointType = s.lineJointType;
1269
- if (s.unit !== undefined) props.unit = s.unit;
1270
- seriesEls.push(h(rc.Scatter, props));
1271
- });
1272
- } else if (chartType === 'pie' || chartType === 'donut') {
1273
- chartSeries.forEach(function(s, i) {
1274
- var actualDataKey = valueKey || s.dataKey || 'value';
1275
- var actualNameKey = nameKey || 'name';
1276
-
1277
- // Inject fill color into data items (Recharts v2 UMD: Cell children don't work reliably)
1278
- var pieColoredData = chartData.map(function(row, ri) {
1279
- var copy = {};
1280
- for (var _k in row) { copy[_k] = row[_k]; }
1281
- copy.fill = COLORS[ri % COLORS.length];
1282
- return copy;
1283
- });
1284
-
1285
- // Pie label formatter: use formattedData for display values
1286
- var pieRawToFormatted = {};
1287
- if (chartData && formattedData && chartData.length === formattedData.length) {
1288
- for (var _pi = 0; _pi < chartData.length; _pi++) {
1289
- var _pRaw = chartData[_pi];
1290
- var _pFmt = formattedData[_pi];
1291
- var _pRawVal = _pRaw[actualDataKey];
1292
- if (_pRawVal !== undefined && _pRawVal !== null && _pFmt) {
1293
- pieRawToFormatted[String(_pRawVal)] = {
1294
- name: _pFmt[actualNameKey] || _pRaw[actualNameKey],
1295
- formatted: _pFmt[s.dataKey]
1296
- };
1297
- }
1298
- }
1299
- }
1300
-
1301
- var labelFormatter = function(entry) {
1302
- var rawVal = String(entry.value);
1303
- var matched = pieRawToFormatted[rawVal];
1304
- if (matched) {
1305
- return matched.name + ' ' + matched.formatted;
1306
- }
1307
- return String(entry.name) + ' ' + entry.value;
1308
- };
1309
-
1310
- var pieProps = {
1311
- data: pieColoredData,
1312
- dataKey: actualDataKey,
1313
- nameKey: actualNameKey,
1314
- name: s.label || s.dataKey,
1315
- cx: '50%', cy: '50%',
1316
- outerRadius: 100,
1317
- innerRadius: 60,
1318
- isAnimationActive: false,
1319
- label: labelFormatter
1320
- };
1321
- if (s.paddingAngle !== undefined) pieProps.paddingAngle = s.paddingAngle;
1322
- if (s.cornerRadius !== undefined) pieProps.cornerRadius = s.cornerRadius;
1323
- if (s.startAngle !== undefined) pieProps.startAngle = s.startAngle;
1324
- if (s.endAngle !== undefined) pieProps.endAngle = s.endAngle;
1325
- if (s.minAngle !== undefined) pieProps.minAngle = s.minAngle;
1326
- seriesEls.push(h(rc.Pie, pieProps));
1327
- });
1328
- } else if (chartType === 'radar') {
1329
- chartSeries.forEach(function(s, i) {
1330
- var props = {
1331
- dataKey: s.dataKey,
1332
- name: s.label || s.dataKey,
1333
- stroke: s.color || COLORS[i % COLORS.length],
1334
- fill: s.color || COLORS[i % COLORS.length],
1335
- fillOpacity: 0.3
1336
- };
1337
- if (s.dot !== undefined) props.dot = s.dot;
1338
- if (s.activeDot !== undefined) props.activeDot = s.activeDot;
1339
- if (s.strokeWidth !== undefined) props.strokeWidth = s.strokeWidth;
1340
- if (s.unit !== undefined) props.unit = s.unit;
1341
- seriesEls.push(h(rc.Radar, props));
1342
- });
1343
- } else {
1344
- // Fallback: unsupported chart type
1345
- document.getElementById('widget-container').innerHTML = '<div style="padding:20px;color:#666;">Unsupported chart type: ' + chartType + '</div>';
1346
- window._widgetRendered = true;
1347
- return;
1348
- }
1349
-
1350
- // Select chart component
1351
- var ChartComponent;
1352
- switch (chartType) {
1353
- case 'bar': ChartComponent = rc.BarChart; break;
1354
- case 'line': ChartComponent = rc.LineChart; break;
1355
- case 'area': ChartComponent = rc.AreaChart; break;
1356
- case 'scatter': ChartComponent = rc.ScatterChart; break;
1357
- case 'pie': case 'donut': ChartComponent = rc.PieChart; break;
1358
- case 'radar': ChartComponent = rc.RadarChart; break;
1359
- default: ChartComponent = rc.BarChart;
1360
- }
1361
-
1362
- var chartProps = { data: chartData, width: 720, height: 300 };
1363
- if (chartType === 'radar') {
1364
- chartProps.cx = '50%';
1365
- chartProps.cy = '50%';
1366
- chartProps.outerRadius = 100;
1367
- } else if (chartType === 'scatter') {
1368
- chartProps.xAxisId = 0;
1369
- }
1370
-
1371
- var gridEls = [];
1372
- if (chartType !== 'pie' && chartType !== 'donut' && chartType !== 'radar') {
1373
- gridEls.push(h(rc.CartesianGrid, { strokeDasharray: '3 3', key: 'grid' }));
1374
- var axisKey = chartType === 'scatter' ? scatterXKey : xKey;
1375
-
1376
- // ── XAxis: use axes[].dimension from API JSON ──
1377
- // Find X axis config from axes array by dimension="x"
1378
- var xAxisConfig = null;
1379
- for (var _xi = 0; _xi < chartAxes.length; _xi++) {
1380
- if (chartAxes[_xi].dimension === 'x') {
1381
- xAxisConfig = chartAxes[_xi];
1382
- break;
1383
- }
1384
- }
1385
- var xAxisProps = { dataKey: axisKey, key: 'x' };
1386
- if (xAxisConfig && xAxisConfig.domain) { xAxisProps.domain = xAxisConfig.domain; }
1387
- if (xAxisConfig && xAxisConfig.ticks) {
1388
- var _xTickMap = {};
1389
- for (var _xti = 0; _xti < xAxisConfig.ticks.length; _xti++) {
1390
- _xTickMap[String(xAxisConfig.ticks[_xti].value)] = xAxisConfig.ticks[_xti].label;
1391
- }
1392
- xAxisProps.tickFormatter = function(v) { var k = String(v); return _xTickMap[k] !== undefined ? _xTickMap[k] : String(v); };
1393
- }
1394
- if (xAxisConfig && xAxisConfig.label) { xAxisProps.label = xAxisConfig.label; }
1395
- gridEls.push(h(rc.XAxis, xAxisProps));
1396
-
1397
- // ── YAxis: use axes[].dimension from API JSON, linked by series[].axisId ──
1398
- var yAxisId = chartSeries[0] ? (chartSeries[0].axisId || null) : null;
1399
- var yAxisConfig = yAxisId ? buildAxisConfig(yAxisId) : buildAxisConfig(null);
1400
- var yAxisProps = { key: 'y', tickFormatter: yAxisConfig.tickFormatter };
1401
- if (yAxisConfig.domain) { yAxisProps.domain = yAxisConfig.domain; }
1402
- if (yAxisConfig.ticks) { yAxisProps.ticks = yAxisConfig.ticks; }
1403
- // Use axes[].label from API JSON for axis title (e.g., "用户数")
1404
- if (yAxisConfig.label) { yAxisProps.label = yAxisConfig.label; }
1405
- gridEls.push(h(rc.YAxis, yAxisProps));
1406
- }
1407
- gridEls.push(h(rc.Tooltip, { key: 'tooltip' }));
1408
-
1409
- // Legend: for pie/donut charts, use custom payload to show category names
1410
- // instead of the series name ("占比")
1411
- if (chartType === 'pie' || chartType === 'donut') {
1412
- var pieActualNameKey = nameKey || 'name';
1413
- var legendPayload = chartData.map(function(row, i) {
1414
- var fmt = formattedData[i];
1415
- return {
1416
- id: i,
1417
- value: fmt ? (fmt[pieActualNameKey] || row[pieActualNameKey]) : (row[pieActualNameKey] || 'Item'),
1418
- type: 'rect',
1419
- color: COLORS[i % COLORS.length]
1420
- };
1421
- });
1422
- gridEls.push(h(rc.Legend, { key: 'legend', payload: legendPayload }));
1423
- } else {
1424
- gridEls.push(h(rc.Legend, { key: 'legend' }));
1425
- }
1426
-
1427
- var chart = h(ChartComponent, chartProps,
1428
- gridEls.concat(seriesEls)
1429
- );
1430
-
1431
- // Use ReactDOM.render for Recharts v2.12.7 compatibility
1432
- // Render chart directly without ResponsiveContainer to avoid coordinate scaling issues
1433
- var el = h('div', {},
1434
- chartTitle && h('div', { className: 'chart-title' }, chartTitle),
1435
- chartDesc && h('div', { className: 'chart-subtitle' }, chartDesc),
1436
- chart
1437
- );
1438
- if (ReactDOM.render) {
1439
- ReactDOM.render(el, document.getElementById('widget-container'));
1440
- } else {
1441
- ReactDOM.createRoot(document.getElementById('widget-container')).render(el);
1442
- }
1443
- window._widgetRendered = true;
1444
- } catch (err) {
1445
- document.getElementById('widget-container').innerHTML = '<div style="padding:20px;color:#c00;">Chart render error: ' + err.message + '</div>';
1446
- window._widgetRendered = true;
1447
- }
1448
- })();
1449
- <\/script>
1450
- </body></html>`;
1451
  }
1452
 
1453
  async renderWidgetPuppeteer(widgetHtml, type, title, renderTimeout) {
 
30
  const path = require('path');
31
  const os = require('os');
32
  const { BrowserPool } = require('./browser-pool');
33
+ // ChatGPT chart HTML 构建器已提取为共享模块(桌面 APP 同步使用,单一事实源)
34
+ const { buildChatGPTChartHtml } = require('./lib/chatgpt-chart-html');
35
 
36
  let ChartJSNodeCanvas = null;
37
  try {
 
1043
  }
1044
  }
1045
 
1046
+ // buildChatGPTChartHtml 已提取到 lib/chatgpt-chart-html.js(桌面 APP 同步使用)。
1047
+ // 保留此转发方法以兼容 this. 调用点;新代码直接 require 模块。
1048
+ buildChatGPTChartHtml(...args) {
1049
+ return buildChatGPTChartHtml(...args);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1050
  }
1051
 
1052
  async renderWidgetPuppeteer(widgetHtml, type, title, renderTimeout) {