功能: 完善功率曲线图表编辑与导出
- 支持图表样式服务端保存与滤除点显示配置\n- 优化实际和设计功率曲线绘制、图例与 PNG 导出\n- 调整浅色图表主题和编辑交互
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
#include "WindPowerController.h"
|
#include "WindPowerController.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
@@ -140,6 +141,10 @@ fs::path SchemeConfigPath() {
|
|||||||
return fs::path("data") / "wind_schemes.json";
|
return fs::path("data") / "wind_schemes.json";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fs::path ChartOptionsConfigPath() {
|
||||||
|
return fs::path("data") / "wind_chart_options.json";
|
||||||
|
}
|
||||||
|
|
||||||
std::string GenerateJobId() {
|
std::string GenerateJobId() {
|
||||||
const auto now = std::chrono::system_clock::now().time_since_epoch().count();
|
const auto now = std::chrono::system_clock::now().time_since_epoch().count();
|
||||||
std::random_device rd;
|
std::random_device rd;
|
||||||
@@ -192,6 +197,142 @@ std::optional<double> GetNumberField(const json& body, const std::string& field)
|
|||||||
return body[field].get<double>();
|
return body[field].get<double>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool IsHexColor(const json& value) {
|
||||||
|
if (!value.is_string()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto color = value.get<std::string>();
|
||||||
|
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<std::string>& allowed) {
|
||||||
|
return value.is_string() && std::find(allowed.begin(), allowed.end(), value.get<std::string>()) != allowed.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ValidateChartOptions(const json& options, std::string& error) {
|
||||||
|
if (!options.is_object()) {
|
||||||
|
error = "图表参数必须是对象";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::array<std::string, 3> 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<std::string>().size() > 200) {
|
||||||
|
error = "图表文字参数无效";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::array<std::string, 4> 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<std::string, 5> 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<double>()) &&
|
||||||
|
options[field].get<double>() >= minimum && options[field].get<double>() <= 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<std::string>());
|
||||||
|
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<std::string> line_styles = {"solid", "dashed", "dotted"};
|
||||||
|
const std::vector<std::string> 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<json> 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<json>{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<bool>(out);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::string NormalizeSchemeId(const std::string& scheme_id) {
|
std::string NormalizeSchemeId(const std::string& scheme_id) {
|
||||||
return scheme_id == kSchemeTwoId ? kSchemeTwoId : kDefaultSchemeId;
|
return scheme_id == kSchemeTwoId ? kSchemeTwoId : kDefaultSchemeId;
|
||||||
}
|
}
|
||||||
@@ -276,13 +417,34 @@ std::vector<SchemeInfo> LoadSchemes() {
|
|||||||
return schemes;
|
return schemes;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool SaveSchemes(const std::vector<SchemeInfo>& 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<SchemeInfo>& schemes,
|
||||||
|
const std::string& default_scheme_id) {
|
||||||
try {
|
try {
|
||||||
const auto config_path = SchemeConfigPath();
|
const auto config_path = SchemeConfigPath();
|
||||||
fs::create_directories(config_path.parent_path());
|
fs::create_directories(config_path.parent_path());
|
||||||
|
|
||||||
json data;
|
json data;
|
||||||
data["default_scheme_id"] = kDefaultSchemeId;
|
data["default_scheme_id"] = NormalizeSchemeId(default_scheme_id);
|
||||||
data["schemes"] = json::array();
|
data["schemes"] = json::array();
|
||||||
for (const auto& scheme : schemes) {
|
for (const auto& scheme : schemes) {
|
||||||
data["schemes"].push_back(SchemeToJson(scheme));
|
data["schemes"].push_back(SchemeToJson(scheme));
|
||||||
@@ -955,7 +1117,7 @@ void WindPowerController::GetSchemes(
|
|||||||
const HttpRequestPtr&,
|
const HttpRequestPtr&,
|
||||||
std::function<void(const HttpResponsePtr&)>&& callback) {
|
std::function<void(const HttpResponsePtr&)>&& callback) {
|
||||||
json data;
|
json data;
|
||||||
data["default_scheme_id"] = kDefaultSchemeId;
|
data["default_scheme_id"] = LoadDefaultSchemeId();
|
||||||
data["schemes"] = json::array();
|
data["schemes"] = json::array();
|
||||||
for (const auto& scheme : LoadSchemes()) {
|
for (const auto& scheme : LoadSchemes()) {
|
||||||
data["schemes"].push_back(SchemeToJson(scheme));
|
data["schemes"].push_back(SchemeToJson(scheme));
|
||||||
@@ -1034,17 +1196,53 @@ void WindPowerController::SaveSchemeDescription(
|
|||||||
SendError(callback, kErrorInvalidRequest, "方案不存在");
|
SendError(callback, kErrorInvalidRequest, "方案不存在");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!SaveSchemes(schemes)) {
|
if (!SaveSchemes(schemes, normalized_id)) {
|
||||||
SendError(callback, kErrorServer, "保存方案描述失败");
|
SendError(callback, kErrorServer, "保存方案描述失败");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto saved_scheme = FindScheme(schemes, normalized_id);
|
const auto saved_scheme = FindScheme(schemes, normalized_id);
|
||||||
json data;
|
json data;
|
||||||
|
data["default_scheme_id"] = normalized_id;
|
||||||
data["scheme"] = SchemeToJson(saved_scheme.value());
|
data["scheme"] = SchemeToJson(saved_scheme.value());
|
||||||
SendSuccess(callback, data);
|
SendSuccess(callback, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void WindPowerController::GetChartOptions(
|
||||||
|
const HttpRequestPtr&,
|
||||||
|
std::function<void(const HttpResponsePtr&)>&& 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<void(const HttpResponsePtr&)>&& 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(
|
void WindPowerController::StartJob(
|
||||||
const HttpRequestPtr& req,
|
const HttpRequestPtr& req,
|
||||||
std::function<void(const HttpResponsePtr&)>&& callback) {
|
std::function<void(const HttpResponsePtr&)>&& callback) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public:
|
|||||||
ADD_METHOD_TO(WindPowerController::SaveSchemeDescription,
|
ADD_METHOD_TO(WindPowerController::SaveSchemeDescription,
|
||||||
"/api/wind/schemes/{1}/description",
|
"/api/wind/schemes/{1}/description",
|
||||||
Post);
|
Post);
|
||||||
|
ADD_METHOD_TO(WindPowerController::GetChartOptions, "/api/wind/chart-options", Get);
|
||||||
|
ADD_METHOD_TO(WindPowerController::SaveChartOptions, "/api/wind/chart-options", Post);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
void GetSchemes(const HttpRequestPtr& req,
|
void GetSchemes(const HttpRequestPtr& req,
|
||||||
@@ -26,6 +28,10 @@ public:
|
|||||||
void SaveSchemeDescription(const HttpRequestPtr& req,
|
void SaveSchemeDescription(const HttpRequestPtr& req,
|
||||||
std::function<void(const HttpResponsePtr&)>&& callback,
|
std::function<void(const HttpResponsePtr&)>&& callback,
|
||||||
const std::string& scheme_id);
|
const std::string& scheme_id);
|
||||||
|
void GetChartOptions(const HttpRequestPtr& req,
|
||||||
|
std::function<void(const HttpResponsePtr&)>&& callback);
|
||||||
|
void SaveChartOptions(const HttpRequestPtr& req,
|
||||||
|
std::function<void(const HttpResponsePtr&)>&& callback);
|
||||||
void StartJob(const HttpRequestPtr& req,
|
void StartJob(const HttpRequestPtr& req,
|
||||||
std::function<void(const HttpResponsePtr&)>&& callback);
|
std::function<void(const HttpResponsePtr&)>&& callback);
|
||||||
void UploadChunk(const HttpRequestPtr& req,
|
void UploadChunk(const HttpRequestPtr& req,
|
||||||
|
|||||||
@@ -776,3 +776,110 @@ td {
|
|||||||
grid-template-columns: 1fr;
|
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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #0d0d14;
|
background: #f4f7fb;
|
||||||
color: #e0e0e0;
|
color: #1e293b;
|
||||||
font-family: 'Segoe UI', 'Inter', -apple-system, sans-serif;
|
font-family: 'Segoe UI', 'Inter', -apple-system, sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -25,12 +25,12 @@ body {
|
|||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #0d0d14;
|
background: #f4f7fb;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #2a2a3a;
|
background: #cbd5e1;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #3a3a4a;
|
background: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,26 @@ export function saveWindSchemeDescription(schemeId, payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取服务端全局图表样式参数。
|
||||||
|
* @returns {Promise<{configured: boolean, options?: object}>}
|
||||||
|
*/
|
||||||
|
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
|
* @param {{files: Array<{file_name: string, row_count: number}>, mapping: object}} payload
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user