api-server / lib /chatgpt-chart-html.js
XWX-AI's picture
refactor(chart): 提取buildChatGPTChartHtml为共享模块lib/chatgpt-chart-html.js(课题2,备忘录34)——server.js类方法改为转发,函数体逐字节一致仅类方法→独立函数;桌面APP经make/build-pdf-render-assets.mjs逐字节同步同一文件(漂移校验报错),chatgpt_chart渲染两端单一事实源
6800ea9
Raw History Blame Contribute Delete
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 };