const fs = require("node:fs"); const path = require("node:path"); const { ChartJSNodeCanvas } = require("chartjs-node-canvas"); const chartCanvas = new ChartJSNodeCanvas({ width: 1400, height: 720, backgroundColour: "white" }); function normalizeJsonSeries(data) { if (Array.isArray(data)) { if (data.every(Number.isFinite)) { return { labels: data.map((_, index) => index), series: [{ label: "value", data }] }; } if (data.every((item) => item && typeof item === "object" && !Array.isArray(item))) { const numericFields = [...new Set(data.flatMap(Object.keys))] .filter((field) => data.some((item) => Number.isFinite(item[field]))); const xField = ["t", "time", "x", "timestamp", "index", "distance"] .find((field) => numericFields.includes(field)); const valueFields = numericFields.filter((field) => field !== xField); const isTravelStability = xField === "distance" && valueFields.length === 1 && valueFields[0] === "pressure"; return { labels: data.map((item, index) => xField ? item[xField] : index), series: valueFields.map((field) => ({ label: field, data: isTravelStability ? data.filter((item) => Number.isFinite(item.distance) && Number.isFinite(item[field])) .map((item) => ({ x: item.distance, y: item[field] })) : data.map((item) => Number.isFinite(item[field]) ? item[field] : null) })), chartKind: isTravelStability ? "travel-stability" : "line", xLabel: xField === "distance" ? "行程" : "采样点 / 时间", yLabel: isTravelStability ? "稳态压力 (kPa)" : "数值" }; } } if (data && typeof data === "object") { const arrays = Object.entries(data).filter(([, value]) => Array.isArray(value)); if (arrays.length === 1) return normalizeJsonSeries(arrays[0][1]); const numericArrays = arrays.filter(([, values]) => values.length > 0 && values.every(Number.isFinite) ); if (numericArrays.length > 0) { const xEntry = numericArrays.find(([field]) => ["t", "time", "x", "timestamp", "index"].includes(field) ); const seriesEntries = numericArrays.filter(([field]) => !xEntry || field !== xEntry[0]); const pointCount = Math.max(...numericArrays.map(([, values]) => values.length)); return { labels: xEntry ? xEntry[1] : Array.from({ length: pointCount }, (_, index) => index), series: seriesEntries.map(([label, values]) => ({ label, data: values })) }; } } throw new Error("JSON 必须包含数字数组、数值对象数组或多个数值数组字段"); } const travelPointLabels = { id: "travelPointLabels", afterDatasetsDraw(chart) { if (chart.options.plugins.travelPointLabels !== true) return; const { ctx } = chart; ctx.save(); ctx.fillStyle = "#15251f"; ctx.font = "12px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "bottom"; for (const meta of chart.getSortedVisibleDatasetMetas()) { meta.data.forEach((element, index) => { const point = chart.data.datasets[meta.index].data[index]; ctx.fillText(`(${point.x}, ${Number(point.y).toFixed(2)})`, element.x + 7, element.y - 7); }); } ctx.restore(); } }; function buildChartConfiguration(normalized, title) { const isTravelStability = normalized.chartKind === "travel-stability"; const colors = ["#d62828", "#0077b6", "#2a9d8f", "#f77f00", "#6a4c93", "#495057"]; return { type: isTravelStability ? "scatter" : "line", data: { labels: isTravelStability ? undefined : normalized.labels, datasets: normalized.series.map((item, index) => ({ ...item, borderColor: colors[index % colors.length], borderWidth: 2, pointRadius: isTravelStability ? 4 : 0, pointHoverRadius: isTravelStability ? 7 : 3, showLine: isTravelStability, tension: 0, fill: false })) }, options: { responsive: false, animation: false, layout: isTravelStability ? { padding: { top: 24, right: 92 } } : undefined, plugins: { title: { display: true, text: title }, legend: { display: true }, travelPointLabels: isTravelStability }, scales: { x: isTravelStability ? { type: "linear", min: 0, max: 1000, title: { display: true, text: normalized.xLabel } } : { title: { display: true, text: normalized.xLabel } }, y: { title: { display: true, text: normalized.yLabel } } } }, plugins: isTravelStability ? [travelPointLabels] : [] }; } async function plotJson(jsonPath) { const content = await fs.promises.readFile(jsonPath, "utf8"); const normalized = normalizeJsonSeries(JSON.parse(content)); if (normalized.series.length === 0) throw new Error("JSON 数组中没有可绘制的数值字段"); const image = await chartCanvas.renderToBuffer(buildChartConfiguration(normalized, path.basename(jsonPath))); const parsedPath = path.parse(jsonPath); const outputPath = path.join(parsedPath.dir, `${parsedPath.name}-line.png`); await fs.promises.writeFile(outputPath, image); console.log(`[${new Date().toISOString()}] 已生成 JSON 折线图: ${outputPath}`); return outputPath; } module.exports = { buildChartConfiguration, normalizeJsonSeries, plotJson };