功能: 完善功率曲线图表编辑与导出

- 支持图表样式服务端保存与滤除点显示配置\n- 优化实际和设计功率曲线绘制、图例与 PNG 导出\n- 调整浅色图表主题和编辑交互
This commit is contained in:
cloud
2026-07-20 14:49:28 +08:00
parent 1e19485845
commit e125e439f8
7 changed files with 1476 additions and 66 deletions
+202 -4
View File
@@ -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,
+107
View File
@@ -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; }
}
+5 -5
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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
@@ -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 };
}