From e125e439f83cd094c73cded47008bc99f0666d31 Mon Sep 17 00:00:00 2001 From: cloud Date: Mon, 20 Jul 2026 14:49:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=8A=9F=E7=8E=87=E6=9B=B2=E7=BA=BF=E5=9B=BE=E8=A1=A8=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=B8=8E=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持图表样式服务端保存与滤除点显示配置\n- 优化实际和设计功率曲线绘制、图例与 PNG 导出\n- 调整浅色图表主题和编辑交互 --- .../src/controllers/WindPowerController.cpp | 206 +++- backend/src/controllers/WindPowerController.h | 6 + frontend/web_app/src/App.css | 107 ++ frontend/web_app/src/index.css | 10 +- frontend/web_app/src/pages/HomePage.jsx | 1034 ++++++++++++++++- frontend/web_app/src/utils/api.js | 20 + frontend/web_app/src/utils/powerCurveModel.js | 159 +++ 7 files changed, 1476 insertions(+), 66 deletions(-) create mode 100644 frontend/web_app/src/utils/powerCurveModel.js diff --git a/backend/src/controllers/WindPowerController.cpp b/backend/src/controllers/WindPowerController.cpp index 41af81a..3d15cb2 100644 --- a/backend/src/controllers/WindPowerController.cpp +++ b/backend/src/controllers/WindPowerController.cpp @@ -1,6 +1,7 @@ #include "WindPowerController.h" #include +#include #include #include #include @@ -140,6 +141,10 @@ fs::path SchemeConfigPath() { return fs::path("data") / "wind_schemes.json"; } +fs::path ChartOptionsConfigPath() { + return fs::path("data") / "wind_chart_options.json"; +} + std::string GenerateJobId() { const auto now = std::chrono::system_clock::now().time_since_epoch().count(); std::random_device rd; @@ -192,6 +197,142 @@ std::optional GetNumberField(const json& body, const std::string& field) return body[field].get(); } +bool IsHexColor(const json& value) { + if (!value.is_string()) { + return false; + } + const auto color = value.get(); + if (color.size() != 7 || color.front() != '#') { + return false; + } + return std::all_of(color.begin() + 1, color.end(), [](unsigned char ch) { + return std::isxdigit(ch); + }); +} + +bool IsOneOf(const json& value, const std::vector& allowed) { + return value.is_string() && std::find(allowed.begin(), allowed.end(), value.get()) != allowed.end(); +} + +bool ValidateChartOptions(const json& options, std::string& error) { + if (!options.is_object()) { + error = "图表参数必须是对象"; + return false; + } + + const std::array text_fields = {"title", "x_axis_label", "y_axis_label"}; + for (const auto& field : text_fields) { + if (!options.contains(field) || !options[field].is_string() || options[field].get().size() > 200) { + error = "图表文字参数无效"; + return false; + } + } + + const std::array color_fields = { + "scatter_color", "text_color", "actual_color", "design_color", + }; + for (const auto& field : color_fields) { + if (!options.contains(field) || !IsHexColor(options[field])) { + error = "图表颜色参数无效"; + return false; + } + } + + const std::array bool_fields = { + "show_grid", "show_legend", "show_scatter", "show_filtered", "actual_show_markers", + }; + for (const auto& field : bool_fields) { + if (!options.contains(field) || !options[field].is_boolean()) { + error = "图表开关参数无效"; + return false; + } + } + if (!options.contains("design_show_markers") || !options["design_show_markers"].is_boolean()) { + error = "图表开关参数无效"; + return false; + } + + const auto validate_number = [&](const std::string& field, double minimum, double maximum) { + return options.contains(field) && options[field].is_number() && + std::isfinite(options[field].get()) && + options[field].get() >= minimum && options[field].get() <= maximum; + }; + 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("actual_marker_size", 2.0, 12.0) || + !validate_number("design_marker_size", 2.0, 12.0)) { + error = "图表数值参数无效"; + return false; + } + + for (const auto& field : {"x_tick_interval", "y_tick_interval"}) { + if (!options.contains(field) || !options[field].is_string()) { + error = "图表刻度参数无效"; + return false; + } + const auto value = Trim(options[field].get()); + if (!value.empty()) { + try { + size_t parsed = 0; + const double number = std::stod(value, &parsed); + if (parsed != value.size() || !std::isfinite(number) || number <= 0.0) { + error = "图表刻度参数无效"; + return false; + } + } catch (const std::exception&) { + error = "图表刻度参数无效"; + return false; + } + } + } + + const std::vector line_styles = {"solid", "dashed", "dotted"}; + const std::vector marker_shapes = {"circle", "square", "diamond", "triangle", "cross", "x"}; + if (!options.contains("actual_line_style") || !IsOneOf(options["actual_line_style"], line_styles) || + !options.contains("design_line_style") || !IsOneOf(options["design_line_style"], line_styles) || + !options.contains("actual_marker_shape") || !IsOneOf(options["actual_marker_shape"], marker_shapes) || + !options.contains("design_marker_shape") || !IsOneOf(options["design_marker_shape"], marker_shapes)) { + error = "图表线型或标记参数无效"; + return false; + } + return true; +} + +std::optional LoadChartOptions() { + const auto config_path = ChartOptionsConfigPath(); + if (!fs::exists(config_path)) { + return std::nullopt; + } + try { + std::ifstream in(config_path); + auto options = json::parse(in); + // Compatible with configurations saved before filtered-point visibility became persistent. + if (!options.contains("show_filtered")) { + options["show_filtered"] = false; + } + std::string error; + if (ValidateChartOptions(options, error)) { + return std::optional{options}; + } + } catch (const std::exception&) { + // Invalid optional chart configuration falls back to browser defaults. + } + return std::nullopt; +} + +bool SaveChartOptionsToFile(const json& options) { + try { + const auto config_path = ChartOptionsConfigPath(); + fs::create_directories(config_path.parent_path()); + std::ofstream out(config_path); + out << options.dump(2); + return static_cast(out); + } catch (const std::exception&) { + return false; + } +} + std::string NormalizeSchemeId(const std::string& scheme_id) { return scheme_id == kSchemeTwoId ? kSchemeTwoId : kDefaultSchemeId; } @@ -276,13 +417,34 @@ std::vector LoadSchemes() { return schemes; } -bool SaveSchemes(const std::vector& schemes) { +std::string LoadDefaultSchemeId() { + const auto config_path = SchemeConfigPath(); + if (!fs::exists(config_path)) { + return kDefaultSchemeId; + } + + try { + std::ifstream in(config_path); + const auto saved = json::parse(in); + const auto default_scheme_id = GetStringField(saved, "default_scheme_id"); + if (default_scheme_id.has_value() && + default_scheme_id.value() == NormalizeSchemeId(default_scheme_id.value())) { + return default_scheme_id.value(); + } + } catch (const std::exception&) { + // A malformed optional config must not block the built-in default scheme. + } + return kDefaultSchemeId; +} + +bool SaveSchemes(const std::vector& schemes, + const std::string& default_scheme_id) { try { const auto config_path = SchemeConfigPath(); fs::create_directories(config_path.parent_path()); json data; - data["default_scheme_id"] = kDefaultSchemeId; + data["default_scheme_id"] = NormalizeSchemeId(default_scheme_id); data["schemes"] = json::array(); for (const auto& scheme : schemes) { data["schemes"].push_back(SchemeToJson(scheme)); @@ -955,7 +1117,7 @@ void WindPowerController::GetSchemes( const HttpRequestPtr&, std::function&& callback) { json data; - data["default_scheme_id"] = kDefaultSchemeId; + data["default_scheme_id"] = LoadDefaultSchemeId(); data["schemes"] = json::array(); for (const auto& scheme : LoadSchemes()) { data["schemes"].push_back(SchemeToJson(scheme)); @@ -1034,17 +1196,53 @@ void WindPowerController::SaveSchemeDescription( SendError(callback, kErrorInvalidRequest, "方案不存在"); return; } - if (!SaveSchemes(schemes)) { + if (!SaveSchemes(schemes, normalized_id)) { SendError(callback, kErrorServer, "保存方案描述失败"); return; } const auto saved_scheme = FindScheme(schemes, normalized_id); json data; + data["default_scheme_id"] = normalized_id; data["scheme"] = SchemeToJson(saved_scheme.value()); SendSuccess(callback, data); } +void WindPowerController::GetChartOptions( + const HttpRequestPtr&, + std::function&& callback) { + json data; + const auto options = LoadChartOptions(); + data["configured"] = options.has_value(); + if (options.has_value()) { + data["options"] = options.value(); + } + SendSuccess(callback, data); +} + +void WindPowerController::SaveChartOptions( + const HttpRequestPtr& req, + std::function&& callback) { + std::string error; + const auto options = ParseBody(req, error); + if (!options.has_value()) { + SendError(callback, kErrorInvalidRequest, error); + return; + } + if (!ValidateChartOptions(options.value(), error)) { + SendError(callback, kErrorInvalidRequest, error); + return; + } + if (!SaveChartOptionsToFile(options.value())) { + SendError(callback, kErrorServer, "保存图表参数失败"); + return; + } + json data; + data["configured"] = true; + data["options"] = options.value(); + SendSuccess(callback, data); +} + void WindPowerController::StartJob( const HttpRequestPtr& req, std::function&& callback) { diff --git a/backend/src/controllers/WindPowerController.h b/backend/src/controllers/WindPowerController.h index 7af4b91..8d879c7 100644 --- a/backend/src/controllers/WindPowerController.h +++ b/backend/src/controllers/WindPowerController.h @@ -19,6 +19,8 @@ public: ADD_METHOD_TO(WindPowerController::SaveSchemeDescription, "/api/wind/schemes/{1}/description", Post); + ADD_METHOD_TO(WindPowerController::GetChartOptions, "/api/wind/chart-options", Get); + ADD_METHOD_TO(WindPowerController::SaveChartOptions, "/api/wind/chart-options", Post); METHOD_LIST_END void GetSchemes(const HttpRequestPtr& req, @@ -26,6 +28,10 @@ public: void SaveSchemeDescription(const HttpRequestPtr& req, std::function&& callback, const std::string& scheme_id); + void GetChartOptions(const HttpRequestPtr& req, + std::function&& callback); + void SaveChartOptions(const HttpRequestPtr& req, + std::function&& callback); void StartJob(const HttpRequestPtr& req, std::function&& callback); void UploadChunk(const HttpRequestPtr& req, diff --git a/frontend/web_app/src/App.css b/frontend/web_app/src/App.css index 39dfc5f..07d4484 100644 --- a/frontend/web_app/src/App.css +++ b/frontend/web_app/src/App.css @@ -776,3 +776,110 @@ td { grid-template-columns: 1fr; } } + +/* ── 浅色主题覆盖 ── */ +.homeHeader h1, +.panelHeader h2, +.metric strong { color: #172033; } + +.homeHeader .subtitle, +.panelHint, +.panelHeader span, +.muted, +.designActions span, +.metric span, +.chartTools span, +.fileItem span { color: #64748b; } + +.panel { background: #ffffff; border-color: #dbe3ed; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); } +.secondaryButton { color: #334155; background: #ffffff; border-color: #cbd5e1; } +.secondaryButton:hover, +.toolButton:hover { background: #eef4ff; border-color: #6366f1; } +.secondaryButton.active { color: #4338ca; border-color: #818cf8; background: #eef2ff; } +.editToolGroup { background: #ffffff; border-color: #cbd5e1; } +.toolButton { color: #334155; border-right-color: #cbd5e1; } +.toolButton.active { color: #4338ca; background: #eef2ff; } +.toolButton.erase.active { color: #b91c1c; background: #fef2f2; } + +.alert.error, +.error { border-color: #fecaca; background: #fff1f2; color: #b91c1c; } +.alert.info { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; } + +.schemeDescriptionControl span, +.fieldControl span { color: #334155; } +.schemeDescriptionControl input, +.schemeParamGrid input, +.tableInput, +select { background: #ffffff; color: #1e293b; border-color: #cbd5e1; } +.schemeDescriptionControl input:focus, +.schemeParamGrid input:focus, +.tableInput:focus, +select:focus { border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12); } +.schemeDescriptionControl input::placeholder, +.schemeParamGrid input::placeholder, +.tableInput::placeholder { color: #94a3b8; } + +.designMeta span, +.headerPreview span, +.fileItem, +.metric, +.reasonList span, +.paramStrip span { border-color: #dbe3ed; background: #f8fafc; color: #475569; } +.designTableWrap, +.tableWrap { border-color: #dbe3ed; } +.fileItem strong { color: #1e293b; } +.emptyState, +.emptyChart { border-color: #cbd5e1; color: #64748b; background: #f8fafc; } + +th { background: #f8fafc; color: #334155; border-bottom-color: #dbe3ed; } +td { color: #334155; border-bottom-color: #e6edf5; } +.confidence.ok { background: #ecfdf5; color: #047857; } +.confidence.low { background: #fffbeb; color: #a16207; } + +.uplot { color: #334155; } +.uplot canvas { background: #ffffff; border: 1px solid #e2e8f0; } +.uplot .u-title, +.uplot .u-label, +.uplot .u-value, +.uplot .u-legend { color: #334155; } +.chartLegend span { color: inherit; text-shadow: none; } +.legendDot.scatter { background: #2563eb; } +.legendDot.filtered { background: #94a3b8; } +.legendLine.actual { border-color: #ea580c; } +.legendLine.design { border-color: #2563eb; } +.chartBrush { border-color: rgba(37, 99, 235, 0.92); background: rgba(37, 99, 235, 0.08); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.9), 0 0 18px rgba(37, 99, 235, 0.18); } +.chartBrush.erase { border-color: rgba(220, 38, 38, 0.88); background: rgba(220, 38, 38, 0.08); box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.9), 0 0 18px rgba(220, 38, 38, 0.16); } + +.chartSettings { display: grid; gap: 18px; margin: 0 0 14px; padding: 16px; border: 1px solid #dbe3ed; border-radius: 8px; background: #f8fafc; } +.chartSettingsSection + .chartSettingsSection { padding-top: 16px; border-top: 1px solid #dbe3ed; } +.chartSettingsSectionHeader { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } +.chartSettingsSectionTitle { color: #1e293b; font-size: 14px; font-weight: 700; } +.chartSettingsSectionAccent { width: 4px; height: 16px; border-radius: 2px; background: var(--section-color, #6366f1); } +.chartSettingsGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } +.chartSettings label { display: flex; align-items: center; gap: 8px; min-width: 0; color: #475569; font-size: 13px; font-weight: 600; } +.chartSettings label > span { flex: 0 0 auto; color: #475569; font-size: 13px; } +.chartSettings input[type='text'], +.chartSettings input[type='number'], +.chartSettings input:not([type]), +.chartSettings select { min-width: 0; width: 100%; height: 34px; padding: 0 9px; border: 1px solid #cbd5e1; border-radius: 6px; background: #ffffff; color: #1e293b; } +.chartSettings input:focus { outline: none; border-color: #6366f1; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12); } +.chartSettings input[type='range'] { flex: 1; accent-color: #6366f1; } +.chartSettings input[type='color'] { width: 38px; height: 30px; padding: 2px; border: 1px solid #cbd5e1; border-radius: 6px; background: #ffffff; cursor: pointer; } +.chartSettings b { min-width: 16px; color: #1e293b; text-align: right; } +.chartSettings .chartToggle { grid-column: span 1; cursor: pointer; } +.chartSettings .chartToggle input { width: 15px; height: 15px; accent-color: #6366f1; } +.chartSettingsFooter { display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding-top: 14px; border-top: 1px solid #dbe3ed; } +.chartSettingsFooter span { margin-right: auto; color: #475569; font-size: 13px; } + +@media (max-width: 920px) { + .chartSettingsGrid { grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 560px) { + .chartSettingsFooter { align-items: stretch; flex-direction: column; } + .chartSettingsFooter span { margin-right: 0; } +} + +@media (max-width: 560px) { + .chartSettingsGrid { grid-template-columns: 1fr; } +} diff --git a/frontend/web_app/src/index.css b/frontend/web_app/src/index.css index e480207..d07ad56 100644 --- a/frontend/web_app/src/index.css +++ b/frontend/web_app/src/index.css @@ -11,8 +11,8 @@ html, body, #root { } body { - background: #0d0d14; - color: #e0e0e0; + background: #f4f7fb; + color: #1e293b; font-family: 'Segoe UI', 'Inter', -apple-system, sans-serif; font-size: 16px; line-height: 1.5; @@ -25,12 +25,12 @@ body { height: 6px; } ::-webkit-scrollbar-track { - background: #0d0d14; + background: #f4f7fb; } ::-webkit-scrollbar-thumb { - background: #2a2a3a; + background: #cbd5e1; border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { - background: #3a3a4a; + background: #94a3b8; } diff --git a/frontend/web_app/src/pages/HomePage.jsx b/frontend/web_app/src/pages/HomePage.jsx index eefc94c..accf4d0 100644 --- a/frontend/web_app/src/pages/HomePage.jsx +++ b/frontend/web_app/src/pages/HomePage.jsx @@ -5,11 +5,14 @@ import * as XLSX from 'xlsx'; import { deleteWindJob, finishWindJob, + getWindChartOptions, getWindSchemes, + saveWindChartOptions, saveWindSchemeDescription, startWindJob, uploadWindChunk, } from '../utils/api'; +import { alignDesignCurveToActualCurve, buildPowerCurveModel } from '../utils/powerCurveModel'; const REQUIRED_FIELDS = [ { key: 'time', label: '时间' }, @@ -73,6 +76,42 @@ const DEFAULT_SCHEME_TWO_PARAMS = { rated_generator_speed: '1755', rated_power: '2000', }; +const CHART_STATE_KEY = 'wind_power_chart_state_v1'; +const DEFAULT_CHART_OPTIONS = { + title: '#机组功率曲线', + x_axis_label: '风速 (m/s)', + y_axis_label: '功率 (kW)', + show_grid: true, + show_legend: true, + show_filtered: false, + scatter_size: 2, + x_tick_interval: '', + y_tick_interval: '', + scatter_color: '#2563eb', + scatter_opacity: 42, + show_scatter: true, + text_size: 14, + text_color: '#334155', + actual_color: '#000000', + actual_line_style: 'solid', + actual_show_markers: false, + actual_marker_shape: 'circle', + actual_marker_size: 5, + design_color: '#dc2626', + design_line_style: 'solid', + design_show_markers: false, + design_marker_shape: 'circle', + design_marker_size: 5, +}; + +function loadChartState() { + try { + const state = JSON.parse(sessionStorage.getItem(CHART_STATE_KEY) || 'null'); + return state && typeof state === 'object' ? state : {}; + } catch { + return {}; + } +} function createDesignRows(points) { const source = points?.length ? points : [{ wind_speed: '', design_power: '' }]; @@ -431,12 +470,345 @@ function SummaryCards({ summary }) { ); } +function getLineDash(style) { + if (style === 'dashed') return [10, 6]; + if (style === 'dotted') return [2, 5]; + return []; +} + +function drawMarker(context, x, y, shape, size, color) { + const radius = Math.max(1, Number(size) || 1); + context.save(); + context.strokeStyle = color; + context.fillStyle = color; + context.lineWidth = Math.max(1, radius / 2.5); + context.beginPath(); + if (shape === 'square') { + context.fillRect(x - radius, y - radius, radius * 2, radius * 2); + } else if (shape === 'diamond') { + context.moveTo(x, y - radius); + context.lineTo(x + radius, y); + context.lineTo(x, y + radius); + context.lineTo(x - radius, y); + context.closePath(); + context.fill(); + } else if (shape === 'triangle') { + context.moveTo(x, y - radius); + context.lineTo(x + radius, y + radius); + context.lineTo(x - radius, y + radius); + context.closePath(); + context.fill(); + } else if (shape === 'cross' || shape === 'x') { + if (shape === 'cross') { + context.moveTo(x - radius, y); + context.lineTo(x + radius, y); + context.moveTo(x, y - radius); + context.lineTo(x, y + radius); + } else { + context.moveTo(x - radius, y - radius); + context.lineTo(x + radius, y + radius); + context.moveTo(x + radius, y - radius); + context.lineTo(x - radius, y + radius); + } + context.stroke(); + } else { + context.arc(x, y, radius, 0, Math.PI * 2); + context.fill(); + } + context.restore(); +} + +function getCurveStyle(options, series) { + return { + color: options[`${series}_color`], + lineStyle: options[`${series}_line_style`], + showMarkers: Boolean(options[`${series}_show_markers`]), + markerShape: options[`${series}_marker_shape`], + markerSize: options[`${series}_marker_size`], + }; +} + +function drawSmoothLine(context, points, toX, toY, style) { + const segments = []; + let currentSegment = []; + points.forEach((point) => { + const value = point.average_power ?? point.design_power; + if (!Number.isFinite(point.wind_speed) || !Number.isFinite(value)) { + if (currentSegment.length) segments.push(currentSegment); + currentSegment = []; + return; + } + currentSegment.push({ x: toX(point.wind_speed), y: toY(value) }); + }); + if (currentSegment.length) segments.push(currentSegment); + if (!segments.length) return; + + context.save(); + context.strokeStyle = style.color; + context.lineWidth = 3; + context.lineJoin = 'round'; + context.lineCap = 'round'; + context.setLineDash(getLineDash(style.lineStyle)); + context.beginPath(); + + segments.forEach((segment) => { + context.moveTo(segment[0].x, segment[0].y); + if (segment.length < 2) return; + + const slopes = segment.slice(1).map((point, index) => { + const previous = segment[index]; + const deltaX = point.x - previous.x; + return deltaX > 0 ? (point.y - previous.y) / deltaX : 0; + }); + const tangents = segment.map((_, index) => { + if (index === 0) return slopes[0]; + if (index === segment.length - 1) return slopes[slopes.length - 1]; + return (slopes[index - 1] + slopes[index]) / 2; + }); + + // Fritsch-Carlson limiting keeps each cubic segment within its endpoint range. + slopes.forEach((slope, index) => { + if (Math.abs(slope) < 1e-9) { + tangents[index] = 0; + tangents[index + 1] = 0; + return; + } + const alpha = tangents[index] / slope; + const beta = tangents[index + 1] / slope; + const length = Math.hypot(alpha, beta); + if (length > 3) { + const scale = 3 / length; + tangents[index] = scale * alpha * slope; + tangents[index + 1] = scale * beta * slope; + } + }); + + segment.slice(1).forEach((point, index) => { + const previous = segment[index]; + const deltaX = point.x - previous.x; + if (deltaX <= 0) { + context.lineTo(point.x, point.y); + return; + } + context.bezierCurveTo( + previous.x + deltaX / 3, + previous.y + tangents[index] * deltaX / 3, + point.x - deltaX / 3, + point.y - tangents[index + 1] * deltaX / 3, + point.x, + point.y, + ); + }); + }); + context.stroke(); + context.setLineDash([]); + if (style.showMarkers) { + points.forEach((point) => { + const value = point.average_power ?? point.design_power; + if (!Number.isFinite(point.wind_speed) || !Number.isFinite(value)) return; + drawMarker(context, toX(point.wind_speed), toY(value), style.markerShape, style.markerSize, style.color); + }); + } + context.restore(); +} + +function getPositiveTickInterval(value) { + const interval = Number(value); + return Number.isFinite(interval) && interval > 0 ? interval : null; +} + +function getNiceTickInterval(min, max) { + const range = Math.max(Math.abs(max - min), 1); + const raw = range / 8; + const magnitude = 10 ** Math.floor(Math.log10(raw)); + const normalized = raw / magnitude; + const factor = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10; + return factor * magnitude; +} + +function resolveTickInterval(min, max, configuredInterval) { + return getPositiveTickInterval(configuredInterval) || getNiceTickInterval(min, max); +} + +function getAxisMaximum(max, configuredInterval) { + const interval = resolveTickInterval(0, max, configuredInterval); + return Math.max(interval, Math.ceil(Math.max(max, 0) / interval) * interval); +} + +function getAxisTicks(min, max, configuredInterval) { + const interval = resolveTickInterval(min, max, configuredInterval); + const first = Math.ceil((min - 1e-9) / interval) * interval; + const ticks = []; + for (let value = first; value <= max + interval * 1e-6 && ticks.length < 120; value += interval) { + ticks.push(Number(value.toFixed(10))); + } + return ticks; +} + +function formatTick(value) { + if (!Number.isFinite(value)) return ''; + return Math.abs(value) >= 100 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, ''); +} + +function hexToRgba(color, opacity) { + const hex = /^#([\da-f]{6})$/i.exec(String(color || '')); + const alpha = Math.min(1, Math.max(0, Number(opacity) / 100)); + if (!hex) return `rgba(37, 99, 235, ${alpha})`; + const value = Number.parseInt(hex[1], 16); + return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`; +} + +function getVisibleActualCurve(actualCurve) { + const sorted = [...actualCurve].sort((left, right) => left.wind_speed - right.wind_speed); + const validIndexes = sorted + .map((point, index) => (Number.isFinite(point.average_power) ? index : -1)) + .filter((index) => index >= 0); + if (!validIndexes.length) return sorted; + return sorted.slice(validIndexes[0], validIndexes[validIndexes.length - 1] + 1); +} + +function exportPowerCurvePng({ 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 gridColor = '#d8e0ea'; + const plotWidth = width - padding.left - padding.right; + const plotHeight = height - padding.top - padding.bottom; + const values = [ + ...(showActual ? actualCurve.map((point) => ({ x: point.wind_speed, y: point.average_power })) : []), + ...(showDesign ? designCurve.map((point) => ({ x: point.wind_speed, y: point.design_power })) : []), + ...scatterPoints.map((point) => ({ x: point.wind_speed, y: point.active_power })), + ...(showFiltered ? filteredPoints.map((point) => ({ x: point.wind_speed, y: point.active_power })) : []), + ].filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)); + if (!values.length) return false; + const xMin = Number.isFinite(scales?.x?.min) ? scales.x.min : 0; + const xMax = Number.isFinite(scales?.x?.max) + ? scales.x.max + : getAxisMaximum(Math.max(...values.map((point) => point.x), 1), options.x_tick_interval); + const yMin = Number.isFinite(scales?.y?.min) ? scales.y.min : 0; + const yMax = Number.isFinite(scales?.y?.max) + ? scales.y.max + : getAxisMaximum(Math.max(...values.map((point) => point.y), 1), options.y_tick_interval); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context) return false; + const toX = (value) => padding.left + ((value - xMin) / (xMax - xMin || 1)) * plotWidth; + const toY = (value) => padding.top + plotHeight - ((value - yMin) / (yMax - yMin || 1)) * plotHeight; + + 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`; + + if (options.show_grid) { + context.strokeStyle = gridColor; + context.lineWidth = 1; + for (const value of getAxisTicks(xMin, xMax, options.x_tick_interval)) { + const x = toX(value); + context.beginPath(); context.moveTo(x, padding.top); context.lineTo(x, padding.top + plotHeight); context.stroke(); + } + for (const value of getAxisTicks(yMin, yMax, options.y_tick_interval)) { + const y = toY(value); + context.beginPath(); context.moveTo(padding.left, y); context.lineTo(padding.left + plotWidth, y); context.stroke(); + } + } + context.strokeStyle = gridColor; + context.lineWidth = 1; + context.strokeRect(padding.left, padding.top, plotWidth, plotHeight); + context.fillStyle = textColor; + for (const xValue of getAxisTicks(xMin, xMax, options.x_tick_interval)) { + context.textAlign = 'center'; + context.fillText(formatTick(xValue), toX(xValue), padding.top + plotHeight + 30); + } + for (const yValue of getAxisTicks(yMin, yMax, options.y_tick_interval)) { + context.textAlign = 'right'; + context.fillText(formatTick(yValue), padding.left - 14, toY(yValue) + 5); + } + context.textAlign = 'center'; + 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); + context.rotate(-Math.PI / 2); + context.fillText(options.y_axis_label || DEFAULT_CHART_OPTIONS.y_axis_label, 0, 0); + context.restore(); + + const pointRadius = Math.max(1, Number(options.scatter_size) || 2) * 1.6; + const drawPoints = (points, color) => { + context.save(); context.fillStyle = color; + points.forEach((point) => { + if (!Number.isFinite(point.wind_speed) || !Number.isFinite(point.active_power)) return; + context.beginPath(); context.arc(toX(point.wind_speed), toY(point.active_power), pointRadius, 0, Math.PI * 2); context.fill(); + }); + context.restore(); + }; + if (showScatter) drawPoints(scatterPoints, hexToRgba(options.scatter_color, options.scatter_opacity)); + if (showFiltered) drawPoints(filteredPoints, 'rgba(148, 163, 184, 0.72)'); + const actualStyle = getCurveStyle(options, 'actual'); + const designStyle = getCurveStyle(options, 'design'); + if (showDesign && designCurve.length) drawSmoothLine(context, designCurve, toX, toY, designStyle); + 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.textAlign = 'center'; + context.fillText( + options.title || DEFAULT_CHART_OPTIONS.title, + padding.left + plotWidth / 2, + padding.top - Math.round(34 * textScale), + ); + context.restore(); + + if (options.show_legend) { + const entries = [ + ...(showScatter ? [['散点数据', 'scatter']] : []), + ...(showActual ? [['实际功率', 'actual']] : []), + ...(showDesign && designCurve.length ? [['设计功率', 'design']] : []), + ...(showFiltered ? [['滤除点', '#94a3b8']] : []), + ]; + context.textAlign = 'left'; + 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)); + entries.forEach(([label, type], index) => { + const top = legendTop + index * legendLineHeight; + const isScatter = type === 'scatter'; + const style = type === 'actual' ? actualStyle : type === 'design' ? designStyle : null; + if (isScatter) { + drawMarker(context, legendLeft + 10, top, 'circle', 4, hexToRgba(options.scatter_color, options.scatter_opacity)); + } else if (style) { + context.save(); + context.strokeStyle = style.color; + context.lineWidth = 3; + context.setLineDash(getLineDash(style.lineStyle)); + context.beginPath(); context.moveTo(legendLeft, top); context.lineTo(legendLeft + 20, top); context.stroke(); + context.restore(); + } else { + drawMarker(context, legendLeft + 10, top, 'circle', 4, type); + } + context.fillStyle = textColor; + context.fillText(label, legendLeft + 30, top + 7); + }); + } + const link = document.createElement('a'); + link.href = canvas.toDataURL('image/png'); + link.download = `${String(options.title || '风速-功率曲线').replace(/[\\/:*?"<>|]/g, '_')}.png`; + link.click(); + return true; +} + function PowerCurveChart({ points, scatterPoints, + curveScatterPoints, filteredPoints, showFiltered, designPoints, + chartOptions, + chartExportRef, editMode, editTool, onRestorePoints, @@ -444,11 +816,9 @@ function PowerCurveChart({ }) { const frameRef = useRef(null); const chartRef = useRef(null); + const plotRef = 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], - ); + const [seriesVisibility, setSeriesVisibility] = useState({ actual: true, design: true }); const validScatter = useMemo( () => (scatterPoints || []).filter((point) => Number.isFinite(point.wind_speed) && Number.isFinite(point.active_power)), @@ -464,12 +834,44 @@ function PowerCurveChart({ Number.isFinite(point.wind_speed) && Number.isFinite(point.design_power)), [designPoints], ); + const curveModel = useMemo(() => buildPowerCurveModel({ + fallbackPoints: points, + scatterPoints: curveScatterPoints, + designPoints: validDesign, + }), [curveScatterPoints, points, validDesign]); + const actualCurve = curveModel.actualCurve; + const visibleActualCurve = useMemo( + () => getVisibleActualCurve(actualCurve), + [actualCurve], + ); + const visibleDesignCurve = useMemo( + () => alignDesignCurveToActualCurve(validDesign, visibleActualCurve), + [validDesign, visibleActualCurve], + ); + const activeChartOptions = useMemo( + () => ({ ...DEFAULT_CHART_OPTIONS, ...chartOptions }), + [chartOptions], + ); + 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, + }); useEffect(() => { - if (!chartRef.current || !validPoints.length) return undefined; + if (!chartRef.current || (!visibleActualCurve.length && !visibleDesignCurve.length)) return undefined; - const sortedCurve = [...validPoints].sort((left, right) => left.wind_speed - right.wind_speed); - const sortedDesign = [...validDesign].sort((left, right) => left.wind_speed - right.wind_speed); + const sortedCurve = [...visibleActualCurve].sort((left, right) => left.wind_speed - right.wind_speed); + const sortedDesign = [...visibleDesignCurve].sort((left, right) => left.wind_speed - right.wind_speed); const actualByWindSpeed = new Map( sortedCurve.map((point) => [point.wind_speed.toFixed(6), point.average_power]), ); @@ -487,7 +889,7 @@ function PowerCurveChart({ ? validFiltered.reduce((max, point) => Math.max(max, point.active_power), 0) : 0; const maxCurvePower = sortedCurve.reduce( - (max, point) => Math.max(max, point.average_power), + (max, point) => Math.max(max, Number.isFinite(point.average_power) ? point.average_power : 0), 0, ); const maxDesignPower = sortedDesign.reduce( @@ -495,45 +897,83 @@ function PowerCurveChart({ 0, ); const maxPower = Math.max(maxCurvePower, maxDesignPower, maxScatterPower, maxFilteredPower, 1); + const maxWindSpeed = Math.max( + ...curveX, + ...validScatter.map((point) => point.wind_speed), + ...(showFiltered ? validFiltered.map((point) => point.wind_speed) : []), + 1, + ); const width = Math.max(chartRef.current.clientWidth || 760, 320); chartRef.current.innerHTML = ''; const chart = new uPlot({ width, height: CHART_HEIGHT, + title: activeChartOptions.title || DEFAULT_CHART_OPTIONS.title, cursor: { drag: { x: !editMode, y: !editMode }, }, + legend: { + show: true, + live: true, + isolate: false, + markers: { + stroke: (u, seriesIndex) => { + if (seriesIndex === 1) return activeChartOptions.actual_color; + if (seriesIndex === 2) return activeChartOptions.design_color; + return null; + }, + dash: (u, seriesIndex) => { + const style = seriesIndex === 1 + ? activeChartOptions.actual_line_style + : activeChartOptions.design_line_style; + return style === 'dashed' ? 'dashed' : style === 'dotted' ? 'dotted' : 'solid'; + }, + }, + }, scales: { - x: { time: false }, - y: { range: [0, maxPower * 1.08] }, + x: { + time: false, + range: () => [0, getAxisMaximum(maxWindSpeed, activeChartOptions.x_tick_interval)], + }, + y: { + range: () => [0, getAxisMaximum(maxPower, activeChartOptions.y_tick_interval)], + }, }, axes: [ { - label: '风速 (m/s)', - stroke: '#9ca3af', - grid: { stroke: '#2a2a3a', width: 1 }, + 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`, + 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 }, }, { - label: '功率 (kW)', - stroke: '#9ca3af', - grid: { stroke: '#2a2a3a', width: 1 }, + 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`, + 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 }, }, ], series: [ {}, { label: '实际功率', - stroke: '#ef4444', - width: 2.5, + stroke: activeChartOptions.actual_color, + width: 0.01, paths: uPlot.paths.spline(), - spanGaps: true, + spanGaps: false, points: { show: false }, }, { label: '设计功率', - stroke: '#22c55e', - width: 2.2, + stroke: activeChartOptions.design_color, + width: 0.01, paths: uPlot.paths.spline(), spanGaps: true, points: { show: false }, @@ -544,40 +984,86 @@ function PowerCurveChart({ (u) => { const { ctx } = u; ctx.save(); - ctx.fillStyle = 'rgba(251, 191, 36, 0.28)'; - for (const point of validScatter) { - const x = u.valToPos(point.wind_speed, 'x', true); - const y = u.valToPos(point.active_power, 'y', true); - if (Number.isFinite(x) && Number.isFinite(y)) { - ctx.beginPath(); - ctx.arc(x, y, 2, 0, Math.PI * 2); - ctx.fill(); + ctx.beginPath(); + ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + ctx.clip(); + if (showScatterOnChart) { + ctx.fillStyle = hexToRgba(activeChartOptions.scatter_color, activeChartOptions.scatter_opacity); + for (const point of validScatter) { + const x = u.valToPos(point.wind_speed, 'x', true); + const y = u.valToPos(point.active_power, 'y', true); + if (Number.isFinite(x) && Number.isFinite(y)) { + ctx.beginPath(); + ctx.arc(x, y, Math.max(1, Number(activeChartOptions.scatter_size) || 2), 0, Math.PI * 2); + ctx.fill(); + } } } if (showFiltered) { - ctx.fillStyle = 'rgba(248, 113, 113, 0.42)'; + ctx.fillStyle = 'rgba(148, 163, 184, 0.6)'; for (const point of validFiltered) { const x = u.valToPos(point.wind_speed, 'x', true); const y = u.valToPos(point.active_power, 'y', true); if (Number.isFinite(x) && Number.isFinite(y)) { ctx.beginPath(); - ctx.arc(x, y, 2, 0, Math.PI * 2); + ctx.arc(x, y, Math.max(1, Number(activeChartOptions.scatter_size) || 2), 0, Math.PI * 2); ctx.fill(); } } } + if (u.series[2].show) { + drawSmoothLine( + ctx, + sortedDesign, + (value) => u.valToPos(value, 'x', true), + (value) => u.valToPos(value, 'y', true), + getCurveStyle(activeChartOptions, 'design'), + ); + } + if (u.series[1].show) { + drawSmoothLine( + ctx, + sortedCurve, + (value) => u.valToPos(value, 'x', true), + (value) => u.valToPos(value, 'y', true), + getCurveStyle(activeChartOptions, 'actual'), + ); + } ctx.restore(); + ctx.save(); + ctx.strokeStyle = '#d8e0ea'; + ctx.lineWidth = 1; + ctx.strokeRect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); + ctx.restore(); + }, + ], + setSeries: [ + (u, seriesIndex) => { + if (seriesIndex === 1 || seriesIndex === 2) { + setSeriesVisibility({ actual: u.series[1].show, design: u.series[2].show }); + } }, ], }, }, [curveX, actualY, designY], chartRef.current); + 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.padding = '8px 0 10px'; + } + plotRef.current = chart; + setSeriesVisibility({ actual: true, design: true }); const hideBrush = () => { setBrush((prev) => ({ ...prev, visible: false, dragging: false })); }; if (!editMode) { - return () => chart.destroy(); + return () => { + if (plotRef.current === chart) plotRef.current = null; + chart.destroy(); + }; } let dragging = false; @@ -650,18 +1136,20 @@ function PowerCurveChart({ chart.over.removeEventListener('pointermove', handlePointerMove); chart.over.removeEventListener('pointerleave', hideBrush); window.removeEventListener('pointerup', handlePointerUp); + if (plotRef.current === chart) plotRef.current = null; chart.destroy(); }; }, [ - validPoints, + visibleActualCurve, + visibleDesignCurve, validScatter, validFiltered, - validDesign, showFiltered, editMode, editTool, onRestorePoints, onErasePoints, + activeChartOptions, ]); useEffect(() => { @@ -670,7 +1158,7 @@ function PowerCurveChart({ } }, [editMode]); - if (!validPoints.length) { + if (!actualCurve.length && !validDesign.length) { return
暂无可绘制的曲线数据
; } @@ -681,11 +1169,23 @@ function PowerCurveChart({ }`} ref={frameRef} > -
- 散点数据 - 实际功率 - {validDesign.length > 0 && 设计功率} -
+ {activeChartOptions.show_legend && ( +
+ {showScatterOnChart && ( + 散点数据 + )} + {seriesVisibility.actual && 实际功率} + {seriesVisibility.design && visibleDesignCurve.length > 0 && 设计功率} + {showFiltered && 滤除点} +
+ )}
{editMode && brush.visible && (
loadChartState(), []); const [files, setFiles] = useState([]); const [mapping, setMapping] = useState({}); const [reading, setReading] = useState(false); const [submitting, setSubmitting] = useState(false); const [progress, setProgress] = useState(''); const [error, setError] = useState(''); - const [result, setResult] = useState(null); - const [selectedFan, setSelectedFan] = useState(''); - const [showFiltered, setShowFiltered] = useState(false); + const [result, setResult] = useState(() => savedSessionState.result || null); + const [selectedFan, setSelectedFan] = useState(() => savedSessionState.selected_fan || ''); + const [showFiltered, setShowFiltered] = useState(() => ( + typeof savedSessionState.chart_options?.show_filtered === 'boolean' + ? savedSessionState.chart_options.show_filtered + : Boolean(savedSessionState.show_filtered) + )); const [isEditMode, setIsEditMode] = useState(false); const [editTool, setEditTool] = useState(EDIT_TOOL_RESTORE); - const [restoredPointIds, setRestoredPointIds] = useState({}); - const [erasedPointIds, setErasedPointIds] = useState({}); + const [restoredPointIds, setRestoredPointIds] = useState(() => savedSessionState.restored_point_ids || {}); + const [erasedPointIds, setErasedPointIds] = useState(() => savedSessionState.erased_point_ids || {}); + const [confirmedCurveScatterByFan, setConfirmedCurveScatterByFan] = useState( + () => savedSessionState.confirmed_curve_scatter_by_fan || {}, + ); const [readingDesign, setReadingDesign] = useState(false); - const [designRows, setDesignRows] = useState(() => createDesignRows()); + const [designRows, setDesignRows] = useState(() => createDesignRows(savedSessionState.design_rows)); const [designMeta, setDesignMeta] = useState(null); + const [chartOptions, setChartOptions] = useState(() => ({ + ...DEFAULT_CHART_OPTIONS, + ...(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' + ? DEFAULT_CHART_OPTIONS.actual_color + : (savedSessionState.chart_options?.actual_color || DEFAULT_CHART_OPTIONS.actual_color), + // Apply the new red default only to sessions carrying the old built-in blue. + design_color: savedSessionState.chart_options?.design_color === '#2563eb' + ? DEFAULT_CHART_OPTIONS.design_color + : (savedSessionState.chart_options?.design_color || DEFAULT_CHART_OPTIONS.design_color), + })); + const [chartSettingsOpen, setChartSettingsOpen] = useState(false); + const [savingChartOptions, setSavingChartOptions] = useState(false); + const [chartSettingsMessage, setChartSettingsMessage] = useState(''); + const chartExportRef = useRef(() => false); const [schemes, setSchemes] = useState(DEFAULT_SCHEMES); const [selectedSchemeId, setSelectedSchemeId] = useState(DEFAULT_SCHEME_ID); const [schemeDescription, setSchemeDescription] = useState(DEFAULT_SCHEMES[0].description); @@ -789,10 +1313,73 @@ export default function HomePage() { () => [...effectiveBaseScatter, ...restoredFilteredPoints], [effectiveBaseScatter, restoredFilteredPoints], ); + const confirmedCurveScatter = confirmedCurveScatterByFan[selectedFan] || effectiveScatter; const selectedEstimatedParams = selectedFan && result?.estimated_params ? result.estimated_params[selectedFan] : null; + useEffect(() => { + try { + sessionStorage.setItem(CHART_STATE_KEY, JSON.stringify({ + chart_options: chartOptions, + design_rows: designRows, + result, + selected_fan: selectedFan, + show_filtered: showFiltered, + restored_point_ids: restoredPointIds, + erased_point_ids: erasedPointIds, + confirmed_curve_scatter_by_fan: confirmedCurveScatterByFan, + })); + } catch { + try { + sessionStorage.setItem(CHART_STATE_KEY, JSON.stringify({ + chart_options: chartOptions, + design_rows: designRows, + })); + } catch { + // 浏览器禁用会话存储时不影响曲线计算。 + } + } + }, [chartOptions, confirmedCurveScatterByFan, designRows, erasedPointIds, restoredPointIds, result, selectedFan, showFiltered]); + + useEffect(() => { + let canceled = false; + + async function loadSavedChartOptions() { + try { + const data = await getWindChartOptions(); + if (!canceled && data.configured && data.options) { + setChartOptions({ ...DEFAULT_CHART_OPTIONS, ...data.options }); + setShowFiltered(Boolean(data.options.show_filtered)); + } + } catch (err) { + if (!canceled) { + setChartSettingsMessage(`未加载服务端图表参数:${err.message || '请求失败'}`); + } + } + } + + loadSavedChartOptions(); + return () => { + canceled = true; + }; + }, []); + + useEffect(() => { + if (!result?.fans?.length) return; + if (!result.fans.includes(selectedFan)) { + setSelectedFan(result.fans[0]); + } + }, [result, selectedFan]); + + useEffect(() => { + if (!selectedFan || isEditMode || confirmedCurveScatterByFan[selectedFan]) return; + setConfirmedCurveScatterByFan((prev) => ({ + ...prev, + [selectedFan]: effectiveScatter, + })); + }, [confirmedCurveScatterByFan, effectiveScatter, isEditMode, selectedFan]); + const handleRestorePoints = useCallback((pointsToRestore) => { if (!selectedFan || !pointsToRestore.length) return; setRestoredPointIds((prev) => { @@ -861,16 +1448,52 @@ export default function HomePage() { }, [selectedFan]); const handleToggleEditMode = useCallback(() => { - setIsEditMode((prev) => { + if (isEditMode && selectedFan) { + setConfirmedCurveScatterByFan((current) => ({ + ...current, + [selectedFan]: effectiveScatter, + })); + } else { + setEditTool(showFiltered ? EDIT_TOOL_RESTORE : EDIT_TOOL_ERASE); + } + setIsEditMode((prev) => !prev); + }, [effectiveScatter, isEditMode, selectedFan, showFiltered]); + + const handleToggleFiltered = useCallback(() => { + setShowFiltered((prev) => { const next = !prev; - if (next) { - setShowFiltered(true); - setEditTool(EDIT_TOOL_RESTORE); - } + setChartOptions((options) => ({ ...options, show_filtered: next })); + if (!next && isEditMode) setEditTool(EDIT_TOOL_ERASE); return next; }); + }, [isEditMode]); + + const handleSelectRestoreTool = useCallback(() => { + setShowFiltered(true); + setChartOptions((options) => ({ ...options, show_filtered: true })); + setEditTool(EDIT_TOOL_RESTORE); }, []); + const handleChartOptionChange = useCallback((key, value) => { + setChartOptions((prev) => ({ ...prev, [key]: value })); + setChartSettingsMessage(''); + }, []); + + const handleSaveChartOptions = useCallback(async () => { + setSavingChartOptions(true); + setChartSettingsMessage(''); + try { + const data = await saveWindChartOptions({ ...chartOptions, show_filtered: showFiltered }); + setChartOptions({ ...DEFAULT_CHART_OPTIONS, ...data.options }); + setShowFiltered(Boolean(data.options.show_filtered)); + setChartSettingsMessage('图表参数已保存,将作为全局默认配置。'); + } catch (err) { + setChartSettingsMessage(`保存失败:${err.message || '请求失败'}`); + } finally { + setSavingChartOptions(false); + } + }, [chartOptions, showFiltered]); + const totalRows = useMemo( () => files.reduce((sum, file) => sum + file.row_count, 0), [files], @@ -1000,6 +1623,7 @@ export default function HomePage() { setEditTool(EDIT_TOOL_RESTORE); setRestoredPointIds({}); setErasedPointIds({}); + setConfirmedCurveScatterByFan({}); try { const parsedFiles = []; for (const file of selectedFiles) { @@ -1090,6 +1714,7 @@ export default function HomePage() { setEditTool(EDIT_TOOL_RESTORE); setRestoredPointIds({}); setErasedPointIds({}); + setConfirmedCurveScatterByFan({}); setProgress('正在准备标准化数据'); try { @@ -1456,7 +2081,9 @@ export default function HomePage() {
-

功率曲线

+

+ {chartOptions.title || DEFAULT_CHART_OPTIONS.title} +

曲线 {selectedPoints.length} 点 · 散点 {effectiveScatter.length.toLocaleString()} 点 @@ -1465,11 +2092,18 @@ export default function HomePage() { + + handleChartOptionChange('title', event.target.value)} + /> + + + + + + + + + + + + + + +
+ +
+
+ + 实际功率 +
+
+ + + + + +
+
+
+
+ + 设计功率 +
+
+ + + + + +
+
+
+ {chartSettingsMessage && {chartSettingsMessage}} + +
+
+ )} } + */ +export function getWindChartOptions() { + return request('/wind/chart-options'); +} + +/** + * 保存服务端全局图表样式参数。 + * @param {object} options + * @returns {Promise<{configured: boolean, options: object}>} + */ +export function saveWindChartOptions(options) { + return request('/wind/chart-options', { + method: 'POST', + body: JSON.stringify(options), + }); +} + /** * 创建风功率计算任务 * @param {{files: Array<{file_name: string, row_count: number}>, mapping: object}} payload diff --git a/frontend/web_app/src/utils/powerCurveModel.js b/frontend/web_app/src/utils/powerCurveModel.js new file mode 100644 index 0000000..b0f7e57 --- /dev/null +++ b/frontend/web_app/src/utils/powerCurveModel.js @@ -0,0 +1,159 @@ +const EPSILON = 1e-6; + +function validNumber(value) { + return Number.isFinite(value); +} + +export function normalizeDesignCurve(points = []) { + const grouped = new Map(); + for (const point of points) { + const windSpeed = Number(point?.wind_speed); + const designPower = Number(point?.design_power); + if (!validNumber(windSpeed) || windSpeed < 0 || !validNumber(designPower) || designPower < 0) { + continue; + } + const key = windSpeed.toFixed(6); + const item = grouped.get(key) || { wind_speed: windSpeed, total: 0, count: 0 }; + item.total += designPower; + item.count += 1; + grouped.set(key, item); + } + return Array.from(grouped.values()) + .map((item) => ({ wind_speed: item.wind_speed, design_power: item.total / item.count })) + .sort((left, right) => left.wind_speed - right.wind_speed); +} + +export function buildDesignIntervals(designPoints = []) { + const points = normalizeDesignCurve(designPoints); + if (!points.length) return []; + if (points.length === 1) { + return [{ ...points[0], wind_speed_start: points[0].wind_speed - 0.25, wind_speed_end: points[0].wind_speed + 0.25 }]; + } + return points.map((point, index) => { + const previous = points[index - 1]; + const next = points[index + 1]; + const previousGap = previous ? point.wind_speed - previous.wind_speed : next.wind_speed - point.wind_speed; + const nextGap = next ? next.wind_speed - point.wind_speed : previousGap; + return { + ...point, + wind_speed_start: previous ? (previous.wind_speed + point.wind_speed) / 2 : point.wind_speed - previousGap / 2, + wind_speed_end: next ? (point.wind_speed + next.wind_speed) / 2 : point.wind_speed + nextGap / 2, + }; + }); +} + +export function buildActualCurveFromDesign(scatterPoints = [], designPoints = []) { + const intervals = buildDesignIntervals(designPoints); + if (!intervals.length) return []; + return intervals.map((interval, index) => { + const powers = scatterPoints + .filter((point) => { + const windSpeed = Number(point?.wind_speed); + const power = Number(point?.active_power); + if (!validNumber(windSpeed) || !validNumber(power)) return false; + const isLast = index === intervals.length - 1; + return windSpeed >= interval.wind_speed_start - EPSILON + && (isLast ? windSpeed <= interval.wind_speed_end + EPSILON : windSpeed < interval.wind_speed_end - EPSILON); + }) + .map((point) => Number(point.active_power)); + return { + wind_speed: interval.wind_speed, + wind_speed_start: interval.wind_speed_start, + wind_speed_end: interval.wind_speed_end, + sample_count: powers.length, + average_power: powers.length ? powers.reduce((sum, value) => sum + value, 0) / powers.length : null, + }; + }); +} + +export function buildActualCurveFromFixedBins(scatterPoints = [], binSize = 0.5) { + const size = Number(binSize); + if (!validNumber(size) || size <= 0) return []; + + const bins = new Map(); + for (const point of scatterPoints) { + const windSpeed = Number(point?.wind_speed); + const power = Number(point?.active_power); + if (!validNumber(windSpeed) || windSpeed < 0 || !validNumber(power)) continue; + + // Keep the existing backend convention: (center - half, center + half]. + const center = Math.floor((windSpeed + size / 2 - EPSILON) / size) * size; + const key = center.toFixed(6); + const values = bins.get(key) || { wind_speed: center, powers: [] }; + values.powers.push(power); + bins.set(key, values); + } + + return Array.from(bins.values()) + .map((bin) => ({ + wind_speed: bin.wind_speed, + wind_speed_start: bin.wind_speed - size / 2, + wind_speed_end: bin.wind_speed + size / 2, + sample_count: bin.powers.length, + average_power: bin.powers.reduce((sum, value) => sum + value, 0) / bin.powers.length, + })) + .sort((left, right) => left.wind_speed - right.wind_speed); +} + +function interpolatePowerAtWindSpeed(designCurve, windSpeed) { + if (!designCurve.length || windSpeed < designCurve[0].wind_speed - EPSILON + || windSpeed > designCurve[designCurve.length - 1].wind_speed + EPSILON) { + return null; + } + for (let index = 0; index < designCurve.length; index += 1) { + const point = designCurve[index]; + if (Math.abs(point.wind_speed - windSpeed) <= EPSILON) return point.design_power; + if (point.wind_speed > windSpeed && index > 0) { + const previous = designCurve[index - 1]; + const ratio = (windSpeed - previous.wind_speed) / (point.wind_speed - previous.wind_speed); + return previous.design_power + (point.design_power - previous.design_power) * ratio; + } + } + return designCurve[designCurve.length - 1].design_power; +} + +export function alignDesignCurveToActualCurve(designPoints = [], actualCurve = []) { + const designCurve = normalizeDesignCurve(designPoints); + const validActual = actualCurve + .filter((point) => validNumber(point?.wind_speed) + && validNumber(point?.average_power)) + .sort((left, right) => left.wind_speed - right.wind_speed); + if (!designCurve.length || !validActual.length) return designCurve; + + const actualStart = validActual[0].wind_speed; + const actualEnd = validActual[validActual.length - 1].wind_speed; + const start = Math.max(actualStart, designCurve[0].wind_speed); + const end = Math.min(actualEnd, designCurve[designCurve.length - 1].wind_speed); + if (start > end + EPSILON) return []; + + const aligned = designCurve.filter((point) => point.wind_speed >= start - EPSILON + && point.wind_speed <= end + EPSILON); + const startPower = interpolatePowerAtWindSpeed(designCurve, start); + const endPower = interpolatePowerAtWindSpeed(designCurve, end); + if (validNumber(startPower) && !aligned.some((point) => Math.abs(point.wind_speed - start) <= EPSILON)) { + aligned.unshift({ wind_speed: start, design_power: startPower }); + } + if (validNumber(endPower) && !aligned.some((point) => Math.abs(point.wind_speed - end) <= EPSILON)) { + aligned.push({ wind_speed: end, design_power: endPower }); + } + return aligned.sort((left, right) => left.wind_speed - right.wind_speed); +} + +export function buildPowerCurveModel({ fallbackPoints = [], scatterPoints = [], designPoints = [] } = {}) { + const designCurve = normalizeDesignCurve(designPoints); + const actualCurve = designCurve.length + ? buildActualCurveFromDesign(scatterPoints, designCurve) + : scatterPoints.length + ? buildActualCurveFromFixedBins(scatterPoints) + : (fallbackPoints || []) + .filter((point) => Number(point?.sample_count) > 0 && validNumber(Number(point?.average_power))) + .map((point) => ({ + wind_speed: Number(point.wind_speed), + wind_speed_start: Number(point.wind_speed_start), + wind_speed_end: Number(point.wind_speed_end), + sample_count: Number(point.sample_count), + average_power: Number(point.average_power), + })) + .sort((left, right) => left.wind_speed - right.wind_speed); + return { actualCurve, designCurve, hasDesignCurve: designCurve.length > 0 }; +}