/**
* 风功率曲线 / 风功率散点 — 全场 3D 图表(前端 + 报告 Node 共用)
* 展示坐标:X = 风机机位,Y = 风速,Z = 功率(竖直)
* 原始 panel 数据仍为 xData=风速、yData=功率
*/
import {
classifyDiagnosticSeries,
diagnosticSeriesColor,
} from "./anomalyDiagnosticVisual.mjs";
export const WIND_POWER_3D_TEMPLATE_KEYS = new Set([
"wind_power_curve",
"wind_power_scatter",
]);
export const WIND_POWER_3D_AXIS = {
x: "风机机位",
y: "风速 (m/s)",
z: "有功功率 (kW)",
};
/** 参考图视角:功率轴竖直,风机与风速轴分别向左、右前方展开 */
const REFERENCE_VIEW_CONTROL = {
alpha: 25,
beta: 45,
center: [0, -4, 0],
};
/** Y 轴(机组)最多展示的名称数(折线/散点数据仍全部绘制) */
export const FARM_WIND_POWER_3D_TURBINE_LABEL_COUNT = 8;
/** 机组在 Y 轴上的间距(数值越大,各风机切片越分开) */
const TURBINE_Y_GAP = 10;
/** 全场 3D 渐变色:提高饱和度与明度,避免深色底上看不清 */
function farmTurbineGradientColor(turbineIndex, maxIndex, isAnomaly = false) {
const t =
maxIndex <= 1 ? 0 : Math.max(0, Math.min(1, (turbineIndex - 1) / (maxIndex - 1)));
const hue = 88 + t * 172;
if (isAnomaly) return `hsla(${hue}, 92%, 64%, 1)`;
return `hsla(${hue}, 88%, 66%, 1)`;
}
function isAnomalyRow(row) {
const text = `${row?.originalLabel || ""} ${row?.label || ""}`;
return /异常/.test(text) && !/正常/.test(text);
}
function resolveFarmSeriesColor(row, index, turbineMeta, pickColor, farmMode) {
if (!farmMode) return pickColor(row.label, index, row, farmMode);
const role = classifyDiagnosticSeries(row);
if (role === "reference" || role === "warning" || role === "mean") {
return diagnosticSeriesColor(row, index);
}
if (typeof pickColor === "function") {
return pickColor(row.label, index, row, farmMode);
}
const ti = resolveTurbineIndex(row, turbineMeta);
return farmTurbineGradientColor(ti, turbineMeta.maxIndex, role === "anomaly");
}
function resolveFarmSeriesVisual(row, templateKey, turbineMax, reportMode) {
const isRef = isReferenceLineRow(row);
const isScatter = !isRef && isMarkerRow(row, templateKey);
const isAnomaly = isAnomalyRow(row);
const dense = turbineMax > 10;
if (isRef) {
return {
lineWidth: 2.2,
lineOpacity: 1,
lineType: "dashed",
itemOpacity: 1,
symbolSize: 3,
};
}
if (isScatter) {
return {
lineWidth: 1,
lineOpacity: 1,
lineType: "solid",
itemOpacity: dense ? (isAnomaly ? 0.95 : 0.82) : 0.95,
symbolSize: dense ? (isAnomaly ? 6.5 : 5.5) : 7,
};
}
return {
lineWidth: dense ? (reportMode ? 2 : 1.9) : 2.4,
lineOpacity: isAnomaly ? 1 : dense ? 0.92 : 0.98,
lineType: "solid",
itemOpacity: isAnomaly ? 1 : 0.92,
symbolSize: 3,
};
}
function buildAxisSplitStyle(splitColor, show = true) {
return {
show,
lineStyle: { color: splitColor, type: "solid", opacity: show ? 0.35 : 0 },
};
}
export function isWindPower3DTemplate(templateKey) {
return WIND_POWER_3D_TEMPLATE_KEYS.has(String(templateKey || ""));
}
/** 仅全场(farmMode)风功率曲线/散点使用 3D,单机保持 2D */
export function shouldUseWindPower3D(templateKey, farmMode = false) {
return Boolean(farmMode) && isWindPower3DTemplate(templateKey);
}
function getRowArrays(row) {
const xData = Array.isArray(row?.xData) ? row.xData : [];
const yData = Array.isArray(row?.yData) ? row.yData : [];
const zData = Array.isArray(row?.zData) ? row.zData : [];
return { xData, yData, zData };
}
function formatEngineLabel(name) {
if (name == null || name === "") return "";
const text = String(name);
if (/号$/.test(text)) return text;
if (/^\d+$/.test(text)) return `${text}号`;
return text;
}
function resolveTurbineName(row) {
if (row?.engineName) return formatEngineLabel(row.engineName);
const label = String(row?.label || "");
const head = label.split(/\s+/)[0] || "";
if (/号/.test(head) || /^\d+$/.test(head)) return formatEngineLabel(head);
return head;
}
function buildTurbineAxisMeta(rows = []) {
const nameToIndex = new Map();
let maxIndex = 0;
rows.forEach((row) => {
if (isReferenceLineRow(row)) return;
const name = resolveTurbineName(row);
let turbineIndex =
row?.turbineIndex != null && Number.isFinite(Number(row.turbineIndex))
? Number(row.turbineIndex) + 1
: null;
if (turbineIndex == null && name) {
if (!nameToIndex.has(name)) {
maxIndex += 1;
nameToIndex.set(name, maxIndex);
}
turbineIndex = nameToIndex.get(name);
}
if (turbineIndex != null) {
maxIndex = Math.max(maxIndex, turbineIndex);
if (name) nameToIndex.set(name, turbineIndex);
}
});
const indexToName = new Map();
nameToIndex.forEach((index, name) => indexToName.set(index, name));
return { maxIndex, indexToName, nameToIndex };
}
function resolveTurbineIndex(row, meta) {
if (row?.turbineIndex != null && Number.isFinite(Number(row.turbineIndex))) {
return Number(row.turbineIndex) + 1;
}
const name = resolveTurbineName(row);
if (name && meta.nameToIndex.has(name)) return meta.nameToIndex.get(name);
return 1;
}
/** ECharts 3D 坐标:[机组位置, 风速, 功率] */
function toChartPoint(windSpeed, power, turbineIndex) {
return [turbineIndex * TURBINE_Y_GAP, windSpeed, power];
}
function resolveTurbineIndexFromPosition(position) {
return Math.round(Number(position) / TURBINE_Y_GAP);
}
function resolveFarmTriplets(row, meta) {
const { xData, yData, zData } = getRowArrays(row);
const powerArr = zData.length ? zData : yData;
const turbineIndex = resolveTurbineIndex(row, meta);
const len = Math.min(xData.length, powerArr.length);
const triplets = [];
for (let i = 0; i < len; i += 1) {
const windSpeed = Number(xData[i]);
const power = Number(powerArr[i]);
if (!Number.isFinite(windSpeed) || !Number.isFinite(power)) continue;
triplets.push(toChartPoint(windSpeed, power, turbineIndex));
}
return triplets;
}
function isReferenceLineRow(row) {
if (row?.__shared) return true;
const text = `${row?.originalLabel || ""} ${row?.label || ""}`;
return /参考功率|理论功率|合同功率|参考曲线/.test(text);
}
function isMarkerRow(row, templateKey) {
if (isReferenceLineRow(row)) return false;
if (templateKey === "wind_power_curve") return false;
if (templateKey === "wind_power_scatter") return true;
const mode = String(row?.mode || "lines");
return mode.includes("markers") && !mode.includes("lines");
}
function buildReferenceLineTriplets(row) {
const { xData, yData, zData } = getRowArrays(row);
const powerArr = zData.length ? zData : yData;
const len = Math.min(xData.length, powerArr.length);
const triplets = [];
for (let i = 0; i < len; i += 1) {
const windSpeed = Number(xData[i]);
const power = Number(powerArr[i]);
if (!Number.isFinite(windSpeed) || !Number.isFinite(power)) continue;
triplets.push(toChartPoint(windSpeed, power, 0));
}
return triplets;
}
function formatTurbineAxisLabel(index, indexToName) {
if (index === 0) return "参考";
return indexToName.get(index) || `${index}号`;
}
/** 机组较多时 Y 轴仅抽样展示名称,避免标签重叠 */
function buildTurbineAxisLabelConfig(maxIndex, indexToName, hasReference) {
const labeledIndex = new Set();
if (hasReference) labeledIndex.add(0);
if (maxIndex <= FARM_WIND_POWER_3D_TURBINE_LABEL_COUNT) {
for (let index = 1; index <= maxIndex; index += 1) labeledIndex.add(index);
} else {
labeledIndex.add(1);
labeledIndex.add(maxIndex);
const slots = FARM_WIND_POWER_3D_TURBINE_LABEL_COUNT - labeledIndex.size;
const step = Math.max(1, Math.round((maxIndex - 1) / (slots + 1)));
for (let index = 1 + step; index < maxIndex; index += step) {
labeledIndex.add(index);
if (labeledIndex.size >= FARM_WIND_POWER_3D_TURBINE_LABEL_COUNT) break;
}
}
const labelInterval = TURBINE_Y_GAP;
return {
interval: labelInterval,
labeledIndex,
formatter(v) {
const position = Number(v);
if (!Number.isFinite(position)) return "";
const index = resolveTurbineIndexFromPosition(position);
if (index < 0 || index > maxIndex) return "";
if (Math.abs(position - index * TURBINE_Y_GAP) > 0.01) return "";
if (!labeledIndex.has(index)) return "";
return formatTurbineAxisLabel(index, indexToName);
},
};
}
function computePlotExtents(series = []) {
let windMin = Infinity;
let windMax = -Infinity;
let powerMax = 0;
series.forEach((item) => {
(item.data || []).forEach((point) => {
if (!Array.isArray(point) || point.length < 3) return;
const [, windSpeed, power] = point;
if (Number.isFinite(windSpeed)) {
windMin = Math.min(windMin, windSpeed);
windMax = Math.max(windMax, windSpeed);
}
if (Number.isFinite(power)) {
powerMax = Math.max(powerMax, power);
}
});
});
if (!Number.isFinite(windMin) || !Number.isFinite(windMax)) {
windMin = 0;
windMax = 25;
}
if (powerMax <= 0) powerMax = 2000;
const windPad = Math.max(0.5, (windMax - windMin) * 0.04);
const powerPad = Math.max(50, powerMax * 0.06);
return {
windMin: Math.max(0, windMin - windPad),
windMax: windMax + windPad,
powerMax: powerMax + powerPad,
};
}
function resolveWindSpeedAxisName(panel) {
const name = String(panel?.xaxis || "").trim();
if (/风速/.test(name)) return name;
return WIND_POWER_3D_AXIS.y;
}
function resolvePowerAxisName(panel) {
const name = String(panel?.yaxis || "").trim();
if (/功率/.test(name)) return name;
return WIND_POWER_3D_AXIS.z;
}
function buildGrid3DLayout({
hasLegend,
hasSubtitle,
reportMode,
}) {
const titleBlock = hasSubtitle ? 50 : 40;
return {
boxWidth: reportMode ? 96 : 108,
boxHeight: reportMode ? 88 : 96,
boxDepth: reportMode ? 158 : 176,
top: titleBlock,
left: hasLegend ? 18 : "5%",
right: hasLegend ? 112 : "5%",
bottom: 72,
viewControl: {
projection: "orthographic",
autoRotate: false,
distance: reportMode ? 225 : 218,
orthographicSize: reportMode ? 215 : 150,
alpha: REFERENCE_VIEW_CONTROL.alpha,
beta: REFERENCE_VIEW_CONTROL.beta,
minAlpha: 8,
maxAlpha: 62,
rotateSensitivity: 1,
zoomSensitivity: 1,
panSensitivity: 0.8,
center: [...REFERENCE_VIEW_CONTROL.center],
},
};
}
function defaultPickColor(label, index) {
const palette = ["#1E7CF8", "#7C5CFF", "#16A34A", "#F59E0B", "#EF4444"];
const text = String(label || "");
if (/参考|合同|理论/.test(text)) return "#64748B";
if (/异常/.test(text) && !/正常/.test(text)) return "#EF4444";
if (/正常/.test(text)) return "#1E7CF8";
return palette[index % palette.length];
}
/**
* @param {object} params
* @param {object} params.panel - { title, xaxis, yaxis, data }
* @param {string} params.templateKey
* @param {string} [params.title]
* @param {boolean} [params.farmMode]
* @param {function} [params.pickColor]
* @param {object} [params.theme]
* @param {boolean} [params.reportMode] - 报告白底样式
*/
export function buildWindPower3DChartOption({
panel,
templateKey,
title = "",
farmMode = false,
pickColor = defaultPickColor,
theme = {},
reportMode = false,
}) {
const rows = panel?.data || [];
const turbineMeta = buildTurbineAxisMeta(rows);
const turbineMax = Math.max(turbineMeta.maxIndex, 1);
const turbineSeries = [];
const referenceSeries = [];
const legendNames = [];
rows.forEach((row, index) => {
const color = resolveFarmSeriesColor(
row,
index,
turbineMeta,
pickColor,
farmMode,
);
const visual = resolveFarmSeriesVisual(row, templateKey, turbineMax, reportMode);
if (isReferenceLineRow(row)) {
const data = buildReferenceLineTriplets(row);
if (!data.length) return;
referenceSeries.push({
name: row.label,
type: "line3D",
data,
lineStyle: {
width: visual.lineWidth,
color,
type: visual.lineType,
opacity: visual.lineOpacity,
},
itemStyle: { color, opacity: visual.itemOpacity },
});
legendNames.push(row.label);
return;
}
const triplets = resolveFarmTriplets(row, turbineMeta);
if (!triplets.length) return;
const useScatter = isMarkerRow(row, templateKey);
const plotData = useScatter
? triplets
: [...triplets].sort((a, b) => a[1] - b[1]);
const item = {
name: row.label,
type: useScatter ? "scatter3D" : "line3D",
data: plotData,
shading: "color",
symbolSize: visual.symbolSize,
lineStyle: useScatter
? undefined
: {
width: visual.lineWidth,
color,
type: visual.lineType,
opacity: visual.lineOpacity,
},
itemStyle: { color, opacity: visual.itemOpacity },
};
turbineSeries.push(item);
legendNames.push(row.label);
});
const series = [...turbineSeries, ...referenceSeries];
const plotExtents = computePlotExtents(series);
const axisColor = theme.axis || (reportMode ? "#64748b" : "#8da3c7");
const legendColor = theme.legend || (reportMode ? "#334155" : "#cde6ff");
const titleColor = theme.title || (reportMode ? "#0f172a" : "#e3f2ff");
const splitColor = theme.split || (reportMode ? "#e2e8f0" : "rgba(98, 121, 186, 0.16)");
const bg = reportMode ? "#ffffff" : "transparent";
const legendData = legendNames;
const hasLegend = legendData.length > 1;
const chartTitle = title || panel?.title || "";
const chartSubtitle =
farmMode && turbineMax > 8 && !reportMode ? `共 ${turbineMax} 台机组` : "";
const hasReference = rows.some(isReferenceLineRow);
const turbineAxisMax = (hasReference ? turbineMax + 0.5 : turbineMax) * TURBINE_Y_GAP;
const turbineAxisLabel = buildTurbineAxisLabelConfig(
turbineMax,
turbineMeta.indexToName,
hasReference,
);
const grid3DLayout = buildGrid3DLayout({
hasLegend,
hasSubtitle: Boolean(chartSubtitle),
reportMode,
});
return {
backgroundColor: bg,
animation: reportMode ? false : true,
animationDuration: reportMode ? 0 : undefined,
title: chartTitle
? {
text: chartTitle,
subtext: chartSubtitle,
left: "center",
top: reportMode ? 8 : 4,
textStyle: {
fontSize: reportMode ? 13 : 12,
color: titleColor,
fontWeight: reportMode ? 600 : 500,
},
subtextStyle: {
fontSize: 10,
color: axisColor,
opacity: 0.82,
},
itemGap: chartSubtitle ? 4 : 8,
}
: undefined,
toolbox: reportMode
? undefined
: {
show: true,
top: 4,
right: hasLegend ? 112 : 12,
itemSize: 14,
itemGap: 10,
iconStyle: {
borderColor: axisColor,
},
emphasis: {
iconStyle: {
borderColor: titleColor,
},
},
feature: {
restore: {
show: true,
title: "还原视角",
},
saveAsImage: {
show: true,
title: "保存图片",
name: chartTitle || "全场风功率图",
backgroundColor: "#0f172a",
pixelRatio: 2,
},
},
},
tooltip: {
show: true,
formatter(params) {
if (!params) return "";
const val = params.value || params.data;
if (!Array.isArray(val)) return "";
const [turbinePosition, windSpeed, power] = val;
const turbineText = formatTurbineAxisLabel(
resolveTurbineIndexFromPosition(turbinePosition),
turbineMeta.indexToName,
);
return `${params.marker || ""}${params.seriesName || ""}
${WIND_POWER_3D_AXIS.y}: ${windSpeed}
${WIND_POWER_3D_AXIS.x}: ${turbineText}
${WIND_POWER_3D_AXIS.z}: ${power}`;
},
},
legend: hasLegend
? {
type: "scroll",
orient: "vertical",
right: reportMode ? 4 : 0,
top: chartTitle ? (reportMode ? 40 : 36) : 12,
bottom: 8,
width: 96,
itemWidth: 12,
itemHeight: 8,
itemGap: 5,
pageButtonItemGap: 6,
pageIconSize: 10,
textStyle: { color: legendColor, fontSize: 10 },
data: legendData,
}
: undefined,
grid3D: {
...grid3DLayout,
light: {
main: { intensity: 1.35, shadow: reportMode, alpha: 35, beta: 120 },
ambient: { intensity: 0.92 },
},
},
xAxis3D: {
type: "value",
name: WIND_POWER_3D_AXIS.x,
min: 0,
max: turbineAxisMax,
interval: turbineAxisLabel.interval,
nameGap: 28,
nameTextStyle: { color: axisColor, fontSize: 10 },
axisLabel: {
color: axisColor,
fontSize: 9,
hideOverlap: true,
showMinLabel: true,
showMaxLabel: true,
margin: 4,
formatter: turbineAxisLabel.formatter,
},
axisLine: { lineStyle: { color: splitColor, opacity: 0.65 } },
splitLine: buildAxisSplitStyle(splitColor, true),
},
yAxis3D: {
type: "value",
name: resolveWindSpeedAxisName(panel),
min: plotExtents.windMin,
max: plotExtents.windMax,
splitNumber: 5,
nameGap: 22,
nameTextStyle: { color: axisColor, fontSize: 10, fontWeight: 500 },
axisLabel: { color: axisColor, fontSize: 9, margin: 4 },
axisLine: { lineStyle: { color: splitColor, opacity: 0.8 } },
splitLine: buildAxisSplitStyle(splitColor, true),
},
zAxis3D: {
type: "value",
name: resolvePowerAxisName(panel),
min: 0,
max: plotExtents.powerMax,
splitNumber: 5,
nameGap: 24,
nameTextStyle: { color: axisColor, fontSize: 10, fontWeight: 500 },
axisLabel: { color: axisColor, fontSize: 9, margin: 4 },
axisLine: { lineStyle: { color: splitColor, opacity: 0.8 } },
splitLine: buildAxisSplitStyle(splitColor, true),
},
series,
};
}
export function buildWindPower3DPanelOption(panel, options = {}) {
return buildWindPower3DChartOption({
panel,
templateKey: options.templateKey || "",
title: options.title || panel?.title || "",
farmMode: Boolean(options.farmMode),
pickColor: options.pickColor,
theme: options.theme || {},
reportMode: Boolean(options.reportMode),
});
}