功能: 完善功率曲线报告导出与图表样式
This commit is contained in:
@@ -259,7 +259,7 @@ bool ValidateChartOptions(const json& options, std::string& error) {
|
||||
};
|
||||
if (!validate_number("scatter_size", 1.0, 8.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("design_marker_size", 2.0, 12.0)) {
|
||||
error = "图表数值参数无效";
|
||||
@@ -311,6 +311,15 @@ std::optional<json> LoadChartOptions() {
|
||||
if (!options.contains("show_filtered")) {
|
||||
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;
|
||||
if (ValidateChartOptions(options, error)) {
|
||||
return std::optional<json>{options};
|
||||
|
||||
@@ -252,3 +252,13 @@
|
||||
"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` 会在读取时自动换算为倍率。
|
||||
|
||||
Generated
+999
-4
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"exceljs": "^4.4.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.2",
|
||||
|
||||
@@ -541,7 +541,7 @@ select:focus {
|
||||
|
||||
.chartFrame {
|
||||
position: relative;
|
||||
min-height: 540px;
|
||||
min-height: 580px;
|
||||
}
|
||||
|
||||
.chartFrameEditing .u-over {
|
||||
@@ -550,7 +550,7 @@ select:focus {
|
||||
|
||||
.uplotWrap {
|
||||
width: 100%;
|
||||
min-height: 540px;
|
||||
min-height: 580px;
|
||||
}
|
||||
|
||||
.uplot {
|
||||
|
||||
@@ -51,7 +51,7 @@ const DESIGN_FIELD_HINTS = {
|
||||
};
|
||||
|
||||
const CHUNK_SIZE = 4000;
|
||||
const CHART_HEIGHT = 540;
|
||||
const CHART_HEIGHT = 580;
|
||||
const BRUSH_RADIUS = 10;
|
||||
const EDIT_TOOL_RESTORE = 'restore';
|
||||
const EDIT_TOOL_ERASE = 'erase';
|
||||
@@ -90,7 +90,7 @@ const DEFAULT_CHART_OPTIONS = {
|
||||
scatter_color: '#2563eb',
|
||||
scatter_opacity: 42,
|
||||
show_scatter: true,
|
||||
text_size: 14,
|
||||
text_scale: 1,
|
||||
text_color: '#334155',
|
||||
actual_color: '#000000',
|
||||
actual_line_style: 'solid',
|
||||
@@ -104,6 +104,37 @@ const DEFAULT_CHART_OPTIONS = {
|
||||
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() {
|
||||
try {
|
||||
const state = JSON.parse(sessionStorage.getItem(CHART_STATE_KEY) || 'null');
|
||||
@@ -265,10 +296,19 @@ function normalizeNumber(value) {
|
||||
function chooseFirstDataSheet(workbook) {
|
||||
for (const sheetName of workbook.SheetNames) {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: true });
|
||||
const nonEmptyRows = rows.filter((row) => row.some((cell) => String(cell ?? '').trim() !== ''));
|
||||
if (nonEmptyRows.length > 1) {
|
||||
return { sheetName, rows: nonEmptyRows };
|
||||
const rawRows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: true });
|
||||
const displayRows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false });
|
||||
const nonEmptyIndexes = rawRows
|
||||
.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;
|
||||
@@ -290,6 +330,10 @@ async function readExcelFile(file) {
|
||||
sheet_name: sheetData.sheetName,
|
||||
headers,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -370,6 +414,53 @@ function buildStandardRows(files, mapping) {
|
||||
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()) {
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
|
||||
+ `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
@@ -438,6 +529,50 @@ function interpolateDesignPower(points, windSpeed) {
|
||||
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) {
|
||||
return {
|
||||
grid_connected_speed: String(
|
||||
@@ -667,10 +802,16 @@ function getVisibleActualCurve(actualCurve) {
|
||||
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 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 plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
@@ -699,10 +840,8 @@ function exportPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
|
||||
|
||||
context.fillStyle = '#ffffff';
|
||||
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 textScale = textSize / DEFAULT_CHART_OPTIONS.text_size;
|
||||
context.font = `${Math.round(16 * textScale)}px "Segoe UI", sans-serif`;
|
||||
context.font = `${typography.tick}px "Segoe UI", sans-serif`;
|
||||
|
||||
if (options.show_grid) {
|
||||
context.strokeStyle = gridColor;
|
||||
@@ -729,6 +868,7 @@ function exportPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
|
||||
context.fillText(formatTick(yValue), padding.left - 14, toY(yValue) + 5);
|
||||
}
|
||||
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.save();
|
||||
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);
|
||||
context.save();
|
||||
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.fillText(
|
||||
options.title || DEFAULT_CHART_OPTIONS.title,
|
||||
padding.left + plotWidth / 2,
|
||||
padding.top - Math.round(34 * textScale),
|
||||
padding.top - typography.title,
|
||||
);
|
||||
context.restore();
|
||||
|
||||
@@ -770,9 +910,10 @@ function exportPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
|
||||
...(showFiltered ? [['滤除点', '#94a3b8']] : []),
|
||||
];
|
||||
context.textAlign = 'left';
|
||||
context.font = `${typography.legend}px "Segoe UI", sans-serif`;
|
||||
const legendLeft = padding.left + 22;
|
||||
const legendTop = padding.top + Math.max(30, Math.round(34 * textScale));
|
||||
const legendLineHeight = Math.max(30, Math.round(28 * textScale));
|
||||
const legendTop = padding.top + Math.max(30, typography.legend + 10);
|
||||
const legendLineHeight = Math.max(34, typography.legend + 12);
|
||||
entries.forEach(([label, type], index) => {
|
||||
const top = legendTop + index * legendLineHeight;
|
||||
const isScatter = type === 'scatter';
|
||||
@@ -793,13 +934,229 @@ function exportPowerCurvePng({ actualCurve, designCurve, scatterPoints, filtered
|
||||
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');
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.download = `${String(options.title || '风速-功率曲线').replace(/[\\/:*?"<>|]/g, '_')}.png`;
|
||||
link.href = imageData;
|
||||
link.download = `${String(args.options.title || '风速-功率曲线').replace(/[\\/:*?"<>|]/g, '_')}.png`;
|
||||
link.click();
|
||||
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({
|
||||
points,
|
||||
scatterPoints,
|
||||
@@ -809,6 +1166,7 @@ function PowerCurveChart({
|
||||
designPoints,
|
||||
chartOptions,
|
||||
chartExportRef,
|
||||
chartSnapshotRef,
|
||||
editMode,
|
||||
editTool,
|
||||
onRestorePoints,
|
||||
@@ -849,23 +1207,39 @@ function PowerCurveChart({
|
||||
[validDesign, visibleActualCurve],
|
||||
);
|
||||
const activeChartOptions = useMemo(
|
||||
() => ({ ...DEFAULT_CHART_OPTIONS, ...chartOptions }),
|
||||
() => normalizeChartOptions(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;
|
||||
|
||||
chartExportRef.current = () => exportPowerCurvePng({
|
||||
actualCurve: visibleActualCurve,
|
||||
designCurve: visibleDesignCurve,
|
||||
scatterPoints: validScatter,
|
||||
filteredPoints: validFiltered,
|
||||
showActual: plotRef.current?.series[1]?.show !== false,
|
||||
showDesign: plotRef.current?.series[2]?.show !== false,
|
||||
showScatter: activeChartOptions.show_scatter,
|
||||
showFiltered,
|
||||
options: activeChartOptions,
|
||||
scales: plotRef.current?.scales,
|
||||
});
|
||||
chartSnapshotRef.current = async () => {
|
||||
const snapshot = {
|
||||
actualCurve: visibleActualCurve,
|
||||
designCurve: visibleDesignCurve,
|
||||
scatterPoints: validScatter,
|
||||
filteredPoints: validFiltered,
|
||||
showActual: plotRef.current?.series[1]?.show !== false,
|
||||
showDesign: plotRef.current?.series[2]?.show !== false,
|
||||
showScatter: showScatterOnChart,
|
||||
showFiltered,
|
||||
options: activeChartOptions,
|
||||
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(() => {
|
||||
if (!chartRef.current || (!visibleActualCurve.length && !visibleDesignCurve.length)) return undefined;
|
||||
@@ -944,8 +1318,12 @@ function PowerCurveChart({
|
||||
{
|
||||
label: activeChartOptions.x_axis_label,
|
||||
stroke: activeChartOptions.text_color,
|
||||
font: `${activeChartOptions.text_size}px "Segoe UI", sans-serif`,
|
||||
labelFont: `600 ${Math.round(activeChartOptions.text_size * 1.08)}px "Segoe UI", sans-serif`,
|
||||
size: xAxisSize,
|
||||
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),
|
||||
values: (u, splits) => splits.map(formatTick),
|
||||
grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 },
|
||||
@@ -953,8 +1331,12 @@ function PowerCurveChart({
|
||||
{
|
||||
label: activeChartOptions.y_axis_label,
|
||||
stroke: activeChartOptions.text_color,
|
||||
font: `${activeChartOptions.text_size}px "Segoe UI", sans-serif`,
|
||||
labelFont: `600 ${Math.round(activeChartOptions.text_size * 1.08)}px "Segoe UI", sans-serif`,
|
||||
size: yAxisSize,
|
||||
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),
|
||||
values: (u, splits) => splits.map(formatTick),
|
||||
grid: { show: activeChartOptions.show_grid, stroke: '#d8e0ea', width: 1 },
|
||||
@@ -1049,7 +1431,7 @@ function PowerCurveChart({
|
||||
const titleElement = chart.root.querySelector('.u-title');
|
||||
if (titleElement) {
|
||||
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';
|
||||
}
|
||||
plotRef.current = chart;
|
||||
@@ -1150,6 +1532,7 @@ function PowerCurveChart({
|
||||
onRestorePoints,
|
||||
onErasePoints,
|
||||
activeChartOptions,
|
||||
screenTypography,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1174,8 +1557,8 @@ function PowerCurveChart({
|
||||
className="chartLegend"
|
||||
style={{
|
||||
color: activeChartOptions.text_color,
|
||||
fontSize: `${activeChartOptions.text_size}px`,
|
||||
top: `${Math.max(72, Math.round(activeChartOptions.text_size * 5.5))}px`,
|
||||
fontSize: `${screenTypography.legend}px`,
|
||||
top: `${Math.max(72, screenTypography.title * 3)}px`,
|
||||
}}
|
||||
>
|
||||
{showScatterOnChart && (
|
||||
@@ -1228,8 +1611,7 @@ export default function HomePage() {
|
||||
const [readingDesign, setReadingDesign] = useState(false);
|
||||
const [designRows, setDesignRows] = useState(() => createDesignRows(savedSessionState.design_rows));
|
||||
const [designMeta, setDesignMeta] = useState(null);
|
||||
const [chartOptions, setChartOptions] = useState(() => ({
|
||||
...DEFAULT_CHART_OPTIONS,
|
||||
const [chartOptions, setChartOptions] = useState(() => normalizeChartOptions({
|
||||
...(savedSessionState.chart_options || {}),
|
||||
// Apply the new black default only to sessions carrying the old built-in orange.
|
||||
actual_color: savedSessionState.chart_options?.actual_color === '#ea580c'
|
||||
@@ -1243,7 +1625,9 @@ export default function HomePage() {
|
||||
const [chartSettingsOpen, setChartSettingsOpen] = useState(false);
|
||||
const [savingChartOptions, setSavingChartOptions] = useState(false);
|
||||
const [chartSettingsMessage, setChartSettingsMessage] = useState('');
|
||||
const [exportingReport, setExportingReport] = useState(false);
|
||||
const chartExportRef = useRef(() => false);
|
||||
const chartSnapshotRef = useRef(() => null);
|
||||
const [schemes, setSchemes] = useState(DEFAULT_SCHEMES);
|
||||
const [selectedSchemeId, setSelectedSchemeId] = useState(DEFAULT_SCHEME_ID);
|
||||
const [schemeDescription, setSchemeDescription] = useState(DEFAULT_SCHEMES[0].description);
|
||||
@@ -1314,6 +1698,18 @@ export default function HomePage() {
|
||||
[effectiveBaseScatter, restoredFilteredPoints],
|
||||
);
|
||||
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
|
||||
? result.estimated_params[selectedFan]
|
||||
: null;
|
||||
@@ -1349,7 +1745,7 @@ export default function HomePage() {
|
||||
try {
|
||||
const data = await getWindChartOptions();
|
||||
if (!canceled && data.configured && data.options) {
|
||||
setChartOptions({ ...DEFAULT_CHART_OPTIONS, ...data.options });
|
||||
setChartOptions(normalizeChartOptions(data.options));
|
||||
setShowFiltered(Boolean(data.options.show_filtered));
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1484,7 +1880,7 @@ export default function HomePage() {
|
||||
setChartSettingsMessage('');
|
||||
try {
|
||||
const data = await saveWindChartOptions({ ...chartOptions, show_filtered: showFiltered });
|
||||
setChartOptions({ ...DEFAULT_CHART_OPTIONS, ...data.options });
|
||||
setChartOptions(normalizeChartOptions(data.options));
|
||||
setShowFiltered(Boolean(data.options.show_filtered));
|
||||
setChartSettingsMessage('图表参数已保存,将作为全局默认配置。');
|
||||
} catch (err) {
|
||||
@@ -1520,6 +1916,55 @@ export default function HomePage() {
|
||||
}))
|
||||
.sort((left, right) => left.wind_speed - right.wind_speed);
|
||||
}, [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(() => {
|
||||
let canceled = false;
|
||||
@@ -1629,6 +2074,9 @@ export default function HomePage() {
|
||||
for (const file of selectedFiles) {
|
||||
parsedFiles.push(await readExcelFile(file));
|
||||
}
|
||||
if (!hasMatchingHeaderSequence(parsedFiles)) {
|
||||
throw new Error('同一批上传文件的列头和列顺序必须完全一致');
|
||||
}
|
||||
setFiles(parsedFiles);
|
||||
setMapping(inferMapping(parsedFiles[0].headers));
|
||||
} catch (err) {
|
||||
@@ -2081,7 +2529,7 @@ export default function HomePage() {
|
||||
|
||||
<div className="panel">
|
||||
<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}
|
||||
</h2>
|
||||
<div className="chartTools">
|
||||
@@ -2142,6 +2590,15 @@ export default function HomePage() {
|
||||
>
|
||||
导出有效数据
|
||||
</button>
|
||||
<button
|
||||
className="secondaryButton"
|
||||
type="button"
|
||||
onClick={handleExportFullReport}
|
||||
disabled={!canExportFullReport || exportingReport}
|
||||
title={reportExportDisabledReason || '导出当前风机的完整功率曲线报告'}
|
||||
>
|
||||
{exportingReport ? '导出报告中...' : '导出完整报告'}
|
||||
</button>
|
||||
<button
|
||||
className="secondaryButton"
|
||||
type="button"
|
||||
@@ -2276,16 +2733,16 @@ export default function HomePage() {
|
||||
显示滤除点
|
||||
</label>
|
||||
<label>
|
||||
<span>文字大小</span>
|
||||
<span>文字倍率</span>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="24"
|
||||
step="1"
|
||||
value={chartOptions.text_size}
|
||||
onChange={(event) => handleChartOptionChange('text_size', Number(event.target.value))}
|
||||
min={TEXT_SCALE_LIMITS.min}
|
||||
max={TEXT_SCALE_LIMITS.max}
|
||||
step="0.05"
|
||||
value={chartOptions.text_scale}
|
||||
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>
|
||||
<span>文字颜色</span>
|
||||
@@ -2460,6 +2917,7 @@ export default function HomePage() {
|
||||
designPoints={designCurve}
|
||||
chartOptions={chartOptions}
|
||||
chartExportRef={chartExportRef}
|
||||
chartSnapshotRef={chartSnapshotRef}
|
||||
editMode={isEditMode}
|
||||
editTool={editTool}
|
||||
onRestorePoints={handleRestorePoints}
|
||||
|
||||
Reference in New Issue
Block a user