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

- 增加额定拐角过渡点保护,减少过度筛除
- 补齐滤除点导出字段
- 增加功率曲线编辑模式和笔刷恢复交互
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
+27
View File
@@ -69,6 +69,12 @@
background: #24243a;
}
.secondaryButton.active {
border-color: #fbbf24;
background: rgba(251, 191, 36, 0.14);
color: #fde68a;
}
.uploadButton input {
display: none;
}
@@ -499,6 +505,10 @@ select:focus {
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 {
width: 100%;
min-height: 540px;
@@ -580,6 +590,23 @@ select:focus {
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 {
display: flex;
align-items: center;
+197 -14
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/dist/uPlot.min.css';
import * as XLSX from 'xlsx';
@@ -49,6 +49,7 @@ const DESIGN_FIELD_HINTS = {
const CHUNK_SIZE = 4000;
const CHART_HEIGHT = 540;
const BRUSH_RADIUS = 16;
const DEFAULT_SCHEME_ID = 'scheme_one';
const SCHEME_TWO_ID = 'scheme_two';
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) {
if (!points.length) return null;
if (windSpeed < points[0].wind_speed || windSpeed > points[points.length - 1].wind_speed) {
@@ -417,8 +435,12 @@ function PowerCurveChart({
filteredPoints,
showFiltered,
designPoints,
editMode,
onRestorePoints,
}) {
const frameRef = useRef(null);
const chartRef = useRef(null);
const [brush, setBrush] = useState({ visible: false, x: 0, y: 0, dragging: false });
const validPoints = useMemo(
() => (points || []).filter((point) => point.sample_count > 0),
[points],
@@ -476,7 +498,7 @@ function PowerCurveChart({
width,
height: CHART_HEIGHT,
cursor: {
drag: { x: true, y: true },
drag: { x: !editMode, y: !editMode },
},
scales: {
x: { time: false },
@@ -546,21 +568,116 @@ function PowerCurveChart({
},
}, [curveX, actualY, designY], chartRef.current);
return () => chart.destroy();
}, [validPoints, validScatter, validFiltered, validDesign, showFiltered]);
const hideBrush = () => {
setBrush((prev) => ({ ...prev, visible: false, dragging: false }));
};
if (!editMode) {
return () => chart.destroy();
}
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) {
return <div className="emptyChart">暂无可绘制的曲线数据</div>;
}
return (
<div className="chartFrame">
<div className={`chartFrame ${editMode ? 'chartFrameEditing' : ''}`} ref={frameRef}>
<div className="chartLegend">
<span><i className="legendDot scatter" />散点数据</span>
<span><i className="legendLine actual" />实际功率</span>
{validDesign.length > 0 && <span><i className="legendLine design" />设计功率</span>}
</div>
<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>
);
}
@@ -575,6 +692,8 @@ export default function HomePage() {
const [result, setResult] = useState(null);
const [selectedFan, setSelectedFan] = useState('');
const [showFiltered, setShowFiltered] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [restoredPointIds, setRestoredPointIds] = useState({});
const [readingDesign, setReadingDesign] = useState(false);
const [designRows, setDesignRows] = useState(() => createDesignRows());
const [designMeta, setDesignMeta] = useState(null);
@@ -594,13 +713,63 @@ export default function HomePage() {
const selectedScatter = selectedFan && result?.scatter_points
? result.scatter_points[selectedFan] || []
: [];
const selectedFiltered = selectedFan && result?.filtered_points
const selectedFilteredRaw = selectedFan && result?.filtered_points
? 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
? result.estimated_params[selectedFan]
: 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(
() => files.reduce((sum, file) => sum + file.row_count, 0),
[files],
@@ -726,6 +895,8 @@ export default function HomePage() {
setResult(null);
setSelectedFan('');
setShowFiltered(false);
setIsEditMode(false);
setRestoredPointIds({});
try {
const parsedFiles = [];
for (const file of selectedFiles) {
@@ -812,6 +983,8 @@ export default function HomePage() {
setError('');
setResult(null);
setShowFiltered(false);
setIsEditMode(false);
setRestoredPointIds({});
setProgress('正在准备标准化数据');
try {
@@ -1181,22 +1354,30 @@ export default function HomePage() {
<h2>功率曲线</h2>
<div className="chartTools">
<span>
曲线 {selectedPoints.length} · 散点 {selectedScatter.length.toLocaleString()}
· 滤除 {selectedFiltered.length.toLocaleString()}
曲线 {selectedPoints.length} · 散点 {effectiveScatter.length.toLocaleString()}
· 滤除 {visibleFilteredPoints.length.toLocaleString()}
</span>
<button
className="secondaryButton"
type="button"
onClick={() => setShowFiltered((prev) => !prev)}
disabled={!selectedFiltered.length}
disabled={isEditMode || !visibleFilteredPoints.length}
>
{showFiltered ? '隐藏滤除点' : '显示滤除点'}
</button>
<button
className={`secondaryButton ${isEditMode ? 'active' : ''}`}
type="button"
onClick={handleToggleEditMode}
disabled={!selectedFiltered.length && !isEditMode}
>
{isEditMode ? '退出编辑' : '进入编辑'}
</button>
<button
className="secondaryButton"
type="button"
onClick={() => exportValidRowsToExcel(selectedFan, selectedScatter)}
disabled={!selectedScatter.length}
onClick={() => exportValidRowsToExcel(selectedFan, effectiveScatter)}
disabled={!effectiveScatter.length}
>
导出有效数据
</button>
@@ -1228,10 +1409,12 @@ export default function HomePage() {
)}
<PowerCurveChart
points={selectedPoints}
scatterPoints={selectedScatter}
filteredPoints={selectedFiltered}
showFiltered={showFiltered}
scatterPoints={effectiveScatter}
filteredPoints={visibleFilteredPoints}
showFiltered={showFiltered || isEditMode}
designPoints={designCurve}
editMode={isEditMode}
onRestorePoints={handleRestorePoints}
/>
</div>