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, }); }