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

- 支持图表样式服务端保存与滤除点显示配置\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
+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 };
}