功能: 支持功率曲线手动恢复滤除点

- 增加额定拐角过渡点保护,减少过度筛除
- 补齐滤除点导出字段
- 增加功率曲线编辑模式和笔刷恢复交互
This commit is contained in:
cloud
2026-07-17 17:21:12 +08:00
parent d5d381759e
commit 84d67dc431
3 changed files with 284 additions and 24 deletions
+60 -10
View File
@@ -101,6 +101,11 @@ struct SchemeInfo {
double rated_power = 2000.0; double rated_power = 2000.0;
}; };
constexpr double kRatedCornerWindBefore = 0.5;
constexpr double kRatedCornerWindAfter = 1.2;
constexpr double kRatedCornerPowerLowerRatio = 0.88;
constexpr double kRatedCornerPowerUpperRatio = 1.03;
std::string Trim(const std::string& value) { std::string Trim(const std::string& value) {
const auto begin = value.find_first_not_of(" \t\r\n"); const auto begin = value.find_first_not_of(" \t\r\n");
if (begin == std::string::npos) { if (begin == std::string::npos) {
@@ -485,8 +490,20 @@ void ComputeWindSpeed600sAverage(std::vector<ValidRow>& rows) {
} }
} }
bool IsRatedTransitionCorner(const ValidRow& row, const EstimatedParams& params) {
if (params.rated_power <= 0.0 || params.rated_wind_speed <= 0.0) {
return false;
}
return row.wind_speed >= params.rated_wind_speed - kRatedCornerWindBefore &&
row.wind_speed <= params.rated_wind_speed + kRatedCornerWindAfter &&
row.active_power >= params.rated_power * kRatedCornerPowerLowerRatio &&
row.active_power <= params.rated_power * kRatedCornerPowerUpperRatio;
}
std::vector<ValidRow> FilterLimitPower(const std::vector<ValidRow>& rows, std::vector<ValidRow> FilterLimitPower(const std::vector<ValidRow>& rows,
const CalculationOptions& options, const CalculationOptions& options,
const EstimatedParams* transition_params,
int& removed_count, int& removed_count,
std::vector<RemovedPoint>& removed_points) { std::vector<RemovedPoint>& removed_points) {
removed_count = 0; removed_count = 0;
@@ -529,13 +546,15 @@ std::vector<ValidRow> FilterLimitPower(const std::vector<ValidRow>& rows,
std::vector<ValidRow> result; std::vector<ValidRow> result;
result.reserve(rows.size()); result.reserve(rows.size());
for (size_t i = 0; i < rows.size(); ++i) { for (size_t i = 0; i < rows.size(); ++i) {
if (remove_indexes.count(i) == 0) { const bool protect_transition =
transition_params != nullptr && IsRatedTransitionCorner(rows[i], *transition_params);
if (remove_indexes.count(i) == 0 || protect_transition) {
result.push_back(rows[i]); result.push_back(rows[i]);
} else { } else {
removed_points.push_back(RemovedPoint{rows[i], "limit_power"}); removed_points.push_back(RemovedPoint{rows[i], "limit_power"});
++removed_count;
} }
} }
removed_count = static_cast<int>(remove_indexes.size());
return result; return result;
} }
@@ -607,7 +626,8 @@ std::vector<ValidRow> FilterByWindBinIqr(const std::vector<ValidRow>& rows,
ValueGetter value_getter, ValueGetter value_getter,
int& removed_count, int& removed_count,
std::vector<RemovedPoint>& removed_points, std::vector<RemovedPoint>& removed_points,
const std::string& reason) { const std::string& reason,
const EstimatedParams* transition_params = nullptr) {
removed_count = 0; removed_count = 0;
if (rows.empty()) { if (rows.empty()) {
return {}; return {};
@@ -649,6 +669,9 @@ std::vector<ValidRow> FilterByWindBinIqr(const std::vector<ValidRow>& rows,
const double value = value_getter(row); const double value = value_getter(row);
if (value >= lower && value <= upper) { if (value >= lower && value <= upper) {
result.push_back(row); result.push_back(row);
} else if (transition_params != nullptr &&
IsRatedTransitionCorner(row, *transition_params)) {
result.push_back(row);
} else { } else {
++removed_count; ++removed_count;
removed_points.push_back(RemovedPoint{row, reason}); removed_points.push_back(RemovedPoint{row, reason});
@@ -768,9 +791,10 @@ std::vector<ValidRow> FilterHighWindLowPower(const std::vector<ValidRow>& rows,
return rows; return rows;
} }
const double high_wind_start = params.rated_wind_speed + 1.0;
std::vector<double> platform_powers; std::vector<double> platform_powers;
for (const auto& row : rows) { for (const auto& row : rows) {
if (row.wind_speed >= params.rated_wind_speed) { if (row.wind_speed >= high_wind_start) {
platform_powers.push_back(row.active_power); platform_powers.push_back(row.active_power);
} }
} }
@@ -786,7 +810,8 @@ std::vector<ValidRow> FilterHighWindLowPower(const std::vector<ValidRow>& rows,
std::vector<ValidRow> result; std::vector<ValidRow> result;
result.reserve(rows.size()); result.reserve(rows.size());
for (const auto& row : rows) { for (const auto& row : rows) {
if (row.wind_speed >= params.rated_wind_speed && if (row.wind_speed >= high_wind_start &&
!IsRatedTransitionCorner(row, params) &&
row.active_power < lower_limit) { row.active_power < lower_limit) {
++removed_count; ++removed_count;
removed_points.push_back(RemovedPoint{row, "high_wind_low_power"}); removed_points.push_back(RemovedPoint{row, "high_wind_low_power"});
@@ -846,14 +871,26 @@ std::vector<ValidRow> FilterCurveResidualOutliers(const std::vector<ValidRow>& r
const double median = Quantile(residuals, 0.5); const double median = Quantile(residuals, 0.5);
const double mad_sigma = MedianAbsoluteDeviation(residuals, median) * 1.4826; const double mad_sigma = MedianAbsoluteDeviation(residuals, median) * 1.4826;
const double rated_power = params.rated_power > 0.0 ? params.rated_power : options.rated_power; const double rated_power = params.rated_power > 0.0 ? params.rated_power : options.rated_power;
const double lower_margin = std::max(4.0 * mad_sigma, rated_power * 0.10); double transition_multiplier = 1.0;
const double upper_margin = std::max(4.0 * mad_sigma, rated_power * 0.16); if (params.rated_wind_speed > 0.0) {
const double interval_center = (start + end) / 2.0;
if (interval_center >= params.rated_wind_speed - 1.0 &&
interval_center <= params.rated_wind_speed + 1.5) {
transition_multiplier = 1.8;
}
}
const double lower_margin =
std::max(4.0 * mad_sigma, rated_power * 0.10) * transition_multiplier;
const double upper_margin =
std::max(4.0 * mad_sigma, rated_power * 0.16) * transition_multiplier;
const double lower = median - lower_margin; const double lower = median - lower_margin;
const double upper = median + upper_margin; const double upper = median + upper_margin;
for (size_t i = 0; i < interval_rows.size(); ++i) { for (size_t i = 0; i < interval_rows.size(); ++i) {
if (residuals[i] >= lower && residuals[i] <= upper) { if (residuals[i] >= lower && residuals[i] <= upper) {
result.push_back(interval_rows[i]); result.push_back(interval_rows[i]);
} else if (IsRatedTransitionCorner(interval_rows[i], params)) {
result.push_back(interval_rows[i]);
} else { } else {
++removed_count; ++removed_count;
removed_points.push_back(RemovedPoint{interval_rows[i], "curve_residual_outlier"}); removed_points.push_back(RemovedPoint{interval_rows[i], "curve_residual_outlier"});
@@ -873,7 +910,7 @@ std::vector<ValidRow> FilterStrictRatedPlateau(const std::vector<ValidRow>& rows
return rows; return rows;
} }
const double strict_start_wind_speed = params.rated_wind_speed + 0.5; const double strict_start_wind_speed = params.rated_wind_speed + 1.5;
std::vector<double> plateau_powers; std::vector<double> plateau_powers;
for (const auto& row : rows) { for (const auto& row : rows) {
if (row.wind_speed >= strict_start_wind_speed) { if (row.wind_speed >= strict_start_wind_speed) {
@@ -894,6 +931,7 @@ std::vector<ValidRow> FilterStrictRatedPlateau(const std::vector<ValidRow>& rows
result.reserve(rows.size()); result.reserve(rows.size());
for (const auto& row : rows) { for (const auto& row : rows) {
if (row.wind_speed >= strict_start_wind_speed && if (row.wind_speed >= strict_start_wind_speed &&
!IsRatedTransitionCorner(row, params) &&
row.active_power < lower_limit) { row.active_power < lower_limit) {
++removed_count; ++removed_count;
removed_points.push_back(RemovedPoint{row, "rated_plateau_low_power"}); removed_points.push_back(RemovedPoint{row, "rated_plateau_low_power"});
@@ -1270,7 +1308,14 @@ void WindPowerController::FinishJob(
fan_removed_points); fan_removed_points);
scheme_two_grid_speed_low_count += removed; scheme_two_grid_speed_low_count += removed;
fan_rows = FilterLimitPower(fan_rows, options, removed, fan_removed_points); const auto initial_estimated_params = EstimateRatedParams(fan_rows, options);
fan_rows = FilterLimitPower(
fan_rows,
options,
&initial_estimated_params,
removed,
fan_removed_points);
limit_power_count += removed; limit_power_count += removed;
for (auto& row : fan_rows) { for (auto& row : fan_rows) {
@@ -1293,7 +1338,8 @@ void WindPowerController::FinishJob(
[](const ValidRow& row) { return row.active_power; }, [](const ValidRow& row) { return row.active_power; },
removed, removed,
fan_removed_points, fan_removed_points,
"speed_power_outlier"); "speed_power_outlier",
&initial_estimated_params);
speed_power_outlier_count += removed; speed_power_outlier_count += removed;
const auto fan_estimated_params = EstimateRatedParams(fan_rows, options); const auto fan_estimated_params = EstimateRatedParams(fan_rows, options);
@@ -1357,8 +1403,12 @@ void WindPowerController::FinishJob(
json fan_filtered = json::array(); json fan_filtered = json::array();
for (const auto& removed_point : fan_removed_points) { for (const auto& removed_point : fan_removed_points) {
json point; json point;
point["fan_id"] = removed_point.row.fan_id;
point["time"] = removed_point.row.time;
point["wind_speed"] = removed_point.row.wind_speed; point["wind_speed"] = removed_point.row.wind_speed;
point["active_power"] = removed_point.row.active_power; point["active_power"] = removed_point.row.active_power;
point["generator_speed"] = removed_point.row.generator_speed;
point["pitch_angle_average"] = removed_point.row.pitch_angle_average;
point["reason"] = removed_point.reason; point["reason"] = removed_point.reason;
fan_filtered.push_back(point); fan_filtered.push_back(point);
} }
+27
View File
@@ -69,6 +69,12 @@
background: #24243a; background: #24243a;
} }
.secondaryButton.active {
border-color: #fbbf24;
background: rgba(251, 191, 36, 0.14);
color: #fde68a;
}
.uploadButton input { .uploadButton input {
display: none; display: none;
} }
@@ -499,6 +505,10 @@ select:focus {
min-height: 540px; min-height: 540px;
} }
.chartFrameEditing .u-over {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'%3E%3Cpath d='M6 22l4.6-1.1L22.1 9.4l-3.5-3.5L7.1 17.4 6 22z' fill='%23fbbf24' stroke='%2310101a' stroke-width='1.6'/%3E%3Cpath d='M17.5 5.1l1.4-1.4c.8-.8 2-.8 2.8 0l2.6 2.6c.8.8.8 2 0 2.8l-1.4 1.4-5.4-5.4z' fill='%23fde68a' stroke='%2310101a' stroke-width='1.6'/%3E%3C/svg%3E") 4 24, crosshair;
}
.uplotWrap { .uplotWrap {
width: 100%; width: 100%;
min-height: 540px; min-height: 540px;
@@ -580,6 +590,23 @@ select:focus {
border-color: #22c55e; border-color: #22c55e;
} }
.chartBrush {
position: absolute;
top: 0;
left: 0;
z-index: 3;
border: 1px solid rgba(251, 191, 36, 0.95);
border-radius: 50%;
background: rgba(251, 191, 36, 0.08);
box-shadow: 0 0 0 1px rgba(16, 16, 26, 0.8), 0 0 18px rgba(251, 191, 36, 0.24);
pointer-events: none;
will-change: transform;
}
.chartBrush.dragging {
background: rgba(251, 191, 36, 0.16);
}
.chartTools { .chartTools {
display: flex; display: flex;
align-items: center; align-items: center;
+196 -13
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import uPlot from 'uplot'; import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css'; import 'uplot/dist/uPlot.min.css';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
@@ -49,6 +49,7 @@ const DESIGN_FIELD_HINTS = {
const CHUNK_SIZE = 4000; const CHUNK_SIZE = 4000;
const CHART_HEIGHT = 540; const CHART_HEIGHT = 540;
const BRUSH_RADIUS = 16;
const DEFAULT_SCHEME_ID = 'scheme_one'; const DEFAULT_SCHEME_ID = 'scheme_one';
const SCHEME_TWO_ID = 'scheme_two'; const SCHEME_TWO_ID = 'scheme_two';
const DEFAULT_SCHEMES = [ const DEFAULT_SCHEMES = [
@@ -355,6 +356,23 @@ function exportValidRowsToExcel(fanId, rows) {
); );
} }
function createPointId(point, fanId) {
return [
point.fan_id || fanId || '',
point.time || '',
point.wind_speed ?? '',
point.active_power ?? '',
point.reason || '',
].join('|');
}
function withPointIds(points, fanId) {
return (points || []).map((point) => ({
...point,
point_id: point.point_id || createPointId(point, fanId),
}));
}
function interpolateDesignPower(points, windSpeed) { function interpolateDesignPower(points, windSpeed) {
if (!points.length) return null; if (!points.length) return null;
if (windSpeed < points[0].wind_speed || windSpeed > points[points.length - 1].wind_speed) { if (windSpeed < points[0].wind_speed || windSpeed > points[points.length - 1].wind_speed) {
@@ -417,8 +435,12 @@ function PowerCurveChart({
filteredPoints, filteredPoints,
showFiltered, showFiltered,
designPoints, designPoints,
editMode,
onRestorePoints,
}) { }) {
const frameRef = useRef(null);
const chartRef = useRef(null); const chartRef = useRef(null);
const [brush, setBrush] = useState({ visible: false, x: 0, y: 0, dragging: false });
const validPoints = useMemo( const validPoints = useMemo(
() => (points || []).filter((point) => point.sample_count > 0), () => (points || []).filter((point) => point.sample_count > 0),
[points], [points],
@@ -476,7 +498,7 @@ function PowerCurveChart({
width, width,
height: CHART_HEIGHT, height: CHART_HEIGHT,
cursor: { cursor: {
drag: { x: true, y: true }, drag: { x: !editMode, y: !editMode },
}, },
scales: { scales: {
x: { time: false }, x: { time: false },
@@ -546,21 +568,116 @@ function PowerCurveChart({
}, },
}, [curveX, actualY, designY], chartRef.current); }, [curveX, actualY, designY], chartRef.current);
const hideBrush = () => {
setBrush((prev) => ({ ...prev, visible: false, dragging: false }));
};
if (!editMode) {
return () => chart.destroy(); return () => chart.destroy();
}, [validPoints, validScatter, validFiltered, validDesign, showFiltered]); }
let dragging = false;
const updateBrush = (event, nextDragging = dragging) => {
if (!frameRef.current) return;
const rect = frameRef.current.getBoundingClientRect();
setBrush({
visible: true,
x: event.clientX - rect.left,
y: event.clientY - rect.top,
dragging: nextDragging,
});
};
const restoreAt = (event) => {
if (!chart.over || !onRestorePoints) return;
const rect = chart.over.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const radiusSquared = BRUSH_RADIUS * BRUSH_RADIUS;
const restored = [];
for (const point of validFiltered) {
const pointX = chart.valToPos(point.wind_speed, 'x');
const pointY = chart.valToPos(point.active_power, 'y');
if (!Number.isFinite(pointX) || !Number.isFinite(pointY)) {
continue;
}
const dx = pointX - x;
const dy = pointY - y;
if (dx * dx + dy * dy <= radiusSquared) {
restored.push(point);
}
}
if (restored.length) {
onRestorePoints(restored);
}
};
const handlePointerDown = (event) => {
if (event.button !== 0) return;
event.preventDefault();
dragging = true;
updateBrush(event, true);
restoreAt(event);
};
const handlePointerMove = (event) => {
updateBrush(event);
if (dragging) {
restoreAt(event);
}
};
const handlePointerUp = (event) => {
updateBrush(event, false);
dragging = false;
};
chart.over.addEventListener('pointerdown', handlePointerDown);
chart.over.addEventListener('pointermove', handlePointerMove);
chart.over.addEventListener('pointerleave', hideBrush);
window.addEventListener('pointerup', handlePointerUp);
return () => {
chart.over.removeEventListener('pointerdown', handlePointerDown);
chart.over.removeEventListener('pointermove', handlePointerMove);
chart.over.removeEventListener('pointerleave', hideBrush);
window.removeEventListener('pointerup', handlePointerUp);
chart.destroy();
};
}, [
validPoints,
validScatter,
validFiltered,
validDesign,
showFiltered,
editMode,
onRestorePoints,
]);
useEffect(() => {
if (!editMode) {
setBrush((prev) => ({ ...prev, visible: false, dragging: false }));
}
}, [editMode]);
if (!validPoints.length) { if (!validPoints.length) {
return <div className="emptyChart">暂无可绘制的曲线数据</div>; return <div className="emptyChart">暂无可绘制的曲线数据</div>;
} }
return ( return (
<div className="chartFrame"> <div className={`chartFrame ${editMode ? 'chartFrameEditing' : ''}`} ref={frameRef}>
<div className="chartLegend"> <div className="chartLegend">
<span><i className="legendDot scatter" />散点数据</span> <span><i className="legendDot scatter" />散点数据</span>
<span><i className="legendLine actual" />实际功率</span> <span><i className="legendLine actual" />实际功率</span>
{validDesign.length > 0 && <span><i className="legendLine design" />设计功率</span>} {validDesign.length > 0 && <span><i className="legendLine design" />设计功率</span>}
</div> </div>
<div className="uplotWrap" ref={chartRef} /> <div className="uplotWrap" ref={chartRef} />
{editMode && brush.visible && (
<div
className={`chartBrush ${brush.dragging ? 'dragging' : ''}`}
style={{
width: BRUSH_RADIUS * 2,
height: BRUSH_RADIUS * 2,
transform: `translate(${brush.x - BRUSH_RADIUS}px, ${brush.y - BRUSH_RADIUS}px)`,
}}
/>
)}
</div> </div>
); );
} }
@@ -575,6 +692,8 @@ export default function HomePage() {
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [selectedFan, setSelectedFan] = useState(''); const [selectedFan, setSelectedFan] = useState('');
const [showFiltered, setShowFiltered] = useState(false); const [showFiltered, setShowFiltered] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [restoredPointIds, setRestoredPointIds] = useState({});
const [readingDesign, setReadingDesign] = useState(false); const [readingDesign, setReadingDesign] = useState(false);
const [designRows, setDesignRows] = useState(() => createDesignRows()); const [designRows, setDesignRows] = useState(() => createDesignRows());
const [designMeta, setDesignMeta] = useState(null); const [designMeta, setDesignMeta] = useState(null);
@@ -594,13 +713,63 @@ export default function HomePage() {
const selectedScatter = selectedFan && result?.scatter_points const selectedScatter = selectedFan && result?.scatter_points
? result.scatter_points[selectedFan] || [] ? result.scatter_points[selectedFan] || []
: []; : [];
const selectedFiltered = selectedFan && result?.filtered_points const selectedFilteredRaw = selectedFan && result?.filtered_points
? result.filtered_points[selectedFan] || [] ? result.filtered_points[selectedFan] || []
: []; : [];
const selectedFiltered = useMemo(
() => withPointIds(selectedFilteredRaw, selectedFan),
[selectedFilteredRaw, selectedFan],
);
const selectedRestoredIdSet = useMemo(
() => new Set(restoredPointIds[selectedFan] || []),
[restoredPointIds, selectedFan],
);
const restoredFilteredPoints = useMemo(
() => selectedFiltered.filter((point) => selectedRestoredIdSet.has(point.point_id)),
[selectedFiltered, selectedRestoredIdSet],
);
const visibleFilteredPoints = useMemo(
() => selectedFiltered.filter((point) => !selectedRestoredIdSet.has(point.point_id)),
[selectedFiltered, selectedRestoredIdSet],
);
const effectiveScatter = useMemo(
() => [...selectedScatter, ...restoredFilteredPoints],
[selectedScatter, restoredFilteredPoints],
);
const selectedEstimatedParams = selectedFan && result?.estimated_params const selectedEstimatedParams = selectedFan && result?.estimated_params
? result.estimated_params[selectedFan] ? result.estimated_params[selectedFan]
: null; : null;
const handleRestorePoints = useCallback((pointsToRestore) => {
if (!selectedFan || !pointsToRestore.length) return;
setRestoredPointIds((prev) => {
const nextIds = new Set(prev[selectedFan] || []);
let changed = false;
for (const point of pointsToRestore) {
const pointId = point.point_id || createPointId(point, selectedFan);
if (!nextIds.has(pointId)) {
nextIds.add(pointId);
changed = true;
}
}
if (!changed) return prev;
return {
...prev,
[selectedFan]: Array.from(nextIds),
};
});
}, [selectedFan]);
const handleToggleEditMode = useCallback(() => {
setIsEditMode((prev) => {
const next = !prev;
if (next) {
setShowFiltered(true);
}
return next;
});
}, []);
const totalRows = useMemo( const totalRows = useMemo(
() => files.reduce((sum, file) => sum + file.row_count, 0), () => files.reduce((sum, file) => sum + file.row_count, 0),
[files], [files],
@@ -726,6 +895,8 @@ export default function HomePage() {
setResult(null); setResult(null);
setSelectedFan(''); setSelectedFan('');
setShowFiltered(false); setShowFiltered(false);
setIsEditMode(false);
setRestoredPointIds({});
try { try {
const parsedFiles = []; const parsedFiles = [];
for (const file of selectedFiles) { for (const file of selectedFiles) {
@@ -812,6 +983,8 @@ export default function HomePage() {
setError(''); setError('');
setResult(null); setResult(null);
setShowFiltered(false); setShowFiltered(false);
setIsEditMode(false);
setRestoredPointIds({});
setProgress('正在准备标准化数据'); setProgress('正在准备标准化数据');
try { try {
@@ -1181,22 +1354,30 @@ export default function HomePage() {
<h2>功率曲线</h2> <h2>功率曲线</h2>
<div className="chartTools"> <div className="chartTools">
<span> <span>
曲线 {selectedPoints.length} · 散点 {selectedScatter.length.toLocaleString()} 曲线 {selectedPoints.length} · 散点 {effectiveScatter.length.toLocaleString()}
· 滤除 {selectedFiltered.length.toLocaleString()} · 滤除 {visibleFilteredPoints.length.toLocaleString()}
</span> </span>
<button <button
className="secondaryButton" className="secondaryButton"
type="button" type="button"
onClick={() => setShowFiltered((prev) => !prev)} onClick={() => setShowFiltered((prev) => !prev)}
disabled={!selectedFiltered.length} disabled={isEditMode || !visibleFilteredPoints.length}
> >
{showFiltered ? '隐藏滤除点' : '显示滤除点'} {showFiltered ? '隐藏滤除点' : '显示滤除点'}
</button> </button>
<button
className={`secondaryButton ${isEditMode ? 'active' : ''}`}
type="button"
onClick={handleToggleEditMode}
disabled={!selectedFiltered.length && !isEditMode}
>
{isEditMode ? '退出编辑' : '进入编辑'}
</button>
<button <button
className="secondaryButton" className="secondaryButton"
type="button" type="button"
onClick={() => exportValidRowsToExcel(selectedFan, selectedScatter)} onClick={() => exportValidRowsToExcel(selectedFan, effectiveScatter)}
disabled={!selectedScatter.length} disabled={!effectiveScatter.length}
> >
导出有效数据 导出有效数据
</button> </button>
@@ -1228,10 +1409,12 @@ export default function HomePage() {
)} )}
<PowerCurveChart <PowerCurveChart
points={selectedPoints} points={selectedPoints}
scatterPoints={selectedScatter} scatterPoints={effectiveScatter}
filteredPoints={selectedFiltered} filteredPoints={visibleFilteredPoints}
showFiltered={showFiltered} showFiltered={showFiltered || isEditMode}
designPoints={designCurve} designPoints={designCurve}
editMode={isEditMode}
onRestorePoints={handleRestorePoints}
/> />
</div> </div>