功能: 完善功率曲线图表编辑与导出
- 支持图表样式服务端保存与滤除点显示配置\n- 优化实际和设计功率曲线绘制、图例与 PNG 导出\n- 调整浅色图表主题和编辑交互
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user