功能: 完善功率曲线图表编辑与导出
- 支持图表样式服务端保存与滤除点显示配置\n- 优化实际和设计功率曲线绘制、图例与 PNG 导出\n- 调整浅色图表主题和编辑交互
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
#include "WindPowerController.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
@@ -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<double> GetNumberField(const json& body, const std::string& field)
|
||||
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) {
|
||||
return scheme_id == kSchemeTwoId ? kSchemeTwoId : kDefaultSchemeId;
|
||||
}
|
||||
@@ -276,13 +417,34 @@ std::vector<SchemeInfo> LoadSchemes() {
|
||||
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 {
|
||||
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<void(const HttpResponsePtr&)>&& 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<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(
|
||||
const HttpRequestPtr& req,
|
||||
std::function<void(const HttpResponsePtr&)>&& callback) {
|
||||
|
||||
@@ -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<void(const HttpResponsePtr&)>&& callback,
|
||||
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,
|
||||
std::function<void(const HttpResponsePtr&)>&& callback);
|
||||
void UploadChunk(const HttpRequestPtr& req,
|
||||
|
||||
Reference in New Issue
Block a user