功能: 完善功率曲线报告导出与图表样式

This commit is contained in:
cloud
2026-07-20 17:20:12 +08:00
parent e125e439f8
commit 4fa938ca79
6 changed files with 1531 additions and 57 deletions
@@ -259,7 +259,7 @@ bool ValidateChartOptions(const json& options, std::string& error) {
}; };
if (!validate_number("scatter_size", 1.0, 8.0) || if (!validate_number("scatter_size", 1.0, 8.0) ||
!validate_number("scatter_opacity", 0.0, 100.0) || !validate_number("scatter_opacity", 0.0, 100.0) ||
!validate_number("text_size", 10.0, 24.0) || !validate_number("text_scale", 0.75, 1.5) ||
!validate_number("actual_marker_size", 2.0, 12.0) || !validate_number("actual_marker_size", 2.0, 12.0) ||
!validate_number("design_marker_size", 2.0, 12.0)) { !validate_number("design_marker_size", 2.0, 12.0)) {
error = "图表数值参数无效"; error = "图表数值参数无效";
@@ -311,6 +311,15 @@ std::optional<json> LoadChartOptions() {
if (!options.contains("show_filtered")) { if (!options.contains("show_filtered")) {
options["show_filtered"] = false; options["show_filtered"] = false;
} }
// Migrate the earlier single pixel-size setting to the shared text multiplier.
if (!options.contains("text_scale")) {
double scale = 1.0;
if (options.contains("text_size") && options["text_size"].is_number()) {
scale = options["text_size"].get<double>() / 18.0;
}
options["text_scale"] = std::clamp(scale, 0.75, 1.5);
}
options.erase("text_size");
std::string error; std::string error;
if (ValidateChartOptions(options, error)) { if (ValidateChartOptions(options, error)) {
return std::optional<json>{options}; return std::optional<json>{options};
+10
View File
@@ -252,3 +252,13 @@
"data": null "data": null
} }
``` ```
### GET /api/wind/chart-options
读取全局图表样式配置。未保存时返回 `configured: false`;已保存时 `options.text_scale`
为文字倍率,取值范围 `0.75~1.50`,其中 `1.00` 为默认倍率。
### POST /api/wind/chart-options
保存全局图表样式配置。请求体包含当前图表全部样式字段,其中 `text_scale` 为必填数字,
取值范围 `0.75~1.50`。旧配置中的 `text_size` 会在读取时自动换算为倍率。
+999 -4
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,6 +10,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"exceljs": "^4.4.0",
"html2canvas": "^1.4.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router-dom": "^7.13.2", "react-router-dom": "^7.13.2",
+2 -2
View File
@@ -541,7 +541,7 @@ select:focus {
.chartFrame { .chartFrame {
position: relative; position: relative;
min-height: 540px; min-height: 580px;
} }
.chartFrameEditing .u-over { .chartFrameEditing .u-over {
@@ -550,7 +550,7 @@ select:focus {
.uplotWrap { .uplotWrap {
width: 100%; width: 100%;
min-height: 540px; min-height: 580px;
} }
.uplot { .uplot {
+508 -50
View File
@@ -51,7 +51,7 @@ const DESIGN_FIELD_HINTS = {
}; };
const CHUNK_SIZE = 4000; const CHUNK_SIZE = 4000;
const CHART_HEIGHT = 540; const CHART_HEIGHT = 580;
const BRUSH_RADIUS = 10; const BRUSH_RADIUS = 10;
const EDIT_TOOL_RESTORE = 'restore'; const EDIT_TOOL_RESTORE = 'restore';
const EDIT_TOOL_ERASE = 'erase'; const EDIT_TOOL_ERASE = 'erase';
@@ -90,7 +90,7 @@ const DEFAULT_CHART_OPTIONS = {
scatter_color: '#2563eb', scatter_color: '#2563eb',
scatter_opacity: 42, scatter_opacity: 42,
show_scatter: true, show_scatter: true,
text_size: 14, text_scale: 1,
text_color: '#334155', text_color: '#334155',
actual_color: '#000000', actual_color: '#000000',
actual_line_style: 'solid', actual_line_style: 'solid',
@@ -104,6 +104,37 @@ const DEFAULT_CHART_OPTIONS = {
design_marker_size: 5, design_marker_size: 5,
}; };
const TEXT_SCALE_LIMITS = { min: 0.75, max: 1.5 };
const SCREEN_TYPOGRAPHY = { tick: 14, axis: 20, legend: 18, title: 24 };
const EXPORT_TYPOGRAPHY = { tick: 22, axis: 28, legend: 24, title: 34 };
function getTextScale(value) {
const scale = Number(value);
if (!Number.isFinite(scale)) return 1;
return Math.min(TEXT_SCALE_LIMITS.max, Math.max(TEXT_SCALE_LIMITS.min, scale));
}
function normalizeChartOptions(options = {}) {
const { text_size: legacyTextSize, ...currentOptions } = options;
const textScale = Number.isFinite(Number(currentOptions.text_scale))
? Number(currentOptions.text_scale)
: Number.isFinite(Number(legacyTextSize))
? Number(legacyTextSize) / 18
: DEFAULT_CHART_OPTIONS.text_scale;
return {
...DEFAULT_CHART_OPTIONS,
...currentOptions,
text_scale: getTextScale(textScale),
};
}
function getTypography(options, base) {
const scale = getTextScale(options?.text_scale);
return Object.fromEntries(
Object.entries(base).map(([name, size]) => [name, Math.round(size * scale)]),
);
}
function loadChartState() { function loadChartState() {
try { try {
const state = JSON.parse(sessionStorage.getItem(CHART_STATE_KEY) || 'null'); const state = JSON.parse(sessionStorage.getItem(CHART_STATE_KEY) || 'null');
@@ -265,10 +296,19 @@ function normalizeNumber(value) {
function chooseFirstDataSheet(workbook) { function chooseFirstDataSheet(workbook) {
for (const sheetName of workbook.SheetNames) { for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName]; const sheet = workbook.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: true }); const rawRows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: true });
const nonEmptyRows = rows.filter((row) => row.some((cell) => String(cell ?? '').trim() !== '')); const displayRows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false });
if (nonEmptyRows.length > 1) { const nonEmptyIndexes = rawRows
return { sheetName, rows: nonEmptyRows }; .map((row, index) => (
row.some((cell) => String(cell ?? '').trim() !== '') ? index : -1
))
.filter((index) => index >= 0);
if (nonEmptyIndexes.length > 1) {
return {
sheetName,
rows: nonEmptyIndexes.map((index) => rawRows[index]),
displayRows: nonEmptyIndexes.map((index) => displayRows[index] || []),
};
} }
} }
return null; return null;
@@ -290,6 +330,10 @@ async function readExcelFile(file) {
sheet_name: sheetData.sheetName, sheet_name: sheetData.sheetName,
headers, headers,
rows: dataRows, rows: dataRows,
raw_headers: (sheetData.displayRows[0] || sheetData.rows[0])
.map((cell) => String(cell ?? '')),
// 报告中的原始页需要保留数值类型,Excel 的 COUNTIFS/AVERAGEIFS 才能参与计算。
raw_rows: dataRows.map((row) => [...row]),
row_count: dataRows.length, row_count: dataRows.length,
}; };
} }
@@ -370,6 +414,53 @@ function buildStandardRows(files, mapping) {
return { rows, indexes }; return { rows, indexes };
} }
function hasMatchingHeaderSequence(files) {
if (!files.length) return false;
const firstHeaders = files[0].headers || [];
return files.every((file) => (
file.headers.length === firstHeaders.length &&
file.headers.every((header, index) => header === firstHeaders[index])
));
}
function findRawSourceFiles(files, mapping, fanId) {
if (!fanId) return [];
return files.filter((file) => {
const fanColumnIndex = file.headers.indexOf(mapping.fan_id);
return fanColumnIndex >= 0 && file.rows.some((row) => (
String(row[fanColumnIndex] ?? '').trim() === fanId
));
});
}
function buildRawReportData(file, mapping, fanId) {
if (!file || !fanId) return null;
const fanColumnIndex = file.headers.indexOf(mapping.fan_id);
const windSpeedColumnIndex = file.headers.indexOf(mapping.wind_speed);
if (fanColumnIndex < 0 || windSpeedColumnIndex < 0) return null;
const rows = file.rows.reduce((result, row, index) => {
if (String(row[fanColumnIndex] ?? '').trim() === fanId) {
const rawRow = [...(file.raw_rows?.[index] || row)];
const windSpeed = normalizeNumber(rawRow[windSpeedColumnIndex]);
// 部分源 Excel 将风速保存为“数字文本”。保留显示值的同时写成数值单元格,
// 使计算表的 COUNTIFS 能正确统计风频。
if (Number.isFinite(windSpeed)) {
rawRow[windSpeedColumnIndex] = windSpeed;
}
result.push(rawRow);
}
return result;
}, []);
return {
headers: [...(file.raw_headers || file.headers)],
rows,
windSpeedColumnIndex,
};
}
function formatTimestampForFile(date = new Date()) { function formatTimestampForFile(date = new Date()) {
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
+ `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; + `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
@@ -438,6 +529,50 @@ function interpolateDesignPower(points, windSpeed) {
return points[points.length - 1].design_power; return points[points.length - 1].design_power;
} }
function buildReportDesignCurve(designCurve) {
const points = (designCurve || [])
.filter((point) => Number.isFinite(point?.wind_speed)
&& Number.isFinite(point?.design_power)
&& point.wind_speed >= 0
&& point.design_power >= 0)
.sort((left, right) => left.wind_speed - right.wind_speed);
if (!points.length || points[0].wind_speed <= 0) return points;
const firstGap = points.length > 1 ? points[1].wind_speed - points[0].wind_speed : 1;
const step = Number.isFinite(firstGap) && firstGap > 0 ? firstGap : 1;
const lowWindPoints = [];
for (let windSpeed = 0; windSpeed < points[0].wind_speed - 0.000001; windSpeed += step) {
lowWindPoints.push({
wind_speed: Number(windSpeed.toFixed(6)),
design_power: 0,
});
}
return [...lowWindPoints, ...points];
}
function buildReportCurveRows(scatterPoints, reportDesignCurve) {
return reportDesignCurve.map((designPoint) => {
const powers = (scatterPoints || [])
.filter((point) => {
const windSpeed = Number(point?.wind_speed);
const activePower = Number(point?.active_power);
return Number.isFinite(windSpeed) && Number.isFinite(activePower)
&& windSpeed >= designPoint.wind_speed - 0.5
&& windSpeed < designPoint.wind_speed + 0.5;
})
.map((point) => Number(point.active_power));
const averagePower = powers.length
? powers.reduce((sum, power) => sum + power, 0) / powers.length
: null;
return {
wind_speed: designPoint.wind_speed,
design_power: designPoint.design_power,
sample_count: powers.length,
average_power: averagePower,
};
}).filter((point) => Number.isFinite(point.average_power) && point.average_power > 0);
}
function schemeParamsToState(parameters) { function schemeParamsToState(parameters) {
return { return {
grid_connected_speed: String( grid_connected_speed: String(
@@ -667,10 +802,16 @@ function getVisibleActualCurve(actualCurve) {
return sorted.slice(validIndexes[0], validIndexes[validIndexes.length - 1] + 1); return sorted.slice(validIndexes[0], validIndexes[validIndexes.length - 1] + 1);
} }
function exportPowerCurvePng({ actualCurve, designCurve, scatterPoints, filteredPoints, showActual, showDesign, showScatter, showFiltered, options, scales }) { function renderPowerCurvePng({ actualCurve, designCurve, scatterPoints, filteredPoints, showActual, showDesign, showScatter, showFiltered, options, scales }) {
const width = 1800; const width = 1800;
const height = 1160; const height = 1160;
const padding = { top: 120, right: 70, bottom: 115, left: 135 }; const typography = getTypography(options, EXPORT_TYPOGRAPHY);
const padding = {
top: Math.max(120, typography.title * 2 + 40),
right: 70,
bottom: Math.max(135, typography.axis + typography.tick + 82),
left: Math.max(160, typography.axis + typography.tick * 4 + 48),
};
const gridColor = '#d8e0ea'; const gridColor = '#d8e0ea';
const plotWidth = width - padding.left - padding.right; const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom; const plotHeight = height - padding.top - padding.bottom;
@@ -699,10 +840,8 @@ function exportPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
context.fillStyle = '#ffffff'; context.fillStyle = '#ffffff';
context.fillRect(0, 0, width, height); context.fillRect(0, 0, width, height);
const textSize = Math.min(24, Math.max(10, Number(options.text_size) || DEFAULT_CHART_OPTIONS.text_size));
const textColor = options.text_color || DEFAULT_CHART_OPTIONS.text_color; const textColor = options.text_color || DEFAULT_CHART_OPTIONS.text_color;
const textScale = textSize / DEFAULT_CHART_OPTIONS.text_size; context.font = `${typography.tick}px "Segoe UI", sans-serif`;
context.font = `${Math.round(16 * textScale)}px "Segoe UI", sans-serif`;
if (options.show_grid) { if (options.show_grid) {
context.strokeStyle = gridColor; context.strokeStyle = gridColor;
@@ -729,6 +868,7 @@ function exportPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
context.fillText(formatTick(yValue), padding.left - 14, toY(yValue) + 5); context.fillText(formatTick(yValue), padding.left - 14, toY(yValue) + 5);
} }
context.textAlign = 'center'; context.textAlign = 'center';
context.font = `${typography.axis}px "Segoe UI", sans-serif`;
context.fillText(options.x_axis_label || DEFAULT_CHART_OPTIONS.x_axis_label, padding.left + plotWidth / 2, height - 28); context.fillText(options.x_axis_label || DEFAULT_CHART_OPTIONS.x_axis_label, padding.left + plotWidth / 2, height - 28);
context.save(); context.save();
context.translate(34, padding.top + plotHeight / 2); context.translate(34, padding.top + plotHeight / 2);
@@ -753,12 +893,12 @@ function exportPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
if (showActual) drawSmoothLine(context, actualCurve, toX, toY, actualStyle); if (showActual) drawSmoothLine(context, actualCurve, toX, toY, actualStyle);
context.save(); context.save();
context.fillStyle = textColor; context.fillStyle = textColor;
context.font = `700 ${Math.round(34 * textScale)}px "Segoe UI", sans-serif`; context.font = `700 ${typography.title}px "Segoe UI", sans-serif`;
context.textAlign = 'center'; context.textAlign = 'center';
context.fillText( context.fillText(
options.title || DEFAULT_CHART_OPTIONS.title, options.title || DEFAULT_CHART_OPTIONS.title,
padding.left + plotWidth / 2, padding.left + plotWidth / 2,
padding.top - Math.round(34 * textScale), padding.top - typography.title,
); );
context.restore(); context.restore();
@@ -770,9 +910,10 @@ function exportPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
...(showFiltered ? [['滤除点', '#94a3b8']] : []), ...(showFiltered ? [['滤除点', '#94a3b8']] : []),
]; ];
context.textAlign = 'left'; context.textAlign = 'left';
context.font = `${typography.legend}px "Segoe UI", sans-serif`;
const legendLeft = padding.left + 22; const legendLeft = padding.left + 22;
const legendTop = padding.top + Math.max(30, Math.round(34 * textScale)); const legendTop = padding.top + Math.max(30, typography.legend + 10);
const legendLineHeight = Math.max(30, Math.round(28 * textScale)); const legendLineHeight = Math.max(34, typography.legend + 12);
entries.forEach(([label, type], index) => { entries.forEach(([label, type], index) => {
const top = legendTop + index * legendLineHeight; const top = legendTop + index * legendLineHeight;
const isScatter = type === 'scatter'; const isScatter = type === 'scatter';
@@ -793,13 +934,229 @@ function exportPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
context.fillText(label, legendLeft + 30, top + 7); context.fillText(label, legendLeft + 30, top + 7);
}); });
} }
return canvas.toDataURL('image/png');
}
async function exportPowerCurvePng(args) {
const imageData = args.imageData || renderPowerCurvePng(args);
if (!imageData) return false;
const link = document.createElement('a'); const link = document.createElement('a');
link.href = canvas.toDataURL('image/png'); link.href = imageData;
link.download = `${String(options.title || '风速-功率曲线').replace(/[\\/:*?"<>|]/g, '_')}.png`; link.download = `${String(args.options.title || '风速-功率曲线').replace(/[\\/:*?"<>|]/g, '_')}.png`;
link.click(); link.click();
return true; return true;
} }
async function captureChartFrame(frame) {
if (!frame) return null;
const { default: html2canvas } = await import('html2canvas');
const frameRect = frame.getBoundingClientRect();
const plotCanvas = frame.querySelector('.uplot canvas');
const plotRect = plotCanvas?.getBoundingClientRect();
const captureHeight = plotRect
? Math.max(1, Math.ceil(plotRect.bottom - frameRect.top))
: undefined;
const canvas = await html2canvas(frame, {
backgroundColor: '#ffffff',
height: captureHeight,
logging: false,
onclone: (documentClone) => {
documentClone.querySelectorAll('.uplot .u-legend').forEach((legend) => legend.remove());
},
scale: 2,
});
return {
imageData: canvas.toDataURL('image/png'),
imageWidth: canvas.width,
imageHeight: canvas.height,
};
}
const REPORT_DETAIL_HEADERS = [
'风机编号',
'采样时间',
'平均功率',
'平均转速',
'平均风速',
'3个叶片变桨角平均值',
];
const REPORT_BORDER = {
top: { style: 'thin', color: { argb: 'FF475569' } },
left: { style: 'thin', color: { argb: 'FF475569' } },
bottom: { style: 'thin', color: { argb: 'FF475569' } },
right: { style: 'thin', color: { argb: 'FF475569' } },
};
function reportNumber(value) {
if (value === null || value === undefined || String(value).trim() === '') return '';
const number = Number(value);
return Number.isFinite(number) ? number : '';
}
function reportPitchAverage(row) {
const values = [row.blade_pitch_1, row.blade_pitch_2, row.blade_pitch_3]
.map(reportNumber);
return values.every((value) => value !== '') ? values.reduce((sum, value) => sum + value, 0) / values.length : '';
}
function reportDetailValues(row, fanId) {
const savedPitchAverage = reportNumber(row.pitch_angle_average);
return [
row.fan_id || fanId || '',
row.time || '',
reportNumber(row.active_power),
reportNumber(row.generator_speed),
reportNumber(row.wind_speed),
savedPitchAverage !== '' ? savedPitchAverage : reportPitchAverage(row),
];
}
function styleReportHeader(row) {
row.height = 23;
row.eachCell((cell) => {
cell.font = { bold: true, color: { argb: 'FF0F172A' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } };
cell.alignment = { horizontal: 'center', vertical: 'middle' };
cell.border = REPORT_BORDER;
});
}
function styleReportBody(sheet, startRow, endRow, numericColumns) {
for (let rowIndex = startRow; rowIndex <= endRow; rowIndex += 1) {
const row = sheet.getRow(rowIndex);
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.border = REPORT_BORDER;
cell.alignment = { vertical: 'middle' };
if (numericColumns.includes(columnNumber)) {
cell.numFmt = '0.0000';
}
});
}
}
function addReportDetailSheet(workbook, name, rows, fanId) {
const sheet = workbook.addWorksheet(name, {
views: [{ state: 'frozen', ySplit: 1 }],
});
sheet.columns = [
{ width: 16 }, { width: 22 }, { width: 14 }, { width: 14 }, { width: 14 }, { width: 23 },
];
sheet.addRow(REPORT_DETAIL_HEADERS);
rows.forEach((row) => sheet.addRow(reportDetailValues(row, fanId)));
styleReportHeader(sheet.getRow(1));
styleReportBody(sheet, 2, Math.max(2, rows.length + 1), [3, 4, 5, 6]);
sheet.autoFilter = `A1:F${Math.max(1, rows.length + 1)}`;
return sheet;
}
function excelColumnName(index) {
let value = index + 1;
let name = '';
while (value > 0) {
const remainder = (value - 1) % 26;
name = String.fromCharCode(65 + remainder) + name;
value = Math.floor((value - 1) / 26);
}
return name;
}
function addRawReportSheet(workbook, { headers, rows }) {
const sheet = workbook.addWorksheet('筛选前的数据', {
views: [{ state: 'frozen', ySplit: 1 }],
});
sheet.addRow(headers);
rows.forEach((row) => sheet.addRow(row));
sheet.columns = headers.map((header, index) => {
const maxLength = rows.reduce((max, row) => (
Math.max(max, String(row[index] ?? '').length)
), String(header ?? '').length);
return { width: Math.min(32, Math.max(12, maxLength + 2)) };
});
styleReportHeader(sheet.getRow(1));
styleReportBody(sheet, 2, Math.max(2, rows.length + 1), []);
sheet.autoFilter = `A1:${excelColumnName(Math.max(0, headers.length - 1))}${Math.max(1, rows.length + 1)}`;
return sheet;
}
async function exportPowerCurveReport({
fanId,
rawReportData,
effectiveRows,
reportRows,
chartRenderSnapshot,
}) {
const { default: ExcelJS } = await import('exceljs');
const workbook = new ExcelJS.Workbook();
workbook.creator = '风电功率计算平台';
workbook.created = new Date();
workbook.calcProperties.fullCalcOnLoad = true;
addReportDetailSheet(workbook, '筛选后的数据', effectiveRows, fanId);
addRawReportSheet(workbook, rawReportData);
const rawWindColumn = excelColumnName(rawReportData.windSpeedColumnIndex);
const curveSheet = workbook.addWorksheet('功率曲线计算表', {
views: [{ state: 'frozen', ySplit: 1 }],
});
curveSheet.columns = [
{ width: 9 }, { width: 14 }, { width: 16 }, { width: 17 }, { width: 17 }, { width: 19 }, { width: 19 }, { width: 4 }, { width: 16 },
];
curveSheet.addRow([
'序号', '风速 (m/s)', '风频时间 (h)', '计算功率 (kW)', '保证功率 (kW)', '计算发电量 (kWh)', '理论发电量 (kWh)', '', 'K值',
]);
styleReportHeader(curveSheet.getRow(1));
reportRows.forEach((point, index) => {
const rowNumber = index + 2;
const row = curveSheet.getRow(rowNumber);
row.getCell(1).value = index + 1;
row.getCell(2).value = reportNumber(point.wind_speed);
row.getCell(3).value = {
formula: `(COUNTIFS('筛选前的数据'!${rawWindColumn}:${rawWindColumn},">="&B${rowNumber}-0.5,'筛选前的数据'!${rawWindColumn}:${rawWindColumn},"<"&B${rowNumber}+0.5)/COUNT('筛选前的数据'!${rawWindColumn}:${rawWindColumn}))*8760`,
};
row.getCell(4).value = {
formula: `IFERROR(AVERAGEIFS('筛选后的数据'!$C:$C,'筛选后的数据'!$E:$E,">="&B${rowNumber}-0.5,'筛选后的数据'!$E:$E,"<"&B${rowNumber}+0.5),0)`,
};
row.getCell(5).value = reportNumber(point.design_power);
row.getCell(6).value = { formula: `IFERROR(ROUND(C${rowNumber}*D${rowNumber}/1000,4),0)` };
row.getCell(7).value = { formula: `ROUND(C${rowNumber}*E${rowNumber}/1000,4)` };
});
const lastRow = reportRows.length + 1;
curveSheet.getCell('I2').value = {
formula: `IFERROR(SUM(F2:F${lastRow})/SUM(G2:G${lastRow}),0)`,
};
styleReportBody(curveSheet, 2, lastRow, [2, 3, 4, 5, 6, 7, 9]);
curveSheet.getCell('I2').numFmt = '0.0000';
curveSheet.autoFilter = `A1:G${lastRow}`;
const reportImage = chartRenderSnapshot?.imageData
|| (chartRenderSnapshot ? renderPowerCurvePng(chartRenderSnapshot) : false);
if (reportImage) {
const imageId = workbook.addImage({ base64: reportImage, extension: 'png' });
const imageWidth = 820;
const imageHeight = chartRenderSnapshot?.imageWidth && chartRenderSnapshot?.imageHeight
? Math.round(imageWidth * chartRenderSnapshot.imageHeight / chartRenderSnapshot.imageWidth)
: 529;
curveSheet.addImage(imageId, {
tl: { col: 8, row: 3 },
ext: { width: imageWidth, height: imageHeight },
});
}
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `完整功率曲线报告_${sanitizeFileName(fanId)}_${formatTimestampForFile()}.xlsx`;
link.click();
URL.revokeObjectURL(url);
}
function PowerCurveChart({ function PowerCurveChart({
points, points,
scatterPoints, scatterPoints,
@@ -809,6 +1166,7 @@ function PowerCurveChart({
designPoints, designPoints,
chartOptions, chartOptions,
chartExportRef, chartExportRef,
chartSnapshotRef,
editMode, editMode,
editTool, editTool,
onRestorePoints, onRestorePoints,
@@ -849,23 +1207,39 @@ function PowerCurveChart({
[validDesign, visibleActualCurve], [validDesign, visibleActualCurve],
); );
const activeChartOptions = useMemo( const activeChartOptions = useMemo(
() => ({ ...DEFAULT_CHART_OPTIONS, ...chartOptions }), () => normalizeChartOptions(chartOptions),
[chartOptions], [chartOptions],
); );
const screenTypography = useMemo(
() => getTypography(activeChartOptions, SCREEN_TYPOGRAPHY),
[activeChartOptions],
);
const xAxisSize = screenTypography.tick + 22;
const yAxisSize = Math.max(64, Math.round(screenTypography.tick * 3.2 + 16));
const axisLabelSize = screenTypography.axis + 18;
const showScatterOnChart = activeChartOptions.show_scatter || editMode; const showScatterOnChart = activeChartOptions.show_scatter || editMode;
chartSnapshotRef.current = async () => {
chartExportRef.current = () => exportPowerCurvePng({ const snapshot = {
actualCurve: visibleActualCurve, actualCurve: visibleActualCurve,
designCurve: visibleDesignCurve, designCurve: visibleDesignCurve,
scatterPoints: validScatter, scatterPoints: validScatter,
filteredPoints: validFiltered, filteredPoints: validFiltered,
showActual: plotRef.current?.series[1]?.show !== false, showActual: plotRef.current?.series[1]?.show !== false,
showDesign: plotRef.current?.series[2]?.show !== false, showDesign: plotRef.current?.series[2]?.show !== false,
showScatter: activeChartOptions.show_scatter, showScatter: showScatterOnChart,
showFiltered, showFiltered,
options: activeChartOptions, options: activeChartOptions,
scales: plotRef.current?.scales, scales: plotRef.current?.scales,
}); };
try {
const capturedImage = await captureChartFrame(frameRef.current);
return capturedImage ? { ...snapshot, ...capturedImage } : snapshot;
} catch {
// Keep the existing Canvas renderer as a fallback when browser capture is unavailable.
return snapshot;
}
};
chartExportRef.current = async () => exportPowerCurvePng(await chartSnapshotRef.current());
useEffect(() => { useEffect(() => {
if (!chartRef.current || (!visibleActualCurve.length && !visibleDesignCurve.length)) return undefined; if (!chartRef.current || (!visibleActualCurve.length && !visibleDesignCurve.length)) return undefined;
@@ -944,8 +1318,12 @@ function PowerCurveChart({
{ {
label: activeChartOptions.x_axis_label, label: activeChartOptions.x_axis_label,
stroke: activeChartOptions.text_color, stroke: activeChartOptions.text_color,
font: `${activeChartOptions.text_size}px "Segoe UI", sans-serif`, size: xAxisSize,
labelFont: `600 ${Math.round(activeChartOptions.text_size * 1.08)}px "Segoe UI", sans-serif`, gap: 8,
labelSize: axisLabelSize,
labelGap: 10,
font: `${screenTypography.tick}px "Segoe UI", sans-serif`,
labelFont: `600 ${screenTypography.axis}px "Segoe UI", sans-serif`,
splits: (u, axisIdx, min, max) => getAxisTicks(min, max, activeChartOptions.x_tick_interval), splits: (u, axisIdx, min, max) => getAxisTicks(min, max, activeChartOptions.x_tick_interval),
values: (u, splits) => splits.map(formatTick), values: (u, splits) => splits.map(formatTick),
grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 }, grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 },
@@ -953,8 +1331,12 @@ function PowerCurveChart({
{ {
label: activeChartOptions.y_axis_label, label: activeChartOptions.y_axis_label,
stroke: activeChartOptions.text_color, stroke: activeChartOptions.text_color,
font: `${activeChartOptions.text_size}px "Segoe UI", sans-serif`, size: yAxisSize,
labelFont: `600 ${Math.round(activeChartOptions.text_size * 1.08)}px "Segoe UI", sans-serif`, gap: 8,
labelSize: axisLabelSize,
labelGap: 10,
font: `${screenTypography.tick}px "Segoe UI", sans-serif`,
labelFont: `600 ${screenTypography.axis}px "Segoe UI", sans-serif`,
splits: (u, axisIdx, min, max) => getAxisTicks(min, max, activeChartOptions.y_tick_interval), splits: (u, axisIdx, min, max) => getAxisTicks(min, max, activeChartOptions.y_tick_interval),
values: (u, splits) => splits.map(formatTick), values: (u, splits) => splits.map(formatTick),
grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 }, grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 },
@@ -1049,7 +1431,7 @@ function PowerCurveChart({
const titleElement = chart.root.querySelector('.u-title'); const titleElement = chart.root.querySelector('.u-title');
if (titleElement) { if (titleElement) {
titleElement.style.color = activeChartOptions.text_color; titleElement.style.color = activeChartOptions.text_color;
titleElement.style.fontSize = `${Math.round(activeChartOptions.text_size * 1.25)}px`; titleElement.style.fontSize = `${screenTypography.title}px`;
titleElement.style.padding = '8px 0 10px'; titleElement.style.padding = '8px 0 10px';
} }
plotRef.current = chart; plotRef.current = chart;
@@ -1150,6 +1532,7 @@ function PowerCurveChart({
onRestorePoints, onRestorePoints,
onErasePoints, onErasePoints,
activeChartOptions, activeChartOptions,
screenTypography,
]); ]);
useEffect(() => { useEffect(() => {
@@ -1174,8 +1557,8 @@ function PowerCurveChart({
className="chartLegend" className="chartLegend"
style={{ style={{
color: activeChartOptions.text_color, color: activeChartOptions.text_color,
fontSize: `${activeChartOptions.text_size}px`, fontSize: `${screenTypography.legend}px`,
top: `${Math.max(72, Math.round(activeChartOptions.text_size * 5.5))}px`, top: `${Math.max(72, screenTypography.title * 3)}px`,
}} }}
> >
{showScatterOnChart && ( {showScatterOnChart && (
@@ -1228,8 +1611,7 @@ export default function HomePage() {
const [readingDesign, setReadingDesign] = useState(false); const [readingDesign, setReadingDesign] = useState(false);
const [designRows, setDesignRows] = useState(() => createDesignRows(savedSessionState.design_rows)); const [designRows, setDesignRows] = useState(() => createDesignRows(savedSessionState.design_rows));
const [designMeta, setDesignMeta] = useState(null); const [designMeta, setDesignMeta] = useState(null);
const [chartOptions, setChartOptions] = useState(() => ({ const [chartOptions, setChartOptions] = useState(() => normalizeChartOptions({
...DEFAULT_CHART_OPTIONS,
...(savedSessionState.chart_options || {}), ...(savedSessionState.chart_options || {}),
// Apply the new black default only to sessions carrying the old built-in orange. // Apply the new black default only to sessions carrying the old built-in orange.
actual_color: savedSessionState.chart_options?.actual_color === '#ea580c' actual_color: savedSessionState.chart_options?.actual_color === '#ea580c'
@@ -1243,7 +1625,9 @@ export default function HomePage() {
const [chartSettingsOpen, setChartSettingsOpen] = useState(false); const [chartSettingsOpen, setChartSettingsOpen] = useState(false);
const [savingChartOptions, setSavingChartOptions] = useState(false); const [savingChartOptions, setSavingChartOptions] = useState(false);
const [chartSettingsMessage, setChartSettingsMessage] = useState(''); const [chartSettingsMessage, setChartSettingsMessage] = useState('');
const [exportingReport, setExportingReport] = useState(false);
const chartExportRef = useRef(() => false); const chartExportRef = useRef(() => false);
const chartSnapshotRef = useRef(() => null);
const [schemes, setSchemes] = useState(DEFAULT_SCHEMES); const [schemes, setSchemes] = useState(DEFAULT_SCHEMES);
const [selectedSchemeId, setSelectedSchemeId] = useState(DEFAULT_SCHEME_ID); const [selectedSchemeId, setSelectedSchemeId] = useState(DEFAULT_SCHEME_ID);
const [schemeDescription, setSchemeDescription] = useState(DEFAULT_SCHEMES[0].description); const [schemeDescription, setSchemeDescription] = useState(DEFAULT_SCHEMES[0].description);
@@ -1314,6 +1698,18 @@ export default function HomePage() {
[effectiveBaseScatter, restoredFilteredPoints], [effectiveBaseScatter, restoredFilteredPoints],
); );
const confirmedCurveScatter = confirmedCurveScatterByFan[selectedFan] || effectiveScatter; const confirmedCurveScatter = confirmedCurveScatterByFan[selectedFan] || effectiveScatter;
const hasConsistentRawHeaders = useMemo(
() => hasMatchingHeaderSequence(files),
[files],
);
const rawReportSourceFiles = useMemo(
() => findRawSourceFiles(files, mapping, selectedFan),
[files, mapping, selectedFan],
);
const selectedRawReportData = useMemo(() => {
if (!hasConsistentRawHeaders || rawReportSourceFiles.length !== 1) return null;
return buildRawReportData(rawReportSourceFiles[0], mapping, selectedFan);
}, [hasConsistentRawHeaders, mapping, rawReportSourceFiles, selectedFan]);
const selectedEstimatedParams = selectedFan && result?.estimated_params const selectedEstimatedParams = selectedFan && result?.estimated_params
? result.estimated_params[selectedFan] ? result.estimated_params[selectedFan]
: null; : null;
@@ -1349,7 +1745,7 @@ export default function HomePage() {
try { try {
const data = await getWindChartOptions(); const data = await getWindChartOptions();
if (!canceled && data.configured && data.options) { if (!canceled && data.configured && data.options) {
setChartOptions({ ...DEFAULT_CHART_OPTIONS, ...data.options }); setChartOptions(normalizeChartOptions(data.options));
setShowFiltered(Boolean(data.options.show_filtered)); setShowFiltered(Boolean(data.options.show_filtered));
} }
} catch (err) { } catch (err) {
@@ -1484,7 +1880,7 @@ export default function HomePage() {
setChartSettingsMessage(''); setChartSettingsMessage('');
try { try {
const data = await saveWindChartOptions({ ...chartOptions, show_filtered: showFiltered }); const data = await saveWindChartOptions({ ...chartOptions, show_filtered: showFiltered });
setChartOptions({ ...DEFAULT_CHART_OPTIONS, ...data.options }); setChartOptions(normalizeChartOptions(data.options));
setShowFiltered(Boolean(data.options.show_filtered)); setShowFiltered(Boolean(data.options.show_filtered));
setChartSettingsMessage('图表参数已保存,将作为全局默认配置。'); setChartSettingsMessage('图表参数已保存,将作为全局默认配置。');
} catch (err) { } catch (err) {
@@ -1520,6 +1916,55 @@ export default function HomePage() {
})) }))
.sort((left, right) => left.wind_speed - right.wind_speed); .sort((left, right) => left.wind_speed - right.wind_speed);
}, [designRows]); }, [designRows]);
const reportDesignCurve = useMemo(
() => buildReportDesignCurve(designCurve),
[designCurve],
);
const reportCurveRows = useMemo(
() => buildReportCurveRows(effectiveScatter, reportDesignCurve),
[effectiveScatter, reportDesignCurve],
);
const reportExportDisabledReason = files.length && !hasConsistentRawHeaders
? '同一批上传文件的列头和列顺序必须完全一致'
: files.length && rawReportSourceFiles.length !== 1
? '完整报告要求当前风机对应唯一一份上传 Excel 文件'
: files.length && (!selectedRawReportData || !selectedRawReportData.rows.length)
? '当前风机没有可导出的原始数据,或未识别到风速列'
: '';
const canExportFullReport = Boolean(
selectedFan && selectedRawReportData?.rows.length && reportCurveRows.length && !isEditMode &&
!reportExportDisabledReason,
);
const handleExportFullReport = useCallback(async () => {
if (!canExportFullReport || exportingReport) return;
setExportingReport(true);
setError('');
try {
const chartRenderSnapshot = await chartSnapshotRef.current?.();
if (!chartRenderSnapshot) {
throw new Error('图表尚未完成绘制,请稍后重试');
}
await exportPowerCurveReport({
fanId: selectedFan,
rawReportData: selectedRawReportData,
effectiveRows: effectiveScatter,
reportRows: reportCurveRows,
chartRenderSnapshot,
});
} catch (err) {
setError(err.message || '导出完整报告失败');
} finally {
setExportingReport(false);
}
}, [
canExportFullReport,
effectiveScatter,
exportingReport,
reportCurveRows,
rawReportSourceFiles,
selectedRawReportData,
selectedFan,
]);
useEffect(() => { useEffect(() => {
let canceled = false; let canceled = false;
@@ -1629,6 +2074,9 @@ export default function HomePage() {
for (const file of selectedFiles) { for (const file of selectedFiles) {
parsedFiles.push(await readExcelFile(file)); parsedFiles.push(await readExcelFile(file));
} }
if (!hasMatchingHeaderSequence(parsedFiles)) {
throw new Error('同一批上传文件的列头和列顺序必须完全一致');
}
setFiles(parsedFiles); setFiles(parsedFiles);
setMapping(inferMapping(parsedFiles[0].headers)); setMapping(inferMapping(parsedFiles[0].headers));
} catch (err) { } catch (err) {
@@ -2081,7 +2529,7 @@ export default function HomePage() {
<div className="panel"> <div className="panel">
<div className="panelHeader"> <div className="panelHeader">
<h2 style={{ color: chartOptions.text_color, fontSize: `${Math.round((Number(chartOptions.text_size) || DEFAULT_CHART_OPTIONS.text_size) * 1.35)}px` }}> <h2 style={{ color: chartOptions.text_color, fontSize: `${getTypography(chartOptions, SCREEN_TYPOGRAPHY).title}px` }}>
{chartOptions.title || DEFAULT_CHART_OPTIONS.title} {chartOptions.title || DEFAULT_CHART_OPTIONS.title}
</h2> </h2>
<div className="chartTools"> <div className="chartTools">
@@ -2142,6 +2590,15 @@ export default function HomePage() {
> >
导出有效数据 导出有效数据
</button> </button>
<button
className="secondaryButton"
type="button"
onClick={handleExportFullReport}
disabled={!canExportFullReport || exportingReport}
title={reportExportDisabledReason || '导出当前风机的完整功率曲线报告'}
>
{exportingReport ? '导出报告中...' : '导出完整报告'}
</button>
<button <button
className="secondaryButton" className="secondaryButton"
type="button" type="button"
@@ -2276,16 +2733,16 @@ export default function HomePage() {
显示滤除点 显示滤除点
</label> </label>
<label> <label>
<span>文字大小</span> <span>文字倍率</span>
<input <input
type="range" type="range"
min="10" min={TEXT_SCALE_LIMITS.min}
max="24" max={TEXT_SCALE_LIMITS.max}
step="1" step="0.05"
value={chartOptions.text_size} value={chartOptions.text_scale}
onChange={(event) => handleChartOptionChange('text_size', Number(event.target.value))} onChange={(event) => handleChartOptionChange('text_scale', Number(event.target.value))}
/> />
<b>{chartOptions.text_size}px</b> <b>{Math.round(chartOptions.text_scale * 100)}%</b>
</label> </label>
<label> <label>
<span>文字颜色</span> <span>文字颜色</span>
@@ -2460,6 +2917,7 @@ export default function HomePage() {
designPoints={designCurve} designPoints={designCurve}
chartOptions={chartOptions} chartOptions={chartOptions}
chartExportRef={chartExportRef} chartExportRef={chartExportRef}
chartSnapshotRef={chartSnapshotRef}
editMode={isEditMode} editMode={isEditMode}
editTool={editTool} editTool={editTool}
onRestorePoints={handleRestorePoints} onRestorePoints={handleRestorePoints}