import * as echarts from "echarts";
import "echarts-gl";
import { readCssVar, isTechBlueTheme } from "@/utils/chartTheme";
import {
shouldUseWindPower3D,
buildWindPower3DPanelOption,
} from "../../../../shared/anomalyWindPower3D.mjs";
import {
DIAGNOSTIC_COLORS,
DIAGNOSTIC_CONTINUOUS_COLORS,
PAGE_DIAGNOSTIC_COUNT_COLORS,
classifyDiagnosticSeries,
isOverlayCurveSeries,
diagnosticBladeStyle,
diagnosticFarmSeriesColor,
diagnosticFarmStyle,
diagnosticFarmSymbolSize,
farmSeriesPaintOrder,
diagnosticLineType,
distinctLineXCount,
isDeloadRateGuideLabel,
isDeloadThresholdLabel,
spanThresholdLinePairs,
diagnosticSeriesColor,
diagnosticSeriesSymbol,
diagnosticSingleSeriesZ,
expandAxisToExtent,
lockAxisToRange,
applyPitchCoordXAxis,
farmStatusPaletteColor,
farmTurbineLegendName,
farmStatusSeriesName,
pickFarmStatusLegend,
ensureFarmNormalLegendSeries,
usesFarmStatusLegend,
YAW_ERROR_ABS_RANGES,
FARM_STATUS_NORMAL_LEGEND,
classifyYawErrorAbsRange,
buildFarmStatusBarSeries,
} from "../../../../shared/anomalyDiagnosticVisual.mjs";
import {
extractDetectorPanels,
findPeakCoord,
formatFarmSeriesHover,
formatFarmValueHover,
formatPointHover,
resolvePanelSubtitle,
isBubbleColorbar,
usesBubbleSymbolSize,
resolveRowColorMetric,
sortScatterPairsByColorMetricAsc,
isFarmPanelsJson,
isValueBarPanel,
isYawCountDetector,
isYawErrorDetector,
resolveTemplateKey,
scaleBubbleSize,
unwrapFarmPlotJson,
isPointAnomalyFlag,
readPointAnomalyFlags,
splitConnectedLineByPointAnomaly,
} from "../../../../shared/anomalyDetectorPlotAdapter.mjs";
import {
applyFarmPanelTitles,
fillFarmPanelsToRule,
getFarmPanelRule,
selectFarmPanels,
shouldExcludeFarmLabel,
shouldKeepFullFarmLegend,
applyZeroStartAxis,
seriesAxisDataMin,
} from "../../../../shared/anomalyFarmChartRules.mjs";
const FORCE_SCATTER_TEMPLATE_KEYS = new Set([
"wind_power_scatter",
"pitch_coord",
"ctrl_op_state",
"ctrl_power_quality",
"ctrl_deload",
"yaw_static",
"yaw_twist",
"aero_tsr",
"aero_cp",
"aero_tsr_wind",
]);
/** 以下检测器图表统一按散点图展示(含多 panel 轮播) */
const SCATTER_ONLY_DETECTOR_KEYS = new Set(["anomalyPowerqualityPO"]);
/** 变桨红蓝分段:蓝线共用「正常」,红线按异常机组列入图例 */
function pitchSegmentSeriesName(row, anomaly) {
if (!anomaly) return "正常";
const name = String(farmTurbineLegendName(row) || "")
.replace(/\s*桨叶\s*\d+\s*$/u, "")
.trim();
return name || "异常";
}
function pitchSegmentLegendData(series = []) {
const anomalyNames = [];
const seen = new Set();
series.forEach((item) => {
if (!item.__bladeSegment || item.name === "正常" || seen.has(item.name)) {
return;
}
seen.add(item.name);
anomalyNames.push(item.name);
});
anomalyNames.sort((a, b) => a.localeCompare(b, "zh-CN", { numeric: true }));
const hasNormal = series.some(
(item) => item.__bladeSegment && item.name === "正常",
);
return [...(hasNormal ? ["正常"] : []), ...anomalyNames];
}
function chartTheme() {
return {
axis: readCssVar("--anomaly-chart-axis-label", "#8da3c7"),
split: readCssVar("--anomaly-chart-split", "rgba(98, 121, 186, 0.2)"),
tooltipBg: readCssVar(
"--anomaly-chart-tooltip-bg",
"rgba(16, 30, 66, 0.95)",
),
tooltipBorder: readCssVar(
"--anomaly-chart-tooltip-border",
"rgba(120, 156, 255, 0.4)",
),
tooltipText: readCssVar("--anomaly-chart-tooltip-text", "#dbeafe"),
legend: readCssVar("--anomaly-chart-legend", "#cde6ff"),
title: readCssVar("--anomaly-title", "#e3f2ff"),
dataZoomBg: readCssVar(
"--anomaly-chart-datazoom-bg",
"rgba(16, 21, 39, 0.85)",
),
dataZoomBorder: readCssVar(
"--anomaly-chart-datazoom-border",
"rgba(101, 167, 201, 0.35)",
),
dataZoomFiller: readCssVar(
"--anomaly-chart-datazoom-filler",
"rgba(50, 115, 138, 0.55)",
),
};
}
function getTooltipBase() {
return {
appendToBody: true,
confine: false,
extraCssText: "z-index: 999999 !important; pointer-events: none;",
};
}
export function extractPanels(plotJson, options = {}) {
return extractDetectorPanels(plotJson, options);
}
function filterFarmPanelRows(panel, templateKey) {
if (!panel || panel.chartKind === "valueBar") return panel;
const data = (panel.data || []).filter(
(row) =>
!shouldExcludeFarmLabel(row.label || row.originalLabel, templateKey),
);
return { ...panel, data };
}
function emptyFarmPanelSlots(templateKey) {
return (getFarmPanelRule(templateKey).titles || []).map((title) => ({
title,
xaxis: "",
yaxis: "",
data: [],
}));
}
/** 页面总图按规则保留全部分面 Tab;JSON 缺哪面就留空,不再补模拟数据 */
export function expandFarmPagePanels(plotJson, options = {}) {
const json = unwrapFarmPlotJson(plotJson) || plotJson;
const templateKey =
options.templateKey || json?.detector || json?.__templateKey || "";
const rule = getFarmPanelRule(templateKey);
let panels = extractDetectorPanels(plotJson, options).map((panel) =>
filterFarmPanelRows(panel, templateKey),
);
if (
panels.length &&
panels.every((panel) => panel?.chartKind === "valueBar")
) {
return panels;
}
// chartType=panels:算法已按分面给全,按原顺序与原标题展示
if (isFarmPanelsJson(json)) {
panels =
panels.length >= rule.count
? panels
: fillFarmPanelsToRule(
panels,
emptyFarmPanelSlots(templateKey),
templateKey,
);
} else {
panels = selectFarmPanels(panels, templateKey);
panels =
rule.count <= 1
? applyFarmPanelTitles(panels, templateKey)
: fillFarmPanelsToRule(
panels,
emptyFarmPanelSlots(templateKey),
templateKey,
);
}
return panels;
}
function isTimeAxis(xaxis, sampleX) {
if (xaxis && String(xaxis).includes("时间")) return true;
if (typeof sampleX === "string" && /\d{4}-\d{2}-\d{2}/.test(sampleX)) {
return true;
}
const n = Number(sampleX);
return Number.isFinite(n) && n > 1e12;
}
function toMs(v) {
const n = Number(v);
if (!Number.isFinite(n)) return v;
return n > 1e15 ? Math.floor(n / 1e6) : n;
}
function formatTimeLabel(ms) {
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`;
}
function formatTimeAxisLabel(ms) {
const d = new Date(ms);
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}\n${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`;
}
function pickSeriesColor(label, index) {
// 页面「停机」点用可见色:科技蓝深底走浅灰,浅色主题仍用 shutdown 深色
return diagnosticSeriesColor(label, index, {
pageTheme: true,
darkTheme: isTechBlueTheme(),
});
}
function isDashedLine(label) {
return diagnosticLineType(label) !== "solid";
}
function buildLineStyle(color, label) {
return {
width: 2,
color,
type: isDashedLine(label) ? "dashed" : "solid",
};
}
function isCompositionPanel(panel) {
if (panel.xaxis || panel.yaxis) return false;
const rows = panel.data || [];
if (!rows.length) return false;
return rows.every(
(row) =>
Array.isArray(row.xData) &&
row.xData.length === 1 &&
Array.isArray(row.yData) &&
row.yData.length === 1 &&
Number(row.xData[0]) === Number(row.yData[0]),
);
}
function buildCompositionOption(panel, theme) {
const pieData = (panel.data || []).map((row, idx) => ({
name: row.label,
value: Number(row.xData?.[0]) || Number(row.yData?.[0]) || 0,
itemStyle: { color: pickSeriesColor(row.label, idx) },
}));
return {
backgroundColor: "transparent",
tooltip: {
...getTooltipBase(),
trigger: "item",
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
textStyle: { color: theme.tooltipText },
},
legend: {
bottom: 6,
left: "center",
textStyle: { color: theme.legend, fontSize: 11 },
},
series: [
{
type: "pie",
radius: ["38%", "62%"],
center: ["50%", "52%"],
data: pieData,
label: {
color: theme.legend,
fontSize: 11,
formatter: "{b}\n{d}%",
},
},
],
};
}
function toAxisValue(v, useTime) {
if (!useTime) return v;
const n = Number(v);
if (Number.isFinite(n)) return toMs(n);
if (typeof v === "string" && v) {
const parsed = Date.parse(v.replace(" ", "T"));
if (Number.isFinite(parsed)) return parsed;
}
return v;
}
function looksLikeTimeArray(arr) {
if (!Array.isArray(arr) || !arr.length) return false;
return isTimeAxis(
"",
arr.find((item) => item != null && item !== ""),
);
}
function sampleRowX(row) {
const pools = [row?.timeData, row?.xData, row?.x];
for (let i = 0; i < pools.length; i += 1) {
const arr = pools[i];
if (!Array.isArray(arr)) continue;
const hit = arr.find((item) => item != null && item !== "");
if (hit != null) return hit;
}
return undefined;
}
/** 与报告一致:时序优先 timeData/xData/x 中真正的时间列,否则读 xData、x */
function getRowXData(row, useTime = false) {
if (useTime) {
if (looksLikeTimeArray(row?.timeData)) return row.timeData;
if (looksLikeTimeArray(row?.xData)) return row.xData;
if (looksLikeTimeArray(row?.x)) return row.x;
}
if (Array.isArray(row?.xData) && row.xData.length) return row.xData;
if (Array.isArray(row?.x) && row.x.length) return row.x;
if (Array.isArray(row?.timeData) && row.timeData.length) return row.timeData;
return [];
}
function getRowYData(row) {
if (Array.isArray(row?.yData) && row.yData.length) return row.yData;
if (Array.isArray(row?.y) && row.y.length) return row.y;
return [];
}
function toYNumber(v) {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function hoverExtra(row) {
const extra = row?.extra;
const model = row?.model;
const extraHasModel =
extra &&
typeof extra === "object" &&
extra.model != null &&
extra.model !== "";
if (extraHasModel || model == null || model === "") return extra || null;
if (extra && typeof extra === "object") return { ...extra, model };
return { model };
}
function buildScatterPairs(row, useTime) {
const xArr = getRowXData(row, useTime);
const yArr = getRowYData(row);
const colorMetric = resolveRowColorMetric(
row,
Math.min(xArr.length, yArr.length),
);
const points = Array.isArray(row.pointData) ? row.pointData : [];
const len = Math.min(xArr.length, yArr.length);
const extra = hoverExtra(row);
const pairs = [];
for (let i = 0; i < len; i += 1) {
const y = toYNumber(yArr[i]);
if (y == null) continue;
const value = [toAxisValue(xArr[i], useTime), y];
if (colorMetric.length === len) value.push(colorMetric[i]);
pairs.push({
value,
point: points[i] || null,
extra,
});
}
return pairs;
}
function finiteExtent(values = []) {
let min = Infinity;
let max = -Infinity;
for (let i = 0; i < values.length; i += 1) {
const value = Number(values[i]);
if (!Number.isFinite(value)) continue;
if (value < min) min = value;
if (value > max) max = value;
}
return { min, max };
}
function collectFlaggedAnomalyPairs(row, useTime) {
const flags = readPointAnomalyFlags(row);
if (!Array.isArray(flags) || !flags.length) return [];
if (classifyDiagnosticSeries(row?.label) === "anomaly") return [];
const xArr = getRowXData(row, useTime);
const yArr = getRowYData(row);
const points = Array.isArray(row.pointData) ? row.pointData : [];
const len = Math.min(xArr.length, yArr.length);
const pairs = [];
for (let i = 0; i < len; i += 1) {
if (!isPointAnomalyFlag(flags[i])) continue;
const y = toYNumber(yArr[i]);
if (y == null) continue;
pairs.push({
value: [toAxisValue(xArr[i], useTime), y],
point: points[i] || null,
extra: row.extra || null,
});
}
return pairs;
}
/** 图例已有「异常点」系列但坐标没读到时,用同图 pointAnomaly 把红点补上 */
function fillEmptyAnomalySeries(series, rows, useTime) {
const empty = (series || []).filter(
(item) =>
classifyDiagnosticSeries(item.name) === "anomaly" &&
!(item.data || []).length,
);
if (!empty.length) return;
const pairs = [];
(rows || []).forEach((row) => {
pairs.push(...collectFlaggedAnomalyPairs(row, useTime));
});
if (!pairs.length) return;
empty.forEach((item) => {
item.data = pairs;
item.type = "scatter";
item.symbol = "circle";
item.symbolSize = item.symbolSize || 6;
item.showSymbol = true;
item.z = diagnosticSingleSeriesZ({ label: item.name });
item.z2 = 2;
item.zlevel = 0;
item.itemStyle = {
...(item.itemStyle || {}),
color: DIAGNOSTIC_COLORS.anomaly,
opacity: 1,
};
});
}
function buildSeries(panel, theme, options = {}) {
const farmMode = Boolean(options.farmMode);
const templateKey =
resolveTemplateKey(options) || resolveWindPowerTemplateKey(options);
const forceScatter =
SCATTER_ONLY_DETECTOR_KEYS.has(options.detectorKey) ||
options.detectorKey === "anomalyScatterPO" ||
(farmMode && FORCE_SCATTER_TEMPLATE_KEYS.has(templateKey));
const firstRow = panel.data?.[0];
const sampleX = sampleRowX(firstRow);
const hasTimeData = (panel.data || []).some(
(row) => Array.isArray(row.timeData) && row.timeData.length,
);
const axisSaysTime = String(panel.xaxis || "").includes("时间");
const useTime =
hasTimeData || axisSaysTime || isTimeAxis(panel.xaxis, sampleX);
const series = [];
const visualMaps = [];
const densePitchCoord = farmMode && templateKey === "pitch_coord";
const statusLegendPick =
farmMode && usesFarmStatusLegend(templateKey)
? pickFarmStatusLegend(panel.data, templateKey)
: null;
(panel.data || []).forEach((row, idx) => {
const rawMode = row.mode || "lines";
const overlayCurve = isOverlayCurveSeries(row);
let mode =
forceScatter && !row.__shared && !overlayCurve ? "markers" : rawMode;
if (
farmMode &&
templateKey === "pitch_regulation" &&
!row.__shared &&
!overlayCurve
) {
mode = "lines+markers";
}
if (row.__shared || overlayCurve) mode = "lines";
if (
/累计降载|频繁阈值|正常上限/.test(
String(row.originalLabel || row.label || ""),
)
) {
mode = "lines";
}
if (
!farmMode &&
!row.__shared &&
!overlayCurve &&
classifyDiagnosticSeries(row) === "anomaly"
) {
mode = "markers";
}
const color = farmMode
? diagnosticFarmSeriesColor(row, idx, templateKey)
: pickSeriesColor(row.label, idx);
const seriesName = statusLegendPick
? farmStatusSeriesName(row, statusLegendPick.mode, templateKey)
: row.label;
const diagnosticRole = classifyDiagnosticSeries(row);
const farmStyle = diagnosticFarmStyle(row, templateKey);
const farmAnomaly = farmMode && diagnosticRole === "anomaly";
const isMarker = mode.includes("markers");
const isLine = mode.includes("lines");
const pairs = buildScatterPairs(row, useTime);
const pointCount = pairs.length;
const sourcePointCount = Math.min(
getRowXData(row, useTime).length,
getRowYData(row).length,
);
const colorMetric = resolveRowColorMetric(row, sourcePointCount);
const colorExtent = finiteExtent(colorMetric);
const ignoreColorbar = Boolean(statusLegendPick);
// 正常系列 x/y 都为空时,色值个数和点数都是 0,不能当成色带。
// 否则 visualMap 的 min/max 会变成 Infinity / -Infinity,画布渐变报 addColorStop。
const hasColorbar =
!ignoreColorbar &&
sourcePointCount > 0 &&
Array.isArray(colorMetric) &&
colorMetric.length === sourcePointCount &&
Number.isFinite(colorExtent.min) &&
Number.isFinite(colorExtent.max) &&
colorExtent.max > colorExtent.min;
const useBubble = hasColorbar && isBubbleColorbar(row, templateKey);
const useBubbleSize = hasColorbar && usesBubbleSymbolSize(row, templateKey);
const scatterPairs = useBubble
? sortScatterPairsByColorMetricAsc(pairs)
: pairs;
const bladeStyle = farmMode
? diagnosticBladeStyle(row.originalLabel)
: null;
const symbol = "circle";
const peak = isYawErrorDetector(templateKey)
? findPeakCoord(getRowXData(row, useTime), getRowYData(row))
: null;
const markPoint =
peak && !farmMode
? {
symbol: "diamond",
symbolSize: 10,
itemStyle: { color: "#ff9f43" },
label: {
formatter: `${peak.x}°`,
color: theme.legend,
fontSize: 10,
},
data: [
{
name: "最大功率",
coord: [toAxisValue(peak.x, useTime), peak.y],
},
],
}
: undefined;
if (hasColorbar) {
const { min: cbMin, max: cbMax } = colorExtent;
const seriesIndex = series.length;
const useColorbar = !farmAnomaly && diagnosticRole !== "anomaly";
series.push({
name: seriesName,
type: "scatter",
data: scatterPairs,
symbol: "circle",
symbolSize: useBubbleSize
? (item) =>
scaleBubbleSize(
Array.isArray(item.value) ? item.value[2] : item[2],
cbMin,
cbMax,
)
: farmMode
? diagnosticFarmSymbolSize(row, pointCount, templateKey)
: templateKey === "pitch_min"
? 8
: pointCount > 5000
? 3
: 6,
large: false,
progressive: 0,
animation: false,
hoverAnimation: false,
emphasis: { scale: false },
clip: true,
itemStyle: useColorbar
? {
opacity:
templateKey === "pitch_min" ? 0.92 : densePitchCoord ? 0.72 : 1,
}
: {
color: DIAGNOSTIC_COLORS.anomaly,
opacity: farmMode ? farmStyle.pointOpacity : 1,
borderColor: "#ffffff",
borderWidth: 1,
},
__farmExtra: row.extra || null,
...(farmMode
? farmSeriesPaintOrder(row, templateKey)
: { z: diagnosticSingleSeriesZ(row), z2: 0, zlevel: 0 }),
});
if (useColorbar) {
const sharedPitchColorbar = farmMode && templateKey === "pitch_coord";
const existingVisualMap = sharedPitchColorbar ? visualMaps[0] : null;
if (existingVisualMap) {
existingVisualMap.min = Math.min(existingVisualMap.min, cbMin);
existingVisualMap.max = Math.max(existingVisualMap.max, cbMax);
existingVisualMap.seriesIndex.push(seriesIndex);
} else {
visualMaps.push({
show: true,
dimension: 2,
min: cbMin,
max: cbMax,
calculable: false,
orient: "vertical",
right: 6,
top: "middle",
text: [
row.colorbarTitle ||
row.colorbartitle ||
(useBubble ? "点数" : "桨距角 (°)"),
],
textStyle: { color: theme.legend, fontSize: 10 },
inRange: {
// 页面点数:暗→亮蓝(点数越高越醒目);报告色带另用 DIAGNOSTIC_COUNT_COLORS
color: useBubble
? PAGE_DIAGNOSTIC_COUNT_COLORS
: DIAGNOSTIC_CONTINUOUS_COLORS,
},
seriesIndex: sharedPitchColorbar ? [seriesIndex] : seriesIndex,
});
}
}
return;
}
if (isMarker && !isLine) {
series.push({
name: seriesName,
type: "scatter",
data: pairs,
symbol,
symbolSize: farmMode
? diagnosticFarmSymbolSize(row, pointCount, templateKey)
: pointCount > 5000
? 3
: 6,
large: false,
progressive: 0,
animation: false,
hoverAnimation: false,
emphasis: { scale: false },
clip: true,
itemStyle: {
color,
opacity: farmMode
? farmStyle.pointOpacity
: diagnosticRole === "anomaly" || diagnosticRole === "deload"
? 1
: 0.82,
},
__farmExtra: row.extra || null,
...(farmMode
? farmSeriesPaintOrder(row, templateKey)
: { z: diagnosticSingleSeriesZ(row), z2: 0, zlevel: 0 }),
});
return;
}
const farmPitchBlade = farmMode && templateKey === "pitch_regulation";
const lineType = bladeStyle ? bladeStyle.lineType : diagnosticLineType(row);
if (isLine && !isMarker) {
const pitchRangeSinglePoint =
templateKey === "pitch_regulation" &&
/极差/.test(`${panel.title || ""} ${panel.yaxis || ""}`) &&
pairs.length === 1;
const deloadGuide = isDeloadRateGuideLabel(row);
let lineData = pairs;
if (deloadGuide && isDeloadThresholdLabel(row)) {
const axisX = [];
(panel.data || []).forEach((item) => {
getRowXData(item, useTime).forEach((x) => {
axisX.push(toAxisValue(x, useTime));
});
});
const spanned = spanThresholdLinePairs(pairs, axisX);
if (spanned) lineData = spanned;
}
// 只有一个时刻时折线没有线段。累计降载率和阈值都改成画点。
const showLonePoint =
(pitchRangeSinglePoint || deloadGuide) &&
distinctLineXCount(lineData) <= 1;
if (showLonePoint && lineData.length > 1) {
lineData = [lineData[0]];
}
series.push({
name: seriesName,
type: "line",
data: lineData,
showSymbol: showLonePoint,
symbol: showLonePoint ? "circle" : "none",
symbolSize: showLonePoint ? 10 : 0,
smooth: false,
clip: showLonePoint ? false : true,
lineStyle: {
...buildLineStyle(color, row.label),
type: lineType,
width: farmMode
? farmStyle.lineWidth
: diagnosticRole === "contract"
? 3.8
: row.__shared
? 2.5
: 2,
opacity: farmMode ? farmStyle.lineOpacity : 1,
},
itemStyle: { color },
...(farmMode
? farmSeriesPaintOrder(row, templateKey)
: { z: diagnosticSingleSeriesZ(row), z2: 0, zlevel: 0 }),
});
return;
}
if (farmPitchBlade) {
const flags = readPointAnomalyFlags(row);
const segments = splitConnectedLineByPointAnomaly(
getRowXData(row, useTime),
getRowYData(row),
flags,
);
if (Array.isArray(flags) && flags.length && segments.length) {
const symbol = diagnosticSeriesSymbol(
row,
bladeStyle ? bladeStyle.symbol : "circle",
);
segments.forEach((segment) => {
const data = [];
segment.xData.forEach((xValue, index) => {
const y = toYNumber(segment.yData[index]);
if (y == null) return;
data.push({
value: [toAxisValue(xValue, useTime), y],
extra: row.extra || null,
});
});
if (!data.length) return;
const segmentColor = segment.anomaly
? DIAGNOSTIC_COLORS.anomaly
: DIAGNOSTIC_COLORS.normal;
const singlePoint = data.length === 1;
series.push({
name: pitchSegmentSeriesName(row, segment.anomaly),
type: "line",
data,
clip: true,
showSymbol: true,
symbol,
symbolSize: singlePoint ? 10 : 8,
smooth: false,
lineStyle: {
color: segmentColor,
type: lineType,
width: farmStyle.lineWidth,
opacity: 1,
},
itemStyle: { color: segmentColor, opacity: 1 },
__farmExtra: row.extra || null,
__bladeSegment: true,
z: segment.anomaly ? 20 : 2,
z2: segment.anomaly ? 20 : 0,
zlevel: 0,
});
});
return;
}
}
series.push({
name: seriesName,
type: "line",
data: pairs,
clip: true,
showSymbol:
(templateKey === "pitch_regulation" &&
/极差/.test(`${panel.title || ""} ${panel.yaxis || ""}`) &&
pairs.length === 1) ||
Boolean(bladeStyle?.showSymbol) ||
isMarker,
symbol: diagnosticSeriesSymbol(
row,
bladeStyle ? bladeStyle.symbol : "circle",
),
symbolSize:
templateKey === "pitch_regulation" &&
/极差/.test(`${panel.title || ""} ${panel.yaxis || ""}`) &&
pairs.length === 1
? 10
: bladeStyle
? 8
: farmMode
? diagnosticFarmSymbolSize(row, pointCount, templateKey)
: 4,
smooth: false,
markPoint,
lineStyle: {
...buildLineStyle(color, row.label),
type: lineType,
width: farmMode ? farmStyle.lineWidth : 2,
opacity: farmMode ? farmStyle.lineOpacity : 1,
},
itemStyle: {
color,
opacity: farmMode ? farmStyle.pointOpacity : 0.88,
},
...(farmMode
? farmSeriesPaintOrder(row, templateKey)
: { z: diagnosticSingleSeriesZ(row), z2: 0, zlevel: 0 }),
});
});
if (!farmMode) {
fillEmptyAnomalySeries(series, panel.data, useTime);
}
if (!visualMaps.length) {
series.sort(
(a, b) =>
(Number(a.z) || 0) * 100 +
(Number(a.z2) || 0) -
((Number(b.z) || 0) * 100 + (Number(b.z2) || 0)),
);
}
return {
series: ensureFarmNormalLegendSeries(series, statusLegendPick),
useTime,
visualMaps,
optimizedDenseScatter: false,
};
}
/** 检测器折线/散点图:区域圈选、滚轮缩放、拖拽平移、还原 */
function buildPlotInteractionOptions(
theme,
{ hasVisualMap, useTime, hasLegend } = {},
) {
const sliderBottom = hasLegend ? 32 : 8;
return {
toolbox: {
right: hasVisualMap ? 76 : 8,
top: 4,
itemSize: 14,
itemGap: 8,
showTitle: true,
iconStyle: { borderColor: theme.legend },
emphasis: { iconStyle: { borderColor: theme.title } },
tooltip: {
show: true,
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
textStyle: { color: theme.tooltipText, fontSize: 11 },
},
feature: {
dataZoom: {
xAxisIndex: 0,
yAxisIndex: 0,
title: { zoom: "区域缩放", back: "缩放还原" },
},
restore: { title: "还原" },
saveAsImage: {
title: "保存图片",
pixelRatio: 2,
backgroundColor: "transparent",
},
},
},
dataZoom: [
{
type: "inside",
xAxisIndex: 0,
filterMode: "none",
zoomOnMouseWheel: true,
moveOnMouseMove: true,
preventDefaultMouseMove: true,
},
{
type: "inside",
yAxisIndex: 0,
filterMode: "none",
zoomOnMouseWheel: "shift",
moveOnMouseMove: true,
preventDefaultMouseMove: true,
},
...(useTime
? [
{
type: "slider",
xAxisIndex: 0,
filterMode: "none",
height: 16,
bottom: sliderBottom,
borderColor: theme.dataZoomBorder,
backgroundColor: theme.dataZoomBg,
fillerColor: theme.dataZoomFiller,
handleSize: "80%",
showDetail: false,
brushSelect: false,
},
]
: []),
],
};
}
const PO_TO_WIND_POWER_3D_TEMPLATE = {
anomalyPowercurvePO: "wind_power_curve",
anomalyScatterPO: "wind_power_scatter",
};
function resolveWindPowerTemplateKey(options = {}) {
if (options.templateKey) return options.templateKey;
const key = options.detectorKey || options.poKey || "";
return PO_TO_WIND_POWER_3D_TEMPLATE[key] || "";
}
function buildWindPower3DOption(panel, options = {}) {
const theme = chartTheme();
const farmMode = Boolean(options.farmMode);
const templateKey = resolveWindPowerTemplateKey(options);
return buildWindPower3DPanelOption(panel, {
...options,
templateKey,
farmMode,
theme,
reportMode: false,
pickColor: (label, index, row) =>
farmMode
? diagnosticFarmSeriesColor(row, index, templateKey)
: pickSeriesColor(label, index),
});
}
function seriesAxisExtent(series = []) {
let xMin = Infinity;
let xMax = -Infinity;
let yMin = Infinity;
let yMax = -Infinity;
let found = false;
(series || []).forEach((item) => {
(item.data || []).forEach((point) => {
const coord = Array.isArray(point)
? point
: Array.isArray(point?.value)
? point.value
: null;
if (!coord) return;
const x = Number(coord[0]);
const y = Number(coord[1]);
if (Number.isFinite(x)) {
if (x < xMin) xMin = x;
if (x > xMax) xMax = x;
found = true;
}
if (Number.isFinite(y)) {
if (y < yMin) yMin = y;
if (y > yMax) yMax = y;
found = true;
}
});
});
if (!found) return null;
return {
xMin: Number.isFinite(xMin) ? xMin : null,
xMax: Number.isFinite(xMax) ? xMax : null,
yMin: Number.isFinite(yMin) ? yMin : null,
yMax: Number.isFinite(yMax) ? yMax : null,
};
}
function pickValueCoord(item) {
if (Array.isArray(item?.value)) return item.value;
if (Array.isArray(item?.data?.value)) return item.data.value;
if (Array.isArray(item?.data)) return item.data;
return [item?.value];
}
function buildValueBarOption(panel, theme) {
const categories = panel.categories || [];
const detector = panel.detectorName || "";
const colorMode = panel.colorMode || "";
const showMean =
panel.showMean !== false && Number.isFinite(Number(panel.mean));
const isCountChart = colorMode === "yawErrorAbsCount";
const isAbsRangeChart = colorMode === "yawErrorAbs";
const isFarmStatusChart = colorMode === "farmStatus";
const compactAxis = categories.length <= 6;
const farmStatusBars = isFarmStatusChart
? buildFarmStatusBarSeries({
categories,
values: panel.values || [],
extras: panel.extras || [],
anomalyFlags: panel.anomalyFlags || [],
})
: null;
const legendNames = isAbsRangeChart
? YAW_ERROR_ABS_RANGES.map((item) => item.label)
: farmStatusBars
? farmStatusBars.legendNames
: showMean
? ["风场均值"]
: [];
const data =
isAbsRangeChart || isFarmStatusChart
? null
: categories.map((name, idx) => {
const anomaly = Boolean(panel.anomalyFlags?.[idx]);
const value = panel.values?.[idx];
return {
name,
value,
extra: panel.extras?.[idx],
itemStyle: {
color: isCountChart
? panel.colors?.[idx] || yawErrorAbsRangeColorByIndex(idx)
: farmStatusPaletteColor(
{ extra: { anomaly }, turbineIndex: idx },
idx,
),
},
};
});
const absRangeSeries = isAbsRangeChart
? YAW_ERROR_ABS_RANGES.map((range) => ({
type: "bar",
name: range.label,
color: range.color,
itemStyle: { color: range.color },
data: categories.map((name, idx) => {
const value = panel.values?.[idx];
const hit = classifyYawErrorAbsRange(value);
if (!hit || hit.key !== range.key) return null;
return {
name,
value,
extra: panel.extras?.[idx],
itemStyle: { color: range.color },
};
}),
barMaxWidth: 28,
barGap: "-100%",
}))
: [];
const barSeries = isAbsRangeChart
? absRangeSeries
: isFarmStatusChart
? farmStatusBars.series
: [
{
type: "bar",
name: panel.yaxis || "检测值",
data,
barMaxWidth: 28,
label: isCountChart
? {
show: true,
position: "top",
color: theme.axis,
fontSize: 11,
}
: undefined,
},
];
const legendData = legendNames.map((name) => {
const range = isAbsRangeChart
? YAW_ERROR_ABS_RANGES.find((item) => item.label === name)
: null;
if (range) {
return { name, itemStyle: { color: range.color } };
}
if (isFarmStatusChart && name === FARM_STATUS_NORMAL_LEGEND) {
return { name, itemStyle: { color: DIAGNOSTIC_COLORS.normal } };
}
if (isFarmStatusChart) {
return { name, itemStyle: { color: DIAGNOSTIC_COLORS.anomaly } };
}
return name;
});
return {
backgroundColor: "transparent",
color: isAbsRangeChart
? YAW_ERROR_ABS_RANGES.map((range) => range.color)
: undefined,
...buildPlotInteractionOptions(theme, {
hasVisualMap: false,
useTime: false,
hasLegend: legendNames.length > 0,
}),
tooltip: {
...getTooltipBase(),
trigger: isAbsRangeChart || isFarmStatusChart ? "axis" : "item",
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
textStyle: { color: theme.tooltipText },
formatter(params) {
const list = Array.isArray(params) ? params : [params];
const hit = list.find((item) => item && item.value != null) || list[0];
if (!hit) return "";
const extra = hit?.data?.extra || {};
const hover = formatFarmValueHover(extra, detector);
const unit = isYawCountDetector(detector) ? "次" : "";
return `${hit.marker || ""}${hit.name}
${panel.yaxis || "数值"}: ${
hit.value
}${unit}${hover ? `
${hover}` : ""}`;
},
},
legend: legendNames.length
? {
type: legendNames.length > 10 ? "scroll" : "plain",
bottom: 4,
left: "center",
textStyle: { color: theme.legend, fontSize: 10 },
data: legendData,
}
: undefined,
grid: {
top: 36,
left: 52,
right: 24,
bottom: legendNames.length ? 56 : 40,
containLabel: true,
},
xAxis: {
type: "category",
data: categories,
name: panel.xaxis || "风机",
nameLocation: "middle",
nameGap: compactAxis ? 24 : 28,
nameTextStyle: { color: theme.axis, fontSize: 10 },
axisLabel: {
color: theme.axis,
fontSize: 10,
hideOverlap: true,
rotate: compactAxis ? 0 : 30,
},
axisLine: { lineStyle: { color: theme.split } },
},
yAxis: {
type: "value",
name: panel.yaxis || "",
nameLocation: "middle",
nameGap: 42,
minInterval: isCountChart ? 1 : undefined,
nameTextStyle: { color: theme.axis, fontSize: 10 },
axisLabel: {
color: theme.axis,
fontSize: 10,
formatter: isYawCountDetector(detector)
? (value) => `${value}次`
: undefined,
},
splitLine: { lineStyle: { color: theme.split, type: "dashed" } },
},
series: [
...barSeries,
...(showMean
? [
{
type: "line",
name: "风场均值",
data: categories.map(() => panel.mean),
symbol: "none",
lineStyle: { type: "dashed", width: 2, color: "#f2c84e" },
},
]
: []),
],
};
}
function yawErrorAbsRangeColorByIndex(idx) {
return YAW_ERROR_ABS_RANGES[idx]?.color || YAW_ERROR_ABS_RANGES[0].color;
}
export function buildPanelChartOption(panel, options = {}) {
const farmMode = Boolean(options.farmMode);
const templateKey =
resolveTemplateKey(options, panel?.detectorName) ||
resolveWindPowerTemplateKey(options);
if (shouldUseWindPower3D(templateKey, farmMode)) {
return buildWindPower3DOption(panel, { ...options, templateKey, farmMode });
}
const theme = chartTheme();
if (isValueBarPanel(panel)) {
return buildValueBarOption(panel, theme);
}
if (isCompositionPanel(panel)) {
return buildCompositionOption(panel, theme);
}
const { series, useTime, visualMaps, optimizedDenseScatter } = buildSeries(
panel,
theme,
{
...options,
templateKey,
},
);
const anomalyLegendData = series
.filter((item) => classifyDiagnosticSeries(item.name) === "anomaly")
.map((item) => item.name);
const compactFarmDiagnostic =
farmMode &&
series.length > 12 &&
anomalyLegendData.length > 0 &&
!shouldKeepFullFarmLegend(templateKey);
const denseFarmChart =
Boolean(optimizedDenseScatter) || (farmMode && series.length > 12);
const statusLegendPick =
farmMode && usesFarmStatusLegend(templateKey)
? pickFarmStatusLegend(panel.data, templateKey)
: null;
const bladeSegmentLegend = series.some((item) => item.__bladeSegment);
const legendData = bladeSegmentLegend
? pitchSegmentLegendData(series)
: statusLegendPick
? statusLegendPick.names
: compactFarmDiagnostic
? anomalyLegendData
: series.map((item) => item.name);
const hasLegend = statusLegendPick
? legendData.length > 0
: legendData.length > 0 && series.length > 1;
const xName = panel.xaxis || "";
const yName = panel.yaxis || "";
const hasVisualMap = visualMaps.length > 0;
const timeAxisBottom = hasLegend ? 82 : 66;
const sliderExtraBottom = useTime ? (hasLegend ? 28 : 22) : 0;
const panelSubtitle = resolvePanelSubtitle(panel);
const xAxis = {
type: useTime ? "time" : "value",
name: xName,
nameLocation: "middle",
nameGap: useTime ? (hasLegend ? 48 : 40) : hasLegend ? 36 : 28,
nameTextStyle: { color: theme.axis, fontSize: 10 },
...(useTime ? {} : { scale: true }),
axisLabel: {
color: theme.axis,
fontSize: 10,
hideOverlap: true,
margin: useTime ? 10 : 8,
formatter: useTime ? (v) => formatTimeAxisLabel(v) : undefined,
},
axisLine: { lineStyle: { color: theme.split } },
splitLine: { lineStyle: { color: theme.split, type: "dashed" } },
};
const yAxis = {
type: "value",
name: yName,
nameLocation: "middle",
nameGap: 42,
nameTextStyle: { color: theme.axis, fontSize: 10 },
scale: true,
axisLabel: {
color: theme.axis,
fontSize: 10,
formatter: isYawCountDetector(templateKey)
? (value) => `${value}次`
: undefined,
},
splitLine: { lineStyle: { color: theme.split, type: "dashed" } },
axisLine: { lineStyle: { color: theme.split } },
...(isYawCountDetector(templateKey) ? { minInterval: 1 } : {}),
};
const pitchCoord = (templateKey || panel.detectorName) === "pitch_coord";
const xRangeLocked = pitchCoord
? applyPitchCoordXAxis(xAxis, panel.xrange)
: false;
const yRangeLocked = pitchCoord
? lockAxisToRange(yAxis, panel.yrange)
: false;
if (farmMode && templateKey === "aero_cp" && !useTime) {
const extent = seriesAxisDataMin(series);
applyZeroStartAxis(xAxis, extent.xMin);
applyZeroStartAxis(yAxis, extent.yMin);
}
const plottedExtent = seriesAxisExtent(series);
if (plottedExtent && !pitchCoord) {
const xFitsAxis = !useTime || Number(plottedExtent.xMin) > 1e11;
if (xFitsAxis) {
expandAxisToExtent(xAxis, plottedExtent.xMin, plottedExtent.xMax);
}
expandAxisToExtent(yAxis, plottedExtent.yMin, plottedExtent.yMax);
} else if (plottedExtent && pitchCoord) {
const xFitsAxis = !useTime || Number(plottedExtent.xMin) > 1e11;
if (xFitsAxis && !xRangeLocked) {
expandAxisToExtent(xAxis, plottedExtent.xMin, plottedExtent.xMax);
}
if (!yRangeLocked) {
expandAxisToExtent(yAxis, plottedExtent.yMin, plottedExtent.yMax);
}
}
const hasScatterLegend = series.some(
(item) =>
item.type === "scatter" &&
(item.data || []).length > 0 &&
!item.__bladePointOverlay,
);
return {
backgroundColor: "transparent",
...buildPlotInteractionOptions(theme, { hasVisualMap, useTime, hasLegend }),
title: panelSubtitle
? {
show: true,
text: panelSubtitle,
left: 8,
top: 6,
textStyle: {
color: theme.axis,
fontSize: 10,
fontWeight: 400,
width: 520,
},
}
: undefined,
grid: {
top: panelSubtitle ? 52 : hasLegend ? 44 : 36,
left: yName ? 58 : 52,
right: hasVisualMap ? 72 : 24,
bottom:
(useTime
? timeAxisBottom
: xName
? hasLegend
? 68
: 52
: hasLegend
? 56
: 40) + sliderExtraBottom,
containLabel: true,
},
tooltip: {
...getTooltipBase(),
trigger: denseFarmChart ? "item" : "axis",
axisPointer: {
type: denseFarmChart ? "none" : useTime ? "line" : "cross",
},
backgroundColor: theme.tooltipBg,
borderColor: theme.tooltipBorder,
textStyle: { color: theme.tooltipText },
formatter(params) {
const items = Array.isArray(params) ? params : params ? [params] : [];
if (!items.length) return "";
const p = items[0];
const coord = pickValueCoord(p);
const xVal = coord[0];
const yVal = coord[1];
const xText = useTime ? formatTimeLabel(xVal) : xVal;
const pointHover = formatPointHover(p.data?.point);
const isPcaProjection =
/PCA|降维投影/.test(panel.title || "") ||
/^PC\d/i.test(String(panel.xaxis || "").trim());
const hideSeriesValue =
isPcaProjection ||
(/转速/.test(panel.title || "") && /功率/.test(panel.title || ""));
if (isPcaProjection) {
const lines = [];
items.forEach((item) => {
if (item.seriesName) {
lines.push(`${item.marker || ""}${item.seriesName}`);
}
const extra = formatPointHover(item.data?.point);
if (extra) lines.push(extra);
});
return lines.join("
");
}
if (denseFarmChart) {
const extraHover = formatFarmSeriesHover(
p.data?.extra || series[p.seriesIndex]?.__farmExtra,
templateKey || panel.detectorName,
);
const unit = isYawCountDetector(templateKey) ? "次" : "";
return `${p.marker || ""}${p.seriesName}
${
panel.xaxis || "X"
}: ${xText}
${panel.yaxis || "Y"}: ${yVal}${unit}${
pointHover ? `
${pointHover}` : ""
}${extraHover ? `
${extraHover}` : ""}`;
}
const unit = isYawCountDetector(templateKey) ? "次" : "";
let html = `${panel.xaxis || "X"}: ${xText}
`;
items.forEach((item) => {
const itemCoord = pickValueCoord(item);
html += hideSeriesValue
? `${item.marker || ""}${item.seriesName}
`
: `${item.marker}${item.seriesName}: ${itemCoord[1]}${unit}
`;
const extra = formatPointHover(item.data?.point);
if (extra) html += `${extra}
`;
const model = item.data?.extra?.model;
if (!farmMode && model != null && model !== "") {
html += `机型: ${model}
`;
}
const extraHover = farmMode
? formatFarmSeriesHover(
item.data?.extra,
templateKey || panel.detectorName,
)
: "";
if (extraHover) html += `${extraHover}
`;
});
return html;
},
},
legend: hasLegend
? {
type: "scroll",
bottom: 4,
left: "center",
icon: hasScatterLegend ? "circle" : undefined,
itemWidth: hasScatterLegend ? 10 : 14,
itemHeight: hasScatterLegend ? 10 : 8,
textStyle: { color: theme.legend, fontSize: 10 },
data: legendData,
}
: undefined,
visualMap: visualMaps.length ? visualMaps : undefined,
xAxis,
yAxis,
series,
};
}
export function buildFarmPanelChartOption(panel, options = {}) {
return buildPanelChartOption(panel, {
...options,
farmMode: true,
});
}