135 lines
5.4 KiB
JavaScript
135 lines
5.4 KiB
JavaScript
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 }; |