|
@@ -1,46 +1,120 @@
|
|
|
/*
|
|
/*
|
|
|
* @Author: your name
|
|
* @Author: your name
|
|
|
* @Date: 2025-05-13 11:02:32
|
|
* @Date: 2025-05-13 11:02:32
|
|
|
- * @LastEditTime: 2025-07-10 13:59:24
|
|
|
|
|
|
|
+ * @LastEditTime: 2026-03-18 09:11:01
|
|
|
* @LastEditors: bogon
|
|
* @LastEditors: bogon
|
|
|
* @Description: In User Settings Edit
|
|
* @Description: In User Settings Edit
|
|
|
* @FilePath: /downLoadServer/src/server/utils/chartsCom/BoxMarkersCharts.js
|
|
* @FilePath: /downLoadServer/src/server/utils/chartsCom/BoxMarkersCharts.js
|
|
|
*/
|
|
*/
|
|
|
-import puppeteer from "puppeteer";
|
|
|
|
|
-import fs from "fs-extra";
|
|
|
|
|
-import path from "path";
|
|
|
|
|
-import FormData from "form-data";
|
|
|
|
|
-import { colorSchemes } from "../colors.js";
|
|
|
|
|
-import axios from "axios";
|
|
|
|
|
-
|
|
|
|
|
-// 判断时间戳是否在选择的日期范围内
|
|
|
|
|
-const isInDateRange = (timestamp) => {
|
|
|
|
|
- const [startDate, endDate] = [];
|
|
|
|
|
|
|
+// 绘制箱线图
|
|
|
|
|
+/*
|
|
|
|
|
+ * 箱线图 + 中位点(终极稳定版)
|
|
|
|
|
+ */
|
|
|
|
|
+import { renderChart } from "../chartService/index.js";
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 判断时间范围
|
|
|
|
|
+ */
|
|
|
|
|
+const isInDateRange = (timestamp, startDate, endDate) => {
|
|
|
if (!startDate || !endDate) return true;
|
|
if (!startDate || !endDate) return true;
|
|
|
|
|
|
|
|
- const date = new Date(timestamp);
|
|
|
|
|
- return date >= new Date(startDate) && date <= new Date(endDate);
|
|
|
|
|
|
|
+ const t = new Date(timestamp).getTime();
|
|
|
|
|
+ const start = new Date(startDate).getTime();
|
|
|
|
|
+ const end = new Date(endDate).getTime();
|
|
|
|
|
+ if (Number.isNaN(t) || Number.isNaN(start) || Number.isNaN(end)) return true;
|
|
|
|
|
+ return t >= start && t <= end;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const normalizeBoxXValue = (x) => {
|
|
|
|
|
+ const date = new Date(x);
|
|
|
|
|
+ if (!Number.isNaN(date.getTime())) {
|
|
|
|
|
+ return date.toISOString().slice(0, 10);
|
|
|
|
|
+ }
|
|
|
|
|
+ return String(x);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+const quantile = (sortedValues, p) => {
|
|
|
|
|
+ if (!sortedValues.length) return null;
|
|
|
|
|
+ const index = (sortedValues.length - 1) * p;
|
|
|
|
|
+ const lower = Math.floor(index);
|
|
|
|
|
+ const upper = Math.ceil(index);
|
|
|
|
|
+ if (lower === upper) return sortedValues[lower];
|
|
|
|
|
+ return (
|
|
|
|
|
+ sortedValues[lower] +
|
|
|
|
|
+ (sortedValues[upper] - sortedValues[lower]) * (index - lower)
|
|
|
|
|
+ );
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-// 过滤数据
|
|
|
|
|
-const filterData = (group) => {
|
|
|
|
|
|
|
+const buildBoxStats = (xData = [], yData = []) => {
|
|
|
|
|
+ const grouped = new Map();
|
|
|
|
|
+ const len = Math.min(xData.length, yData.length);
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = 0; i < len; i++) {
|
|
|
|
|
+ const x = xData[i];
|
|
|
|
|
+ const y = Number(yData[i]);
|
|
|
|
|
+ if (x === null || x === undefined || x === "") continue;
|
|
|
|
|
+ if (!Number.isFinite(y)) continue;
|
|
|
|
|
+
|
|
|
|
|
+ const key = normalizeBoxXValue(x);
|
|
|
|
|
+ if (!grouped.has(key)) grouped.set(key, []);
|
|
|
|
|
+ grouped.get(key).push(y);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const x = [];
|
|
|
|
|
+ const q1 = [];
|
|
|
|
|
+ const median = [];
|
|
|
|
|
+ const q3 = [];
|
|
|
|
|
+ const lowerfence = [];
|
|
|
|
|
+ const upperfence = [];
|
|
|
|
|
+ const mean = [];
|
|
|
|
|
+
|
|
|
|
|
+ [...grouped.entries()]
|
|
|
|
|
+ .sort(([a], [b]) => a.localeCompare(b))
|
|
|
|
|
+ .forEach(([key, values]) => {
|
|
|
|
|
+ const sortedValues = values.sort((a, b) => a - b);
|
|
|
|
|
+ x.push(key);
|
|
|
|
|
+ q1.push(quantile(sortedValues, 0.25));
|
|
|
|
|
+ median.push(quantile(sortedValues, 0.5));
|
|
|
|
|
+ q3.push(quantile(sortedValues, 0.75));
|
|
|
|
|
+ lowerfence.push(sortedValues[0]);
|
|
|
|
|
+ upperfence.push(sortedValues[sortedValues.length - 1]);
|
|
|
|
|
+ mean.push(
|
|
|
|
|
+ sortedValues.reduce((sum, value) => sum + value, 0) /
|
|
|
|
|
+ sortedValues.length,
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return { x, q1, median, q3, lowerfence, upperfence, mean };
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 过滤数据(修复版)
|
|
|
|
|
+ */
|
|
|
|
|
+const filterData = (group, options = {}) => {
|
|
|
|
|
+ const { startDate, endDate } = options;
|
|
|
|
|
+
|
|
|
const filteredXData = [];
|
|
const filteredXData = [];
|
|
|
const filteredYData = [];
|
|
const filteredYData = [];
|
|
|
const filteredMedians = group.medians ? { x: [], y: [] } : null;
|
|
const filteredMedians = group.medians ? { x: [], y: [] } : null;
|
|
|
|
|
|
|
|
- group.medians.x.forEach((timestamp, index) => {
|
|
|
|
|
- if (isInDateRange(timestamp)) {
|
|
|
|
|
- filteredMedians.x.push(timestamp);
|
|
|
|
|
- filteredMedians.y.push(group.medians.y[index]);
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ // ✅ medians 安全处理
|
|
|
|
|
+ if (group.medians?.x?.length) {
|
|
|
|
|
+ group.medians.x.forEach((timestamp, index) => {
|
|
|
|
|
+ if (isInDateRange(timestamp, startDate, endDate)) {
|
|
|
|
|
+ filteredMedians.x.push(timestamp);
|
|
|
|
|
+ filteredMedians.y.push(group.medians.y[index]);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
+ // ✅ 主数据
|
|
|
group.xData.forEach((timestamp, index) => {
|
|
group.xData.forEach((timestamp, index) => {
|
|
|
- if (isInDateRange(timestamp)) {
|
|
|
|
|
|
|
+ if (isInDateRange(timestamp, startDate, endDate)) {
|
|
|
filteredXData.push(timestamp);
|
|
filteredXData.push(timestamp);
|
|
|
filteredYData.push(group.yData[index]);
|
|
filteredYData.push(group.yData[index]);
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
return {
|
|
return {
|
|
|
...group,
|
|
...group,
|
|
|
xData: filteredXData,
|
|
xData: filteredXData,
|
|
@@ -49,7 +123,9 @@ const filterData = (group) => {
|
|
|
};
|
|
};
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-// 绘制箱线图
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 生成箱线图(带中位点)
|
|
|
|
|
+ */
|
|
|
export const generateBoxMarkersCharts = async (
|
|
export const generateBoxMarkersCharts = async (
|
|
|
data,
|
|
data,
|
|
|
bucketName,
|
|
bucketName,
|
|
@@ -57,24 +133,29 @@ export const generateBoxMarkersCharts = async (
|
|
|
) => {
|
|
) => {
|
|
|
const { data: chartData, xaixs, yaixs, analysisTypeCode } = data;
|
|
const { data: chartData, xaixs, yaixs, analysisTypeCode } = data;
|
|
|
|
|
|
|
|
- const filteredData = chartData.map((group) => filterData(group));
|
|
|
|
|
|
|
+ // ✅ 支持时间过滤
|
|
|
|
|
+ const filteredData = chartData.map((group) =>
|
|
|
|
|
+ filterData(group, {
|
|
|
|
|
+ startDate: data.startDate,
|
|
|
|
|
+ endDate: data.endDate,
|
|
|
|
|
+ }),
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
const traces = [];
|
|
const traces = [];
|
|
|
- const medianMarkers = [];
|
|
|
|
|
- // 获取 plotly.js 的绝对路径
|
|
|
|
|
- const plotlyPath = path.join(
|
|
|
|
|
- process.cwd(),
|
|
|
|
|
- "src",
|
|
|
|
|
- "public",
|
|
|
|
|
- "js",
|
|
|
|
|
- "plotly-3.0.1.min.js",
|
|
|
|
|
- );
|
|
|
|
|
- const plotlyContent = await fs.readFile(plotlyPath, "utf-8");
|
|
|
|
|
|
|
|
|
|
filteredData.forEach((group) => {
|
|
filteredData.forEach((group) => {
|
|
|
|
|
+ const stats = buildBoxStats(group.xData, group.yData);
|
|
|
|
|
+ if (!stats.x.length) return;
|
|
|
|
|
+
|
|
|
|
|
+ // ✅ 箱线图
|
|
|
traces.push({
|
|
traces.push({
|
|
|
- x: group.xData,
|
|
|
|
|
- y: group.yData,
|
|
|
|
|
|
|
+ x: stats.x,
|
|
|
|
|
+ q1: stats.q1,
|
|
|
|
|
+ median: stats.median,
|
|
|
|
|
+ q3: stats.q3,
|
|
|
|
|
+ lowerfence: stats.lowerfence,
|
|
|
|
|
+ upperfence: stats.upperfence,
|
|
|
|
|
+ mean: stats.mean,
|
|
|
type: "box",
|
|
type: "box",
|
|
|
name: group.title,
|
|
name: group.title,
|
|
|
marker: { color: "#263649" },
|
|
marker: { color: "#263649" },
|
|
@@ -85,114 +166,69 @@ export const generateBoxMarkersCharts = async (
|
|
|
hovertemplate:
|
|
hovertemplate:
|
|
|
`<b>${xaixs}</b>: %{x}<br>` +
|
|
`<b>${xaixs}</b>: %{x}<br>` +
|
|
|
`<b>最大值</b>: %{upperfence}<br>` +
|
|
`<b>最大值</b>: %{upperfence}<br>` +
|
|
|
- `<b>上四分位数 (Q3)</b>: %{q3}<br>` +
|
|
|
|
|
- `<b>中位数 (Median)</b>: %{median}<br>` +
|
|
|
|
|
- `<b>下四分位数 (Q1)</b>: %{q1}<br>` +
|
|
|
|
|
|
|
+ `<b>Q3</b>: %{q3}<br>` +
|
|
|
|
|
+ `<b>中位数</b>: %{median}<br>` +
|
|
|
|
|
+ `<b>Q1</b>: %{q1}<br>` +
|
|
|
`<b>最小值</b>: %{lowerfence}<br>` +
|
|
`<b>最小值</b>: %{lowerfence}<br>` +
|
|
|
`<b>均值</b>: %{mean}<br>` +
|
|
`<b>均值</b>: %{mean}<br>` +
|
|
|
`<extra></extra>`,
|
|
`<extra></extra>`,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- if (group.medians && group.medians.x.length > 0) {
|
|
|
|
|
- medianMarkers.push({
|
|
|
|
|
- x: group.medians.x,
|
|
|
|
|
- y: group.medians.y,
|
|
|
|
|
- mode: "markers",
|
|
|
|
|
- marker: { color: "#f00", size: 3 },
|
|
|
|
|
- name: `${group.title} - 中位点`,
|
|
|
|
|
- type: "scatter",
|
|
|
|
|
- hovertemplate: `<b>${xaixs}</b>: %{x} <br> <b>${yaixs}</b>: %{y} <br><b>中位点</b>: %{y}<br><extra></extra>`,
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // ✅ 中位点 scatter
|
|
|
|
|
+ traces.push({
|
|
|
|
|
+ x: stats.x,
|
|
|
|
|
+ y: stats.median,
|
|
|
|
|
+ mode: "markers",
|
|
|
|
|
+ marker: { color: "#f00", size: 3 },
|
|
|
|
|
+ name: `${group.title} - 中位点`,
|
|
|
|
|
+ type: "scatter",
|
|
|
|
|
+ hovertemplate:
|
|
|
|
|
+ `<b>${xaixs}</b>: %{x}<br>` +
|
|
|
|
|
+ `<b>${yaixs}</b>: %{y}<br>` +
|
|
|
|
|
+ `<b>中位点</b>: %{y}<br>` +
|
|
|
|
|
+ `<extra></extra>`,
|
|
|
|
|
+ });
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // ✅ layout(更干净)
|
|
|
const layout = {
|
|
const layout = {
|
|
|
title: {
|
|
title: {
|
|
|
- text: analysisTypeCode + chartData[0].engineName,
|
|
|
|
|
- font: { size: 16, weight: "bold" },
|
|
|
|
|
|
|
+ text: analysisTypeCode + (chartData[0]?.engineName || ""),
|
|
|
|
|
+ font: { size: 16 },
|
|
|
},
|
|
},
|
|
|
xaxis: {
|
|
xaxis: {
|
|
|
title: xaixs,
|
|
title: xaixs,
|
|
|
type: "date",
|
|
type: "date",
|
|
|
tickformat: "%Y-%m-%d",
|
|
tickformat: "%Y-%m-%d",
|
|
|
|
|
+ gridcolor: "#fff",
|
|
|
gridcolor: "rgb(255,255,255)",
|
|
gridcolor: "rgb(255,255,255)",
|
|
|
tickcolor: "rgb(255,255,255)",
|
|
tickcolor: "rgb(255,255,255)",
|
|
|
backgroundcolor: "#e5ecf6",
|
|
backgroundcolor: "#e5ecf6",
|
|
|
|
|
+ showbackground: true,
|
|
|
|
|
+ showline: true, // ✅ 显示 X 轴轴线
|
|
|
|
|
+ linecolor: "#ffffff", // ✅ X 轴轴线颜色设为白色
|
|
|
|
|
+ zeroline: false,
|
|
|
},
|
|
},
|
|
|
yaxis: {
|
|
yaxis: {
|
|
|
title: yaixs,
|
|
title: yaixs,
|
|
|
gridcolor: "rgb(255,255,255)",
|
|
gridcolor: "rgb(255,255,255)",
|
|
|
tickcolor: "rgb(255,255,255)",
|
|
tickcolor: "rgb(255,255,255)",
|
|
|
backgroundcolor: "#e5ecf6",
|
|
backgroundcolor: "#e5ecf6",
|
|
|
|
|
+ showbackground: true,
|
|
|
|
|
+ showline: true, // ✅ 显示 X 轴轴线
|
|
|
|
|
+ linecolor: "#ffffff", // ✅ X 轴轴线颜色设为白色
|
|
|
|
|
+ zeroline: false,
|
|
|
},
|
|
},
|
|
|
plot_bgcolor: "#e5ecf6",
|
|
plot_bgcolor: "#e5ecf6",
|
|
|
|
|
+ paper_bgcolor: "#e5ecf6",
|
|
|
showlegend: true,
|
|
showlegend: true,
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // 使用 Puppeteer 生成图表的截图
|
|
|
|
|
- const browser = await puppeteer.launch({
|
|
|
|
|
- headless: "new",
|
|
|
|
|
- // 根据系统改路径
|
|
|
|
|
- executablePath: `${process.env.CHROME_PATH}`, // 根据系统改路径
|
|
|
|
|
- args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
|
|
|
|
|
+ // ✅ 🚀 核心:统一走 chartService
|
|
|
|
|
+ return await renderChart({
|
|
|
|
|
+ traces,
|
|
|
|
|
+ layout,
|
|
|
|
|
+ bucketName,
|
|
|
|
|
+ objectName,
|
|
|
});
|
|
});
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- const page = await browser.newPage();
|
|
|
|
|
- const htmlContent = `
|
|
|
|
|
- <!DOCTYPE html>
|
|
|
|
|
- <html>
|
|
|
|
|
- <head>
|
|
|
|
|
- <meta charset="UTF-8">
|
|
|
|
|
- <script>${plotlyContent}</script>
|
|
|
|
|
- <style>
|
|
|
|
|
- body { margin: 0; }
|
|
|
|
|
- #chart { width: 800px; height: 600px; }
|
|
|
|
|
- </style>
|
|
|
|
|
- </head>
|
|
|
|
|
- <body>
|
|
|
|
|
- <div id="chart"></div>
|
|
|
|
|
- <script>
|
|
|
|
|
- window.onload = function() {
|
|
|
|
|
- const traces = ${JSON.stringify([...traces, ...medianMarkers])};
|
|
|
|
|
- const layout = ${JSON.stringify(layout)};
|
|
|
|
|
- Plotly.newPlot('chart', traces, layout).then(() => {
|
|
|
|
|
- window.chartRendered = true;
|
|
|
|
|
- });
|
|
|
|
|
- };
|
|
|
|
|
- </script>
|
|
|
|
|
- </body>
|
|
|
|
|
- </html>
|
|
|
|
|
- `;
|
|
|
|
|
-
|
|
|
|
|
- await page.setContent(htmlContent, { waitUntil: "networkidle0" });
|
|
|
|
|
- await page.waitForFunction(() => window.chartRendered === true, {
|
|
|
|
|
- timeout: 60000,
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // 截图并保存到临时文件
|
|
|
|
|
- const tempFilePath = path.join(
|
|
|
|
|
- process.cwd(),
|
|
|
|
|
- "images",
|
|
|
|
|
- `chart_${Date.now()}.jpeg`,
|
|
|
|
|
- );
|
|
|
|
|
- const chartElement = await page.$("#chart");
|
|
|
|
|
- await chartElement.screenshot({ path: tempFilePath, type: "jpeg" });
|
|
|
|
|
-
|
|
|
|
|
- // 上传图片到服务器
|
|
|
|
|
- const formData = new FormData();
|
|
|
|
|
- formData.append("file", fs.createReadStream(tempFilePath));
|
|
|
|
|
- const response = await axios.post(
|
|
|
|
|
- `${process.env.API_BASE_URL}/examples/upload`,
|
|
|
|
|
- {
|
|
|
|
|
- filePath: tempFilePath,
|
|
|
|
|
- bucketName,
|
|
|
|
|
- objectName,
|
|
|
|
|
- },
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- return response?.data?.url;
|
|
|
|
|
- } finally {
|
|
|
|
|
- await browser.close();
|
|
|
|
|
- }
|
|
|
|
|
};
|
|
};
|