diff --git a/backend/config/server_config.json b/backend/config/server_config.json
index 071b1b1..37b51f1 100644
--- a/backend/config/server_config.json
+++ b/backend/config/server_config.json
@@ -11,6 +11,7 @@
"document_root": "./web",
"upload_path": "uploads",
"client_max_body_size": "10M",
+ "client_max_memory_body_size": "20M",
"max_connections": 1000,
"log": {
"log_path": "",
diff --git a/frontend/web_app/src/App.css b/frontend/web_app/src/App.css
index dc47007..39dfc5f 100644
--- a/frontend/web_app/src/App.css
+++ b/frontend/web_app/src/App.css
@@ -75,6 +75,45 @@
color: #fde68a;
}
+.editToolGroup {
+ display: inline-flex;
+ align-items: center;
+ overflow: hidden;
+ border: 1px solid #343449;
+ border-radius: 8px;
+ background: #151520;
+}
+
+.toolButton {
+ min-height: 34px;
+ border: 0;
+ border-right: 1px solid #343449;
+ background: transparent;
+ color: #cbd5e1;
+ padding: 0 12px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.toolButton:last-child {
+ border-right: 0;
+}
+
+.toolButton:hover {
+ background: #24243a;
+}
+
+.toolButton.active {
+ background: rgba(251, 191, 36, 0.14);
+ color: #fde68a;
+}
+
+.toolButton.erase.active {
+ background: rgba(248, 113, 113, 0.14);
+ color: #fecaca;
+}
+
.uploadButton input {
display: none;
}
@@ -607,6 +646,16 @@ select:focus {
background: rgba(251, 191, 36, 0.16);
}
+.chartBrush.erase {
+ border-color: rgba(248, 113, 113, 0.95);
+ background: rgba(248, 113, 113, 0.08);
+ box-shadow: 0 0 0 1px rgba(16, 16, 26, 0.8), 0 0 18px rgba(248, 113, 113, 0.24);
+}
+
+.chartBrush.erase.dragging {
+ background: rgba(248, 113, 113, 0.16);
+}
+
.chartTools {
display: flex;
align-items: center;
diff --git a/frontend/web_app/src/pages/HomePage.jsx b/frontend/web_app/src/pages/HomePage.jsx
index dbf426b..eefc94c 100644
--- a/frontend/web_app/src/pages/HomePage.jsx
+++ b/frontend/web_app/src/pages/HomePage.jsx
@@ -49,7 +49,9 @@ const DESIGN_FIELD_HINTS = {
const CHUNK_SIZE = 4000;
const CHART_HEIGHT = 540;
-const BRUSH_RADIUS = 16;
+const BRUSH_RADIUS = 10;
+const EDIT_TOOL_RESTORE = 'restore';
+const EDIT_TOOL_ERASE = 'erase';
const DEFAULT_SCHEME_ID = 'scheme_one';
const SCHEME_TWO_ID = 'scheme_two';
const DEFAULT_SCHEMES = [
@@ -436,7 +438,9 @@ function PowerCurveChart({
showFiltered,
designPoints,
editMode,
+ editTool,
onRestorePoints,
+ onErasePoints,
}) {
const frameRef = useRef(null);
const chartRef = useRef(null);
@@ -587,14 +591,17 @@ function PowerCurveChart({
dragging: nextDragging,
});
};
- const restoreAt = (event) => {
- if (!chart.over || !onRestorePoints) return;
+ const applyToolAt = (event) => {
+ if (!chart.over) 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 targetPoints = editTool === EDIT_TOOL_ERASE
+ ? validScatter
+ : validFiltered.filter((point) => point.reason !== 'manual_erased');
+ const hitPoints = [];
+ for (const point of targetPoints) {
const pointX = chart.valToPos(point.wind_speed, 'x');
const pointY = chart.valToPos(point.active_power, 'y');
if (!Number.isFinite(pointX) || !Number.isFinite(pointY)) {
@@ -603,11 +610,16 @@ function PowerCurveChart({
const dx = pointX - x;
const dy = pointY - y;
if (dx * dx + dy * dy <= radiusSquared) {
- restored.push(point);
+ hitPoints.push(point);
}
}
- if (restored.length) {
- onRestorePoints(restored);
+ if (!hitPoints.length) {
+ return;
+ }
+ if (editTool === EDIT_TOOL_ERASE) {
+ onErasePoints?.(hitPoints);
+ } else {
+ onRestorePoints?.(hitPoints);
}
};
const handlePointerDown = (event) => {
@@ -615,12 +627,12 @@ function PowerCurveChart({
event.preventDefault();
dragging = true;
updateBrush(event, true);
- restoreAt(event);
+ applyToolAt(event);
};
const handlePointerMove = (event) => {
updateBrush(event);
if (dragging) {
- restoreAt(event);
+ applyToolAt(event);
}
};
const handlePointerUp = (event) => {
@@ -647,7 +659,9 @@ function PowerCurveChart({
validDesign,
showFiltered,
editMode,
+ editTool,
onRestorePoints,
+ onErasePoints,
]);
useEffect(() => {
@@ -661,7 +675,12 @@ function PowerCurveChart({
}
return (
-
+
散点数据
实际功率
@@ -670,7 +689,9 @@ function PowerCurveChart({
{editMode && brush.visible && (
createDesignRows());
const [designMeta, setDesignMeta] = useState(null);
@@ -710,9 +733,16 @@ export default function HomePage() {
const isSchemeTwo = selectedSchemeId === SCHEME_TWO_ID;
const selectedPoints = selectedFan && result?.curves ? result.curves[selectedFan] || [] : [];
const selectedBins = selectedFan && result?.bins ? result.bins[selectedFan] || [] : [];
- const selectedScatter = selectedFan && result?.scatter_points
+ const selectedScatterRaw = selectedFan && result?.scatter_points
? result.scatter_points[selectedFan] || []
: [];
+ const selectedScatter = useMemo(
+ () => withPointIds(selectedScatterRaw, selectedFan).map((point) => ({
+ ...point,
+ edit_origin: 'scatter',
+ })),
+ [selectedScatterRaw, selectedFan],
+ );
const selectedFilteredRaw = selectedFan && result?.filtered_points
? result.filtered_points[selectedFan] || []
: [];
@@ -724,17 +754,40 @@ export default function HomePage() {
() => new Set(restoredPointIds[selectedFan] || []),
[restoredPointIds, selectedFan],
);
+ const selectedErasedIdSet = useMemo(
+ () => new Set(erasedPointIds[selectedFan] || []),
+ [erasedPointIds, selectedFan],
+ );
const restoredFilteredPoints = useMemo(
- () => selectedFiltered.filter((point) => selectedRestoredIdSet.has(point.point_id)),
+ () => selectedFiltered
+ .filter((point) => selectedRestoredIdSet.has(point.point_id))
+ .map((point) => ({ ...point, edit_origin: 'restored_filtered' })),
[selectedFiltered, selectedRestoredIdSet],
);
+ const effectiveBaseScatter = useMemo(
+ () => selectedScatter.filter((point) => !selectedErasedIdSet.has(point.point_id)),
+ [selectedScatter, selectedErasedIdSet],
+ );
+ const erasedScatterPoints = useMemo(
+ () => selectedScatter
+ .filter((point) => selectedErasedIdSet.has(point.point_id))
+ .map((point) => ({
+ ...point,
+ reason: 'manual_erased',
+ edit_origin: 'erased_scatter',
+ })),
+ [selectedScatter, selectedErasedIdSet],
+ );
const visibleFilteredPoints = useMemo(
- () => selectedFiltered.filter((point) => !selectedRestoredIdSet.has(point.point_id)),
- [selectedFiltered, selectedRestoredIdSet],
+ () => [
+ ...selectedFiltered.filter((point) => !selectedRestoredIdSet.has(point.point_id)),
+ ...erasedScatterPoints,
+ ],
+ [selectedFiltered, selectedRestoredIdSet, erasedScatterPoints],
);
const effectiveScatter = useMemo(
- () => [...selectedScatter, ...restoredFilteredPoints],
- [selectedScatter, restoredFilteredPoints],
+ () => [...effectiveBaseScatter, ...restoredFilteredPoints],
+ [effectiveBaseScatter, restoredFilteredPoints],
);
const selectedEstimatedParams = selectedFan && result?.estimated_params
? result.estimated_params[selectedFan]
@@ -760,11 +813,59 @@ export default function HomePage() {
});
}, [selectedFan]);
+ const handleErasePoints = useCallback((pointsToErase) => {
+ if (!selectedFan || !pointsToErase.length) return;
+ const restoredIdsToRemove = [];
+ const erasedIdsToAdd = [];
+ for (const point of pointsToErase) {
+ const pointId = point.point_id || createPointId(point, selectedFan);
+ if (point.edit_origin === 'restored_filtered') {
+ restoredIdsToRemove.push(pointId);
+ } else {
+ erasedIdsToAdd.push(pointId);
+ }
+ }
+ if (restoredIdsToRemove.length) {
+ setRestoredPointIds((prev) => {
+ const currentIds = new Set(prev[selectedFan] || []);
+ let changed = false;
+ for (const pointId of restoredIdsToRemove) {
+ if (currentIds.delete(pointId)) {
+ changed = true;
+ }
+ }
+ if (!changed) return prev;
+ return {
+ ...prev,
+ [selectedFan]: Array.from(currentIds),
+ };
+ });
+ }
+ if (erasedIdsToAdd.length) {
+ setErasedPointIds((prev) => {
+ const nextIds = new Set(prev[selectedFan] || []);
+ let changed = false;
+ for (const pointId of erasedIdsToAdd) {
+ 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);
+ setEditTool(EDIT_TOOL_RESTORE);
}
return next;
});
@@ -896,7 +997,9 @@ export default function HomePage() {
setSelectedFan('');
setShowFiltered(false);
setIsEditMode(false);
+ setEditTool(EDIT_TOOL_RESTORE);
setRestoredPointIds({});
+ setErasedPointIds({});
try {
const parsedFiles = [];
for (const file of selectedFiles) {
@@ -984,7 +1087,9 @@ export default function HomePage() {
setResult(null);
setShowFiltered(false);
setIsEditMode(false);
+ setEditTool(EDIT_TOOL_RESTORE);
setRestoredPointIds({});
+ setErasedPointIds({});
setProgress('正在准备标准化数据');
try {
@@ -1369,10 +1474,32 @@ export default function HomePage() {
className={`secondaryButton ${isEditMode ? 'active' : ''}`}
type="button"
onClick={handleToggleEditMode}
- disabled={!selectedFiltered.length && !isEditMode}
+ disabled={!selectedFiltered.length && !effectiveScatter.length && !isEditMode}
>
{isEditMode ? '退出编辑' : '进入编辑'}
+ {isEditMode && (
+
+
+
+
+ )}