anomalyPlotChartBuilder.js 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472
  1. import * as echarts from "echarts";
  2. import "echarts-gl";
  3. import { readCssVar, isTechBlueTheme } from "@/utils/chartTheme";
  4. import {
  5. shouldUseWindPower3D,
  6. buildWindPower3DPanelOption,
  7. } from "../../../../shared/anomalyWindPower3D.mjs";
  8. import {
  9. DIAGNOSTIC_COLORS,
  10. DIAGNOSTIC_CONTINUOUS_COLORS,
  11. PAGE_DIAGNOSTIC_COUNT_COLORS,
  12. classifyDiagnosticSeries,
  13. isOverlayCurveSeries,
  14. diagnosticBladeStyle,
  15. diagnosticFarmSeriesColor,
  16. diagnosticFarmStyle,
  17. diagnosticFarmSymbolSize,
  18. farmSeriesPaintOrder,
  19. diagnosticLineType,
  20. distinctLineXCount,
  21. isDeloadRateGuideLabel,
  22. isDeloadThresholdLabel,
  23. spanThresholdLinePairs,
  24. diagnosticSeriesColor,
  25. diagnosticSeriesSymbol,
  26. diagnosticSingleSeriesZ,
  27. expandAxisToExtent,
  28. lockAxisToRange,
  29. applyPitchCoordXAxis,
  30. farmStatusPaletteColor,
  31. farmTurbineLegendName,
  32. farmStatusSeriesName,
  33. pickFarmStatusLegend,
  34. ensureFarmNormalLegendSeries,
  35. usesFarmStatusLegend,
  36. YAW_ERROR_ABS_RANGES,
  37. FARM_STATUS_NORMAL_LEGEND,
  38. classifyYawErrorAbsRange,
  39. buildFarmStatusBarSeries,
  40. } from "../../../../shared/anomalyDiagnosticVisual.mjs";
  41. import {
  42. extractDetectorPanels,
  43. findPeakCoord,
  44. formatFarmSeriesHover,
  45. formatFarmValueHover,
  46. formatPointHover,
  47. resolvePanelSubtitle,
  48. isBubbleColorbar,
  49. usesBubbleSymbolSize,
  50. resolveRowColorMetric,
  51. sortScatterPairsByColorMetricAsc,
  52. isFarmPanelsJson,
  53. isValueBarPanel,
  54. isYawCountDetector,
  55. isYawErrorDetector,
  56. resolveTemplateKey,
  57. scaleBubbleSize,
  58. unwrapFarmPlotJson,
  59. isPointAnomalyFlag,
  60. readPointAnomalyFlags,
  61. splitConnectedLineByPointAnomaly,
  62. } from "../../../../shared/anomalyDetectorPlotAdapter.mjs";
  63. import {
  64. applyFarmPanelTitles,
  65. fillFarmPanelsToRule,
  66. getFarmPanelRule,
  67. selectFarmPanels,
  68. shouldExcludeFarmLabel,
  69. shouldKeepFullFarmLegend,
  70. applyZeroStartAxis,
  71. seriesAxisDataMin,
  72. } from "../../../../shared/anomalyFarmChartRules.mjs";
  73. const FORCE_SCATTER_TEMPLATE_KEYS = new Set([
  74. "wind_power_scatter",
  75. "pitch_coord",
  76. "ctrl_op_state",
  77. "ctrl_power_quality",
  78. "ctrl_deload",
  79. "yaw_static",
  80. "yaw_twist",
  81. "aero_tsr",
  82. "aero_cp",
  83. "aero_tsr_wind",
  84. ]);
  85. /** 以下检测器图表统一按散点图展示(含多 panel 轮播) */
  86. const SCATTER_ONLY_DETECTOR_KEYS = new Set(["anomalyPowerqualityPO"]);
  87. /** 变桨红蓝分段:蓝线共用「正常」,红线按异常机组列入图例 */
  88. function pitchSegmentSeriesName(row, anomaly) {
  89. if (!anomaly) return "正常";
  90. const name = String(farmTurbineLegendName(row) || "")
  91. .replace(/\s*桨叶\s*\d+\s*$/u, "")
  92. .trim();
  93. return name || "异常";
  94. }
  95. function pitchSegmentLegendData(series = []) {
  96. const anomalyNames = [];
  97. const seen = new Set();
  98. series.forEach((item) => {
  99. if (!item.__bladeSegment || item.name === "正常" || seen.has(item.name)) {
  100. return;
  101. }
  102. seen.add(item.name);
  103. anomalyNames.push(item.name);
  104. });
  105. anomalyNames.sort((a, b) => a.localeCompare(b, "zh-CN", { numeric: true }));
  106. const hasNormal = series.some(
  107. (item) => item.__bladeSegment && item.name === "正常",
  108. );
  109. return [...(hasNormal ? ["正常"] : []), ...anomalyNames];
  110. }
  111. function chartTheme() {
  112. return {
  113. axis: readCssVar("--anomaly-chart-axis-label", "#8da3c7"),
  114. split: readCssVar("--anomaly-chart-split", "rgba(98, 121, 186, 0.2)"),
  115. tooltipBg: readCssVar(
  116. "--anomaly-chart-tooltip-bg",
  117. "rgba(16, 30, 66, 0.95)",
  118. ),
  119. tooltipBorder: readCssVar(
  120. "--anomaly-chart-tooltip-border",
  121. "rgba(120, 156, 255, 0.4)",
  122. ),
  123. tooltipText: readCssVar("--anomaly-chart-tooltip-text", "#dbeafe"),
  124. legend: readCssVar("--anomaly-chart-legend", "#cde6ff"),
  125. title: readCssVar("--anomaly-title", "#e3f2ff"),
  126. dataZoomBg: readCssVar(
  127. "--anomaly-chart-datazoom-bg",
  128. "rgba(16, 21, 39, 0.85)",
  129. ),
  130. dataZoomBorder: readCssVar(
  131. "--anomaly-chart-datazoom-border",
  132. "rgba(101, 167, 201, 0.35)",
  133. ),
  134. dataZoomFiller: readCssVar(
  135. "--anomaly-chart-datazoom-filler",
  136. "rgba(50, 115, 138, 0.55)",
  137. ),
  138. };
  139. }
  140. function getTooltipBase() {
  141. return {
  142. appendToBody: true,
  143. confine: false,
  144. extraCssText: "z-index: 999999 !important; pointer-events: none;",
  145. };
  146. }
  147. export function extractPanels(plotJson, options = {}) {
  148. return extractDetectorPanels(plotJson, options);
  149. }
  150. function filterFarmPanelRows(panel, templateKey) {
  151. if (!panel || panel.chartKind === "valueBar") return panel;
  152. const data = (panel.data || []).filter(
  153. (row) =>
  154. !shouldExcludeFarmLabel(row.label || row.originalLabel, templateKey),
  155. );
  156. return { ...panel, data };
  157. }
  158. function emptyFarmPanelSlots(templateKey) {
  159. return (getFarmPanelRule(templateKey).titles || []).map((title) => ({
  160. title,
  161. xaxis: "",
  162. yaxis: "",
  163. data: [],
  164. }));
  165. }
  166. /** 页面总图按规则保留全部分面 Tab;JSON 缺哪面就留空,不再补模拟数据 */
  167. export function expandFarmPagePanels(plotJson, options = {}) {
  168. const json = unwrapFarmPlotJson(plotJson) || plotJson;
  169. const templateKey =
  170. options.templateKey || json?.detector || json?.__templateKey || "";
  171. const rule = getFarmPanelRule(templateKey);
  172. let panels = extractDetectorPanels(plotJson, options).map((panel) =>
  173. filterFarmPanelRows(panel, templateKey),
  174. );
  175. if (
  176. panels.length &&
  177. panels.every((panel) => panel?.chartKind === "valueBar")
  178. ) {
  179. return panels;
  180. }
  181. // chartType=panels:算法已按分面给全,按原顺序与原标题展示
  182. if (isFarmPanelsJson(json)) {
  183. panels =
  184. panels.length >= rule.count
  185. ? panels
  186. : fillFarmPanelsToRule(
  187. panels,
  188. emptyFarmPanelSlots(templateKey),
  189. templateKey,
  190. );
  191. } else {
  192. panels = selectFarmPanels(panels, templateKey);
  193. panels =
  194. rule.count <= 1
  195. ? applyFarmPanelTitles(panels, templateKey)
  196. : fillFarmPanelsToRule(
  197. panels,
  198. emptyFarmPanelSlots(templateKey),
  199. templateKey,
  200. );
  201. }
  202. return panels;
  203. }
  204. function isTimeAxis(xaxis, sampleX) {
  205. if (xaxis && String(xaxis).includes("时间")) return true;
  206. if (typeof sampleX === "string" && /\d{4}-\d{2}-\d{2}/.test(sampleX)) {
  207. return true;
  208. }
  209. const n = Number(sampleX);
  210. return Number.isFinite(n) && n > 1e12;
  211. }
  212. function toMs(v) {
  213. const n = Number(v);
  214. if (!Number.isFinite(n)) return v;
  215. return n > 1e15 ? Math.floor(n / 1e6) : n;
  216. }
  217. function formatTimeLabel(ms) {
  218. const d = new Date(ms);
  219. const pad = (n) => String(n).padStart(2, "0");
  220. return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
  221. d.getHours(),
  222. )}:${pad(d.getMinutes())}`;
  223. }
  224. function formatTimeAxisLabel(ms) {
  225. const d = new Date(ms);
  226. const pad = (n) => String(n).padStart(2, "0");
  227. return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}\n${pad(
  228. d.getHours(),
  229. )}:${pad(d.getMinutes())}`;
  230. }
  231. function pickSeriesColor(label, index) {
  232. // 页面「停机」点用可见色:科技蓝深底走浅灰,浅色主题仍用 shutdown 深色
  233. return diagnosticSeriesColor(label, index, {
  234. pageTheme: true,
  235. darkTheme: isTechBlueTheme(),
  236. });
  237. }
  238. function isDashedLine(label) {
  239. return diagnosticLineType(label) !== "solid";
  240. }
  241. function buildLineStyle(color, label) {
  242. return {
  243. width: 2,
  244. color,
  245. type: isDashedLine(label) ? "dashed" : "solid",
  246. };
  247. }
  248. function isCompositionPanel(panel) {
  249. if (panel.xaxis || panel.yaxis) return false;
  250. const rows = panel.data || [];
  251. if (!rows.length) return false;
  252. return rows.every(
  253. (row) =>
  254. Array.isArray(row.xData) &&
  255. row.xData.length === 1 &&
  256. Array.isArray(row.yData) &&
  257. row.yData.length === 1 &&
  258. Number(row.xData[0]) === Number(row.yData[0]),
  259. );
  260. }
  261. function buildCompositionOption(panel, theme) {
  262. const pieData = (panel.data || []).map((row, idx) => ({
  263. name: row.label,
  264. value: Number(row.xData?.[0]) || Number(row.yData?.[0]) || 0,
  265. itemStyle: { color: pickSeriesColor(row.label, idx) },
  266. }));
  267. return {
  268. backgroundColor: "transparent",
  269. tooltip: {
  270. ...getTooltipBase(),
  271. trigger: "item",
  272. backgroundColor: theme.tooltipBg,
  273. borderColor: theme.tooltipBorder,
  274. textStyle: { color: theme.tooltipText },
  275. },
  276. legend: {
  277. bottom: 6,
  278. left: "center",
  279. textStyle: { color: theme.legend, fontSize: 11 },
  280. },
  281. series: [
  282. {
  283. type: "pie",
  284. radius: ["38%", "62%"],
  285. center: ["50%", "52%"],
  286. data: pieData,
  287. label: {
  288. color: theme.legend,
  289. fontSize: 11,
  290. formatter: "{b}\n{d}%",
  291. },
  292. },
  293. ],
  294. };
  295. }
  296. function toAxisValue(v, useTime) {
  297. if (!useTime) return v;
  298. const n = Number(v);
  299. if (Number.isFinite(n)) return toMs(n);
  300. if (typeof v === "string" && v) {
  301. const parsed = Date.parse(v.replace(" ", "T"));
  302. if (Number.isFinite(parsed)) return parsed;
  303. }
  304. return v;
  305. }
  306. function looksLikeTimeArray(arr) {
  307. if (!Array.isArray(arr) || !arr.length) return false;
  308. return isTimeAxis(
  309. "",
  310. arr.find((item) => item != null && item !== ""),
  311. );
  312. }
  313. function sampleRowX(row) {
  314. const pools = [row?.timeData, row?.xData, row?.x];
  315. for (let i = 0; i < pools.length; i += 1) {
  316. const arr = pools[i];
  317. if (!Array.isArray(arr)) continue;
  318. const hit = arr.find((item) => item != null && item !== "");
  319. if (hit != null) return hit;
  320. }
  321. return undefined;
  322. }
  323. /** 与报告一致:时序优先 timeData/xData/x 中真正的时间列,否则读 xData、x */
  324. function getRowXData(row, useTime = false) {
  325. if (useTime) {
  326. if (looksLikeTimeArray(row?.timeData)) return row.timeData;
  327. if (looksLikeTimeArray(row?.xData)) return row.xData;
  328. if (looksLikeTimeArray(row?.x)) return row.x;
  329. }
  330. if (Array.isArray(row?.xData) && row.xData.length) return row.xData;
  331. if (Array.isArray(row?.x) && row.x.length) return row.x;
  332. if (Array.isArray(row?.timeData) && row.timeData.length) return row.timeData;
  333. return [];
  334. }
  335. function getRowYData(row) {
  336. if (Array.isArray(row?.yData) && row.yData.length) return row.yData;
  337. if (Array.isArray(row?.y) && row.y.length) return row.y;
  338. return [];
  339. }
  340. function toYNumber(v) {
  341. if (v == null || v === "") return null;
  342. const n = Number(v);
  343. return Number.isFinite(n) ? n : null;
  344. }
  345. function hoverExtra(row) {
  346. const extra = row?.extra;
  347. const model = row?.model;
  348. const extraHasModel =
  349. extra &&
  350. typeof extra === "object" &&
  351. extra.model != null &&
  352. extra.model !== "";
  353. if (extraHasModel || model == null || model === "") return extra || null;
  354. if (extra && typeof extra === "object") return { ...extra, model };
  355. return { model };
  356. }
  357. function buildScatterPairs(row, useTime) {
  358. const xArr = getRowXData(row, useTime);
  359. const yArr = getRowYData(row);
  360. const colorMetric = resolveRowColorMetric(
  361. row,
  362. Math.min(xArr.length, yArr.length),
  363. );
  364. const points = Array.isArray(row.pointData) ? row.pointData : [];
  365. const len = Math.min(xArr.length, yArr.length);
  366. const extra = hoverExtra(row);
  367. const pairs = [];
  368. for (let i = 0; i < len; i += 1) {
  369. const y = toYNumber(yArr[i]);
  370. if (y == null) continue;
  371. const value = [toAxisValue(xArr[i], useTime), y];
  372. if (colorMetric.length === len) value.push(colorMetric[i]);
  373. pairs.push({
  374. value,
  375. point: points[i] || null,
  376. extra,
  377. });
  378. }
  379. return pairs;
  380. }
  381. function finiteExtent(values = []) {
  382. let min = Infinity;
  383. let max = -Infinity;
  384. for (let i = 0; i < values.length; i += 1) {
  385. const value = Number(values[i]);
  386. if (!Number.isFinite(value)) continue;
  387. if (value < min) min = value;
  388. if (value > max) max = value;
  389. }
  390. return { min, max };
  391. }
  392. function collectFlaggedAnomalyPairs(row, useTime) {
  393. const flags = readPointAnomalyFlags(row);
  394. if (!Array.isArray(flags) || !flags.length) return [];
  395. if (classifyDiagnosticSeries(row?.label) === "anomaly") return [];
  396. const xArr = getRowXData(row, useTime);
  397. const yArr = getRowYData(row);
  398. const points = Array.isArray(row.pointData) ? row.pointData : [];
  399. const len = Math.min(xArr.length, yArr.length);
  400. const pairs = [];
  401. for (let i = 0; i < len; i += 1) {
  402. if (!isPointAnomalyFlag(flags[i])) continue;
  403. const y = toYNumber(yArr[i]);
  404. if (y == null) continue;
  405. pairs.push({
  406. value: [toAxisValue(xArr[i], useTime), y],
  407. point: points[i] || null,
  408. extra: row.extra || null,
  409. });
  410. }
  411. return pairs;
  412. }
  413. /** 图例已有「异常点」系列但坐标没读到时,用同图 pointAnomaly 把红点补上 */
  414. function fillEmptyAnomalySeries(series, rows, useTime) {
  415. const empty = (series || []).filter(
  416. (item) =>
  417. classifyDiagnosticSeries(item.name) === "anomaly" &&
  418. !(item.data || []).length,
  419. );
  420. if (!empty.length) return;
  421. const pairs = [];
  422. (rows || []).forEach((row) => {
  423. pairs.push(...collectFlaggedAnomalyPairs(row, useTime));
  424. });
  425. if (!pairs.length) return;
  426. empty.forEach((item) => {
  427. item.data = pairs;
  428. item.type = "scatter";
  429. item.symbol = "circle";
  430. item.symbolSize = item.symbolSize || 6;
  431. item.showSymbol = true;
  432. item.z = diagnosticSingleSeriesZ({ label: item.name });
  433. item.z2 = 2;
  434. item.zlevel = 0;
  435. item.itemStyle = {
  436. ...(item.itemStyle || {}),
  437. color: DIAGNOSTIC_COLORS.anomaly,
  438. opacity: 1,
  439. };
  440. });
  441. }
  442. function buildSeries(panel, theme, options = {}) {
  443. const farmMode = Boolean(options.farmMode);
  444. const templateKey =
  445. resolveTemplateKey(options) || resolveWindPowerTemplateKey(options);
  446. const forceScatter =
  447. SCATTER_ONLY_DETECTOR_KEYS.has(options.detectorKey) ||
  448. options.detectorKey === "anomalyScatterPO" ||
  449. (farmMode && FORCE_SCATTER_TEMPLATE_KEYS.has(templateKey));
  450. const firstRow = panel.data?.[0];
  451. const sampleX = sampleRowX(firstRow);
  452. const hasTimeData = (panel.data || []).some(
  453. (row) => Array.isArray(row.timeData) && row.timeData.length,
  454. );
  455. const axisSaysTime = String(panel.xaxis || "").includes("时间");
  456. const useTime =
  457. hasTimeData || axisSaysTime || isTimeAxis(panel.xaxis, sampleX);
  458. const series = [];
  459. const visualMaps = [];
  460. const densePitchCoord = farmMode && templateKey === "pitch_coord";
  461. const statusLegendPick =
  462. farmMode && usesFarmStatusLegend(templateKey)
  463. ? pickFarmStatusLegend(panel.data, templateKey)
  464. : null;
  465. (panel.data || []).forEach((row, idx) => {
  466. const rawMode = row.mode || "lines";
  467. const overlayCurve = isOverlayCurveSeries(row);
  468. let mode =
  469. forceScatter && !row.__shared && !overlayCurve ? "markers" : rawMode;
  470. if (
  471. farmMode &&
  472. templateKey === "pitch_regulation" &&
  473. !row.__shared &&
  474. !overlayCurve
  475. ) {
  476. mode = "lines+markers";
  477. }
  478. if (row.__shared || overlayCurve) mode = "lines";
  479. if (
  480. /累计降载|频繁阈值|正常上限/.test(
  481. String(row.originalLabel || row.label || ""),
  482. )
  483. ) {
  484. mode = "lines";
  485. }
  486. if (
  487. !farmMode &&
  488. !row.__shared &&
  489. !overlayCurve &&
  490. classifyDiagnosticSeries(row) === "anomaly"
  491. ) {
  492. mode = "markers";
  493. }
  494. const color = farmMode
  495. ? diagnosticFarmSeriesColor(row, idx, templateKey)
  496. : pickSeriesColor(row.label, idx);
  497. const seriesName = statusLegendPick
  498. ? farmStatusSeriesName(row, statusLegendPick.mode, templateKey)
  499. : row.label;
  500. const diagnosticRole = classifyDiagnosticSeries(row);
  501. const farmStyle = diagnosticFarmStyle(row, templateKey);
  502. const farmAnomaly = farmMode && diagnosticRole === "anomaly";
  503. const isMarker = mode.includes("markers");
  504. const isLine = mode.includes("lines");
  505. const pairs = buildScatterPairs(row, useTime);
  506. const pointCount = pairs.length;
  507. const sourcePointCount = Math.min(
  508. getRowXData(row, useTime).length,
  509. getRowYData(row).length,
  510. );
  511. const colorMetric = resolveRowColorMetric(row, sourcePointCount);
  512. const colorExtent = finiteExtent(colorMetric);
  513. const ignoreColorbar = Boolean(statusLegendPick);
  514. // 正常系列 x/y 都为空时,色值个数和点数都是 0,不能当成色带。
  515. // 否则 visualMap 的 min/max 会变成 Infinity / -Infinity,画布渐变报 addColorStop。
  516. const hasColorbar =
  517. !ignoreColorbar &&
  518. sourcePointCount > 0 &&
  519. Array.isArray(colorMetric) &&
  520. colorMetric.length === sourcePointCount &&
  521. Number.isFinite(colorExtent.min) &&
  522. Number.isFinite(colorExtent.max) &&
  523. colorExtent.max > colorExtent.min;
  524. const useBubble = hasColorbar && isBubbleColorbar(row, templateKey);
  525. const useBubbleSize = hasColorbar && usesBubbleSymbolSize(row, templateKey);
  526. const scatterPairs = useBubble
  527. ? sortScatterPairsByColorMetricAsc(pairs)
  528. : pairs;
  529. const bladeStyle = farmMode
  530. ? diagnosticBladeStyle(row.originalLabel)
  531. : null;
  532. const symbol = "circle";
  533. const peak = isYawErrorDetector(templateKey)
  534. ? findPeakCoord(getRowXData(row, useTime), getRowYData(row))
  535. : null;
  536. const markPoint =
  537. peak && !farmMode
  538. ? {
  539. symbol: "diamond",
  540. symbolSize: 10,
  541. itemStyle: { color: "#ff9f43" },
  542. label: {
  543. formatter: `${peak.x}°`,
  544. color: theme.legend,
  545. fontSize: 10,
  546. },
  547. data: [
  548. {
  549. name: "最大功率",
  550. coord: [toAxisValue(peak.x, useTime), peak.y],
  551. },
  552. ],
  553. }
  554. : undefined;
  555. if (hasColorbar) {
  556. const { min: cbMin, max: cbMax } = colorExtent;
  557. const seriesIndex = series.length;
  558. const useColorbar = !farmAnomaly && diagnosticRole !== "anomaly";
  559. series.push({
  560. name: seriesName,
  561. type: "scatter",
  562. data: scatterPairs,
  563. symbol: "circle",
  564. symbolSize: useBubbleSize
  565. ? (item) =>
  566. scaleBubbleSize(
  567. Array.isArray(item.value) ? item.value[2] : item[2],
  568. cbMin,
  569. cbMax,
  570. )
  571. : farmMode
  572. ? diagnosticFarmSymbolSize(row, pointCount, templateKey)
  573. : templateKey === "pitch_min"
  574. ? 8
  575. : pointCount > 5000
  576. ? 3
  577. : 6,
  578. large: false,
  579. progressive: 0,
  580. animation: false,
  581. hoverAnimation: false,
  582. emphasis: { scale: false },
  583. clip: true,
  584. itemStyle: useColorbar
  585. ? {
  586. opacity:
  587. templateKey === "pitch_min" ? 0.92 : densePitchCoord ? 0.72 : 1,
  588. }
  589. : {
  590. color: DIAGNOSTIC_COLORS.anomaly,
  591. opacity: farmMode ? farmStyle.pointOpacity : 1,
  592. borderColor: "#ffffff",
  593. borderWidth: 1,
  594. },
  595. __farmExtra: row.extra || null,
  596. ...(farmMode
  597. ? farmSeriesPaintOrder(row, templateKey)
  598. : { z: diagnosticSingleSeriesZ(row), z2: 0, zlevel: 0 }),
  599. });
  600. if (useColorbar) {
  601. const sharedPitchColorbar = farmMode && templateKey === "pitch_coord";
  602. const existingVisualMap = sharedPitchColorbar ? visualMaps[0] : null;
  603. if (existingVisualMap) {
  604. existingVisualMap.min = Math.min(existingVisualMap.min, cbMin);
  605. existingVisualMap.max = Math.max(existingVisualMap.max, cbMax);
  606. existingVisualMap.seriesIndex.push(seriesIndex);
  607. } else {
  608. visualMaps.push({
  609. show: true,
  610. dimension: 2,
  611. min: cbMin,
  612. max: cbMax,
  613. calculable: false,
  614. orient: "vertical",
  615. right: 6,
  616. top: "middle",
  617. text: [
  618. row.colorbarTitle ||
  619. row.colorbartitle ||
  620. (useBubble ? "点数" : "桨距角 (°)"),
  621. ],
  622. textStyle: { color: theme.legend, fontSize: 10 },
  623. inRange: {
  624. // 页面点数:暗→亮蓝(点数越高越醒目);报告色带另用 DIAGNOSTIC_COUNT_COLORS
  625. color: useBubble
  626. ? PAGE_DIAGNOSTIC_COUNT_COLORS
  627. : DIAGNOSTIC_CONTINUOUS_COLORS,
  628. },
  629. seriesIndex: sharedPitchColorbar ? [seriesIndex] : seriesIndex,
  630. });
  631. }
  632. }
  633. return;
  634. }
  635. if (isMarker && !isLine) {
  636. series.push({
  637. name: seriesName,
  638. type: "scatter",
  639. data: pairs,
  640. symbol,
  641. symbolSize: farmMode
  642. ? diagnosticFarmSymbolSize(row, pointCount, templateKey)
  643. : pointCount > 5000
  644. ? 3
  645. : 6,
  646. large: false,
  647. progressive: 0,
  648. animation: false,
  649. hoverAnimation: false,
  650. emphasis: { scale: false },
  651. clip: true,
  652. itemStyle: {
  653. color,
  654. opacity: farmMode
  655. ? farmStyle.pointOpacity
  656. : diagnosticRole === "anomaly" || diagnosticRole === "deload"
  657. ? 1
  658. : 0.82,
  659. },
  660. __farmExtra: row.extra || null,
  661. ...(farmMode
  662. ? farmSeriesPaintOrder(row, templateKey)
  663. : { z: diagnosticSingleSeriesZ(row), z2: 0, zlevel: 0 }),
  664. });
  665. return;
  666. }
  667. const farmPitchBlade = farmMode && templateKey === "pitch_regulation";
  668. const lineType = bladeStyle ? bladeStyle.lineType : diagnosticLineType(row);
  669. if (isLine && !isMarker) {
  670. const pitchRangeSinglePoint =
  671. templateKey === "pitch_regulation" &&
  672. /极差/.test(`${panel.title || ""} ${panel.yaxis || ""}`) &&
  673. pairs.length === 1;
  674. const deloadGuide = isDeloadRateGuideLabel(row);
  675. let lineData = pairs;
  676. if (deloadGuide && isDeloadThresholdLabel(row)) {
  677. const axisX = [];
  678. (panel.data || []).forEach((item) => {
  679. getRowXData(item, useTime).forEach((x) => {
  680. axisX.push(toAxisValue(x, useTime));
  681. });
  682. });
  683. const spanned = spanThresholdLinePairs(pairs, axisX);
  684. if (spanned) lineData = spanned;
  685. }
  686. // 只有一个时刻时折线没有线段。累计降载率和阈值都改成画点。
  687. const showLonePoint =
  688. (pitchRangeSinglePoint || deloadGuide) &&
  689. distinctLineXCount(lineData) <= 1;
  690. if (showLonePoint && lineData.length > 1) {
  691. lineData = [lineData[0]];
  692. }
  693. series.push({
  694. name: seriesName,
  695. type: "line",
  696. data: lineData,
  697. showSymbol: showLonePoint,
  698. symbol: showLonePoint ? "circle" : "none",
  699. symbolSize: showLonePoint ? 10 : 0,
  700. smooth: false,
  701. clip: showLonePoint ? false : true,
  702. lineStyle: {
  703. ...buildLineStyle(color, row.label),
  704. type: lineType,
  705. width: farmMode
  706. ? farmStyle.lineWidth
  707. : diagnosticRole === "contract"
  708. ? 3.8
  709. : row.__shared
  710. ? 2.5
  711. : 2,
  712. opacity: farmMode ? farmStyle.lineOpacity : 1,
  713. },
  714. itemStyle: { color },
  715. ...(farmMode
  716. ? farmSeriesPaintOrder(row, templateKey)
  717. : { z: diagnosticSingleSeriesZ(row), z2: 0, zlevel: 0 }),
  718. });
  719. return;
  720. }
  721. if (farmPitchBlade) {
  722. const flags = readPointAnomalyFlags(row);
  723. const segments = splitConnectedLineByPointAnomaly(
  724. getRowXData(row, useTime),
  725. getRowYData(row),
  726. flags,
  727. );
  728. if (Array.isArray(flags) && flags.length && segments.length) {
  729. const symbol = diagnosticSeriesSymbol(
  730. row,
  731. bladeStyle ? bladeStyle.symbol : "circle",
  732. );
  733. segments.forEach((segment) => {
  734. const data = [];
  735. segment.xData.forEach((xValue, index) => {
  736. const y = toYNumber(segment.yData[index]);
  737. if (y == null) return;
  738. data.push({
  739. value: [toAxisValue(xValue, useTime), y],
  740. extra: row.extra || null,
  741. });
  742. });
  743. if (!data.length) return;
  744. const segmentColor = segment.anomaly
  745. ? DIAGNOSTIC_COLORS.anomaly
  746. : DIAGNOSTIC_COLORS.normal;
  747. const singlePoint = data.length === 1;
  748. series.push({
  749. name: pitchSegmentSeriesName(row, segment.anomaly),
  750. type: "line",
  751. data,
  752. clip: true,
  753. showSymbol: true,
  754. symbol,
  755. symbolSize: singlePoint ? 10 : 8,
  756. smooth: false,
  757. lineStyle: {
  758. color: segmentColor,
  759. type: lineType,
  760. width: farmStyle.lineWidth,
  761. opacity: 1,
  762. },
  763. itemStyle: { color: segmentColor, opacity: 1 },
  764. __farmExtra: row.extra || null,
  765. __bladeSegment: true,
  766. z: segment.anomaly ? 20 : 2,
  767. z2: segment.anomaly ? 20 : 0,
  768. zlevel: 0,
  769. });
  770. });
  771. return;
  772. }
  773. }
  774. series.push({
  775. name: seriesName,
  776. type: "line",
  777. data: pairs,
  778. clip: true,
  779. showSymbol:
  780. (templateKey === "pitch_regulation" &&
  781. /极差/.test(`${panel.title || ""} ${panel.yaxis || ""}`) &&
  782. pairs.length === 1) ||
  783. Boolean(bladeStyle?.showSymbol) ||
  784. isMarker,
  785. symbol: diagnosticSeriesSymbol(
  786. row,
  787. bladeStyle ? bladeStyle.symbol : "circle",
  788. ),
  789. symbolSize:
  790. templateKey === "pitch_regulation" &&
  791. /极差/.test(`${panel.title || ""} ${panel.yaxis || ""}`) &&
  792. pairs.length === 1
  793. ? 10
  794. : bladeStyle
  795. ? 8
  796. : farmMode
  797. ? diagnosticFarmSymbolSize(row, pointCount, templateKey)
  798. : 4,
  799. smooth: false,
  800. markPoint,
  801. lineStyle: {
  802. ...buildLineStyle(color, row.label),
  803. type: lineType,
  804. width: farmMode ? farmStyle.lineWidth : 2,
  805. opacity: farmMode ? farmStyle.lineOpacity : 1,
  806. },
  807. itemStyle: {
  808. color,
  809. opacity: farmMode ? farmStyle.pointOpacity : 0.88,
  810. },
  811. ...(farmMode
  812. ? farmSeriesPaintOrder(row, templateKey)
  813. : { z: diagnosticSingleSeriesZ(row), z2: 0, zlevel: 0 }),
  814. });
  815. });
  816. if (!farmMode) {
  817. fillEmptyAnomalySeries(series, panel.data, useTime);
  818. }
  819. if (!visualMaps.length) {
  820. series.sort(
  821. (a, b) =>
  822. (Number(a.z) || 0) * 100 +
  823. (Number(a.z2) || 0) -
  824. ((Number(b.z) || 0) * 100 + (Number(b.z2) || 0)),
  825. );
  826. }
  827. return {
  828. series: ensureFarmNormalLegendSeries(series, statusLegendPick),
  829. useTime,
  830. visualMaps,
  831. optimizedDenseScatter: false,
  832. };
  833. }
  834. /** 检测器折线/散点图:区域圈选、滚轮缩放、拖拽平移、还原 */
  835. function buildPlotInteractionOptions(
  836. theme,
  837. { hasVisualMap, useTime, hasLegend } = {},
  838. ) {
  839. const sliderBottom = hasLegend ? 32 : 8;
  840. return {
  841. toolbox: {
  842. right: hasVisualMap ? 76 : 8,
  843. top: 4,
  844. itemSize: 14,
  845. itemGap: 8,
  846. showTitle: true,
  847. iconStyle: { borderColor: theme.legend },
  848. emphasis: { iconStyle: { borderColor: theme.title } },
  849. tooltip: {
  850. show: true,
  851. backgroundColor: theme.tooltipBg,
  852. borderColor: theme.tooltipBorder,
  853. textStyle: { color: theme.tooltipText, fontSize: 11 },
  854. },
  855. feature: {
  856. dataZoom: {
  857. xAxisIndex: 0,
  858. yAxisIndex: 0,
  859. title: { zoom: "区域缩放", back: "缩放还原" },
  860. },
  861. restore: { title: "还原" },
  862. saveAsImage: {
  863. title: "保存图片",
  864. pixelRatio: 2,
  865. backgroundColor: "transparent",
  866. },
  867. },
  868. },
  869. dataZoom: [
  870. {
  871. type: "inside",
  872. xAxisIndex: 0,
  873. filterMode: "none",
  874. zoomOnMouseWheel: true,
  875. moveOnMouseMove: true,
  876. preventDefaultMouseMove: true,
  877. },
  878. {
  879. type: "inside",
  880. yAxisIndex: 0,
  881. filterMode: "none",
  882. zoomOnMouseWheel: "shift",
  883. moveOnMouseMove: true,
  884. preventDefaultMouseMove: true,
  885. },
  886. ...(useTime
  887. ? [
  888. {
  889. type: "slider",
  890. xAxisIndex: 0,
  891. filterMode: "none",
  892. height: 16,
  893. bottom: sliderBottom,
  894. borderColor: theme.dataZoomBorder,
  895. backgroundColor: theme.dataZoomBg,
  896. fillerColor: theme.dataZoomFiller,
  897. handleSize: "80%",
  898. showDetail: false,
  899. brushSelect: false,
  900. },
  901. ]
  902. : []),
  903. ],
  904. };
  905. }
  906. const PO_TO_WIND_POWER_3D_TEMPLATE = {
  907. anomalyPowercurvePO: "wind_power_curve",
  908. anomalyScatterPO: "wind_power_scatter",
  909. };
  910. function resolveWindPowerTemplateKey(options = {}) {
  911. if (options.templateKey) return options.templateKey;
  912. const key = options.detectorKey || options.poKey || "";
  913. return PO_TO_WIND_POWER_3D_TEMPLATE[key] || "";
  914. }
  915. function buildWindPower3DOption(panel, options = {}) {
  916. const theme = chartTheme();
  917. const farmMode = Boolean(options.farmMode);
  918. const templateKey = resolveWindPowerTemplateKey(options);
  919. return buildWindPower3DPanelOption(panel, {
  920. ...options,
  921. templateKey,
  922. farmMode,
  923. theme,
  924. reportMode: false,
  925. pickColor: (label, index, row) =>
  926. farmMode
  927. ? diagnosticFarmSeriesColor(row, index, templateKey)
  928. : pickSeriesColor(label, index),
  929. });
  930. }
  931. function seriesAxisExtent(series = []) {
  932. let xMin = Infinity;
  933. let xMax = -Infinity;
  934. let yMin = Infinity;
  935. let yMax = -Infinity;
  936. let found = false;
  937. (series || []).forEach((item) => {
  938. (item.data || []).forEach((point) => {
  939. const coord = Array.isArray(point)
  940. ? point
  941. : Array.isArray(point?.value)
  942. ? point.value
  943. : null;
  944. if (!coord) return;
  945. const x = Number(coord[0]);
  946. const y = Number(coord[1]);
  947. if (Number.isFinite(x)) {
  948. if (x < xMin) xMin = x;
  949. if (x > xMax) xMax = x;
  950. found = true;
  951. }
  952. if (Number.isFinite(y)) {
  953. if (y < yMin) yMin = y;
  954. if (y > yMax) yMax = y;
  955. found = true;
  956. }
  957. });
  958. });
  959. if (!found) return null;
  960. return {
  961. xMin: Number.isFinite(xMin) ? xMin : null,
  962. xMax: Number.isFinite(xMax) ? xMax : null,
  963. yMin: Number.isFinite(yMin) ? yMin : null,
  964. yMax: Number.isFinite(yMax) ? yMax : null,
  965. };
  966. }
  967. function pickValueCoord(item) {
  968. if (Array.isArray(item?.value)) return item.value;
  969. if (Array.isArray(item?.data?.value)) return item.data.value;
  970. if (Array.isArray(item?.data)) return item.data;
  971. return [item?.value];
  972. }
  973. function buildValueBarOption(panel, theme) {
  974. const categories = panel.categories || [];
  975. const detector = panel.detectorName || "";
  976. const colorMode = panel.colorMode || "";
  977. const showMean =
  978. panel.showMean !== false && Number.isFinite(Number(panel.mean));
  979. const isCountChart = colorMode === "yawErrorAbsCount";
  980. const isAbsRangeChart = colorMode === "yawErrorAbs";
  981. const isFarmStatusChart = colorMode === "farmStatus";
  982. const compactAxis = categories.length <= 6;
  983. const farmStatusBars = isFarmStatusChart
  984. ? buildFarmStatusBarSeries({
  985. categories,
  986. values: panel.values || [],
  987. extras: panel.extras || [],
  988. anomalyFlags: panel.anomalyFlags || [],
  989. })
  990. : null;
  991. const legendNames = isAbsRangeChart
  992. ? YAW_ERROR_ABS_RANGES.map((item) => item.label)
  993. : farmStatusBars
  994. ? farmStatusBars.legendNames
  995. : showMean
  996. ? ["风场均值"]
  997. : [];
  998. const data =
  999. isAbsRangeChart || isFarmStatusChart
  1000. ? null
  1001. : categories.map((name, idx) => {
  1002. const anomaly = Boolean(panel.anomalyFlags?.[idx]);
  1003. const value = panel.values?.[idx];
  1004. return {
  1005. name,
  1006. value,
  1007. extra: panel.extras?.[idx],
  1008. itemStyle: {
  1009. color: isCountChart
  1010. ? panel.colors?.[idx] || yawErrorAbsRangeColorByIndex(idx)
  1011. : farmStatusPaletteColor(
  1012. { extra: { anomaly }, turbineIndex: idx },
  1013. idx,
  1014. ),
  1015. },
  1016. };
  1017. });
  1018. const absRangeSeries = isAbsRangeChart
  1019. ? YAW_ERROR_ABS_RANGES.map((range) => ({
  1020. type: "bar",
  1021. name: range.label,
  1022. color: range.color,
  1023. itemStyle: { color: range.color },
  1024. data: categories.map((name, idx) => {
  1025. const value = panel.values?.[idx];
  1026. const hit = classifyYawErrorAbsRange(value);
  1027. if (!hit || hit.key !== range.key) return null;
  1028. return {
  1029. name,
  1030. value,
  1031. extra: panel.extras?.[idx],
  1032. itemStyle: { color: range.color },
  1033. };
  1034. }),
  1035. barMaxWidth: 28,
  1036. barGap: "-100%",
  1037. }))
  1038. : [];
  1039. const barSeries = isAbsRangeChart
  1040. ? absRangeSeries
  1041. : isFarmStatusChart
  1042. ? farmStatusBars.series
  1043. : [
  1044. {
  1045. type: "bar",
  1046. name: panel.yaxis || "检测值",
  1047. data,
  1048. barMaxWidth: 28,
  1049. label: isCountChart
  1050. ? {
  1051. show: true,
  1052. position: "top",
  1053. color: theme.axis,
  1054. fontSize: 11,
  1055. }
  1056. : undefined,
  1057. },
  1058. ];
  1059. const legendData = legendNames.map((name) => {
  1060. const range = isAbsRangeChart
  1061. ? YAW_ERROR_ABS_RANGES.find((item) => item.label === name)
  1062. : null;
  1063. if (range) {
  1064. return { name, itemStyle: { color: range.color } };
  1065. }
  1066. if (isFarmStatusChart && name === FARM_STATUS_NORMAL_LEGEND) {
  1067. return { name, itemStyle: { color: DIAGNOSTIC_COLORS.normal } };
  1068. }
  1069. if (isFarmStatusChart) {
  1070. return { name, itemStyle: { color: DIAGNOSTIC_COLORS.anomaly } };
  1071. }
  1072. return name;
  1073. });
  1074. return {
  1075. backgroundColor: "transparent",
  1076. color: isAbsRangeChart
  1077. ? YAW_ERROR_ABS_RANGES.map((range) => range.color)
  1078. : undefined,
  1079. ...buildPlotInteractionOptions(theme, {
  1080. hasVisualMap: false,
  1081. useTime: false,
  1082. hasLegend: legendNames.length > 0,
  1083. }),
  1084. tooltip: {
  1085. ...getTooltipBase(),
  1086. trigger: isAbsRangeChart || isFarmStatusChart ? "axis" : "item",
  1087. backgroundColor: theme.tooltipBg,
  1088. borderColor: theme.tooltipBorder,
  1089. textStyle: { color: theme.tooltipText },
  1090. formatter(params) {
  1091. const list = Array.isArray(params) ? params : [params];
  1092. const hit = list.find((item) => item && item.value != null) || list[0];
  1093. if (!hit) return "";
  1094. const extra = hit?.data?.extra || {};
  1095. const hover = formatFarmValueHover(extra, detector);
  1096. const unit = isYawCountDetector(detector) ? "次" : "";
  1097. return `${hit.marker || ""}${hit.name}<br/>${panel.yaxis || "数值"}: ${
  1098. hit.value
  1099. }${unit}${hover ? `<br/>${hover}` : ""}`;
  1100. },
  1101. },
  1102. legend: legendNames.length
  1103. ? {
  1104. type: legendNames.length > 10 ? "scroll" : "plain",
  1105. bottom: 4,
  1106. left: "center",
  1107. textStyle: { color: theme.legend, fontSize: 10 },
  1108. data: legendData,
  1109. }
  1110. : undefined,
  1111. grid: {
  1112. top: 36,
  1113. left: 52,
  1114. right: 24,
  1115. bottom: legendNames.length ? 56 : 40,
  1116. containLabel: true,
  1117. },
  1118. xAxis: {
  1119. type: "category",
  1120. data: categories,
  1121. name: panel.xaxis || "风机",
  1122. nameLocation: "middle",
  1123. nameGap: compactAxis ? 24 : 28,
  1124. nameTextStyle: { color: theme.axis, fontSize: 10 },
  1125. axisLabel: {
  1126. color: theme.axis,
  1127. fontSize: 10,
  1128. hideOverlap: true,
  1129. rotate: compactAxis ? 0 : 30,
  1130. },
  1131. axisLine: { lineStyle: { color: theme.split } },
  1132. },
  1133. yAxis: {
  1134. type: "value",
  1135. name: panel.yaxis || "",
  1136. nameLocation: "middle",
  1137. nameGap: 42,
  1138. minInterval: isCountChart ? 1 : undefined,
  1139. nameTextStyle: { color: theme.axis, fontSize: 10 },
  1140. axisLabel: {
  1141. color: theme.axis,
  1142. fontSize: 10,
  1143. formatter: isYawCountDetector(detector)
  1144. ? (value) => `${value}次`
  1145. : undefined,
  1146. },
  1147. splitLine: { lineStyle: { color: theme.split, type: "dashed" } },
  1148. },
  1149. series: [
  1150. ...barSeries,
  1151. ...(showMean
  1152. ? [
  1153. {
  1154. type: "line",
  1155. name: "风场均值",
  1156. data: categories.map(() => panel.mean),
  1157. symbol: "none",
  1158. lineStyle: { type: "dashed", width: 2, color: "#f2c84e" },
  1159. },
  1160. ]
  1161. : []),
  1162. ],
  1163. };
  1164. }
  1165. function yawErrorAbsRangeColorByIndex(idx) {
  1166. return YAW_ERROR_ABS_RANGES[idx]?.color || YAW_ERROR_ABS_RANGES[0].color;
  1167. }
  1168. export function buildPanelChartOption(panel, options = {}) {
  1169. const farmMode = Boolean(options.farmMode);
  1170. const templateKey =
  1171. resolveTemplateKey(options, panel?.detectorName) ||
  1172. resolveWindPowerTemplateKey(options);
  1173. if (shouldUseWindPower3D(templateKey, farmMode)) {
  1174. return buildWindPower3DOption(panel, { ...options, templateKey, farmMode });
  1175. }
  1176. const theme = chartTheme();
  1177. if (isValueBarPanel(panel)) {
  1178. return buildValueBarOption(panel, theme);
  1179. }
  1180. if (isCompositionPanel(panel)) {
  1181. return buildCompositionOption(panel, theme);
  1182. }
  1183. const { series, useTime, visualMaps, optimizedDenseScatter } = buildSeries(
  1184. panel,
  1185. theme,
  1186. {
  1187. ...options,
  1188. templateKey,
  1189. },
  1190. );
  1191. const anomalyLegendData = series
  1192. .filter((item) => classifyDiagnosticSeries(item.name) === "anomaly")
  1193. .map((item) => item.name);
  1194. const compactFarmDiagnostic =
  1195. farmMode &&
  1196. series.length > 12 &&
  1197. anomalyLegendData.length > 0 &&
  1198. !shouldKeepFullFarmLegend(templateKey);
  1199. const denseFarmChart =
  1200. Boolean(optimizedDenseScatter) || (farmMode && series.length > 12);
  1201. const statusLegendPick =
  1202. farmMode && usesFarmStatusLegend(templateKey)
  1203. ? pickFarmStatusLegend(panel.data, templateKey)
  1204. : null;
  1205. const bladeSegmentLegend = series.some((item) => item.__bladeSegment);
  1206. const legendData = bladeSegmentLegend
  1207. ? pitchSegmentLegendData(series)
  1208. : statusLegendPick
  1209. ? statusLegendPick.names
  1210. : compactFarmDiagnostic
  1211. ? anomalyLegendData
  1212. : series.map((item) => item.name);
  1213. const hasLegend = statusLegendPick
  1214. ? legendData.length > 0
  1215. : legendData.length > 0 && series.length > 1;
  1216. const xName = panel.xaxis || "";
  1217. const yName = panel.yaxis || "";
  1218. const hasVisualMap = visualMaps.length > 0;
  1219. const timeAxisBottom = hasLegend ? 82 : 66;
  1220. const sliderExtraBottom = useTime ? (hasLegend ? 28 : 22) : 0;
  1221. const panelSubtitle = resolvePanelSubtitle(panel);
  1222. const xAxis = {
  1223. type: useTime ? "time" : "value",
  1224. name: xName,
  1225. nameLocation: "middle",
  1226. nameGap: useTime ? (hasLegend ? 48 : 40) : hasLegend ? 36 : 28,
  1227. nameTextStyle: { color: theme.axis, fontSize: 10 },
  1228. ...(useTime ? {} : { scale: true }),
  1229. axisLabel: {
  1230. color: theme.axis,
  1231. fontSize: 10,
  1232. hideOverlap: true,
  1233. margin: useTime ? 10 : 8,
  1234. formatter: useTime ? (v) => formatTimeAxisLabel(v) : undefined,
  1235. },
  1236. axisLine: { lineStyle: { color: theme.split } },
  1237. splitLine: { lineStyle: { color: theme.split, type: "dashed" } },
  1238. };
  1239. const yAxis = {
  1240. type: "value",
  1241. name: yName,
  1242. nameLocation: "middle",
  1243. nameGap: 42,
  1244. nameTextStyle: { color: theme.axis, fontSize: 10 },
  1245. scale: true,
  1246. axisLabel: {
  1247. color: theme.axis,
  1248. fontSize: 10,
  1249. formatter: isYawCountDetector(templateKey)
  1250. ? (value) => `${value}次`
  1251. : undefined,
  1252. },
  1253. splitLine: { lineStyle: { color: theme.split, type: "dashed" } },
  1254. axisLine: { lineStyle: { color: theme.split } },
  1255. ...(isYawCountDetector(templateKey) ? { minInterval: 1 } : {}),
  1256. };
  1257. const pitchCoord = (templateKey || panel.detectorName) === "pitch_coord";
  1258. const xRangeLocked = pitchCoord
  1259. ? applyPitchCoordXAxis(xAxis, panel.xrange)
  1260. : false;
  1261. const yRangeLocked = pitchCoord
  1262. ? lockAxisToRange(yAxis, panel.yrange)
  1263. : false;
  1264. if (farmMode && templateKey === "aero_cp" && !useTime) {
  1265. const extent = seriesAxisDataMin(series);
  1266. applyZeroStartAxis(xAxis, extent.xMin);
  1267. applyZeroStartAxis(yAxis, extent.yMin);
  1268. }
  1269. const plottedExtent = seriesAxisExtent(series);
  1270. if (plottedExtent && !pitchCoord) {
  1271. const xFitsAxis = !useTime || Number(plottedExtent.xMin) > 1e11;
  1272. if (xFitsAxis) {
  1273. expandAxisToExtent(xAxis, plottedExtent.xMin, plottedExtent.xMax);
  1274. }
  1275. expandAxisToExtent(yAxis, plottedExtent.yMin, plottedExtent.yMax);
  1276. } else if (plottedExtent && pitchCoord) {
  1277. const xFitsAxis = !useTime || Number(plottedExtent.xMin) > 1e11;
  1278. if (xFitsAxis && !xRangeLocked) {
  1279. expandAxisToExtent(xAxis, plottedExtent.xMin, plottedExtent.xMax);
  1280. }
  1281. if (!yRangeLocked) {
  1282. expandAxisToExtent(yAxis, plottedExtent.yMin, plottedExtent.yMax);
  1283. }
  1284. }
  1285. const hasScatterLegend = series.some(
  1286. (item) =>
  1287. item.type === "scatter" &&
  1288. (item.data || []).length > 0 &&
  1289. !item.__bladePointOverlay,
  1290. );
  1291. return {
  1292. backgroundColor: "transparent",
  1293. ...buildPlotInteractionOptions(theme, { hasVisualMap, useTime, hasLegend }),
  1294. title: panelSubtitle
  1295. ? {
  1296. show: true,
  1297. text: panelSubtitle,
  1298. left: 8,
  1299. top: 6,
  1300. textStyle: {
  1301. color: theme.axis,
  1302. fontSize: 10,
  1303. fontWeight: 400,
  1304. width: 520,
  1305. },
  1306. }
  1307. : undefined,
  1308. grid: {
  1309. top: panelSubtitle ? 52 : hasLegend ? 44 : 36,
  1310. left: yName ? 58 : 52,
  1311. right: hasVisualMap ? 72 : 24,
  1312. bottom:
  1313. (useTime
  1314. ? timeAxisBottom
  1315. : xName
  1316. ? hasLegend
  1317. ? 68
  1318. : 52
  1319. : hasLegend
  1320. ? 56
  1321. : 40) + sliderExtraBottom,
  1322. containLabel: true,
  1323. },
  1324. tooltip: {
  1325. ...getTooltipBase(),
  1326. trigger: denseFarmChart ? "item" : "axis",
  1327. axisPointer: {
  1328. type: denseFarmChart ? "none" : useTime ? "line" : "cross",
  1329. },
  1330. backgroundColor: theme.tooltipBg,
  1331. borderColor: theme.tooltipBorder,
  1332. textStyle: { color: theme.tooltipText },
  1333. formatter(params) {
  1334. const items = Array.isArray(params) ? params : params ? [params] : [];
  1335. if (!items.length) return "";
  1336. const p = items[0];
  1337. const coord = pickValueCoord(p);
  1338. const xVal = coord[0];
  1339. const yVal = coord[1];
  1340. const xText = useTime ? formatTimeLabel(xVal) : xVal;
  1341. const pointHover = formatPointHover(p.data?.point);
  1342. const isPcaProjection =
  1343. /PCA|降维投影/.test(panel.title || "") ||
  1344. /^PC\d/i.test(String(panel.xaxis || "").trim());
  1345. const hideSeriesValue =
  1346. isPcaProjection ||
  1347. (/转速/.test(panel.title || "") && /功率/.test(panel.title || ""));
  1348. if (isPcaProjection) {
  1349. const lines = [];
  1350. items.forEach((item) => {
  1351. if (item.seriesName) {
  1352. lines.push(`${item.marker || ""}${item.seriesName}`);
  1353. }
  1354. const extra = formatPointHover(item.data?.point);
  1355. if (extra) lines.push(extra);
  1356. });
  1357. return lines.join("<br/>");
  1358. }
  1359. if (denseFarmChart) {
  1360. const extraHover = formatFarmSeriesHover(
  1361. p.data?.extra || series[p.seriesIndex]?.__farmExtra,
  1362. templateKey || panel.detectorName,
  1363. );
  1364. const unit = isYawCountDetector(templateKey) ? "次" : "";
  1365. return `${p.marker || ""}${p.seriesName}<br/>${
  1366. panel.xaxis || "X"
  1367. }: ${xText}<br/>${panel.yaxis || "Y"}: ${yVal}${unit}${
  1368. pointHover ? `<br/>${pointHover}` : ""
  1369. }${extraHover ? `<br/>${extraHover}` : ""}`;
  1370. }
  1371. const unit = isYawCountDetector(templateKey) ? "次" : "";
  1372. let html = `${panel.xaxis || "X"}: ${xText}<br/>`;
  1373. items.forEach((item) => {
  1374. const itemCoord = pickValueCoord(item);
  1375. html += hideSeriesValue
  1376. ? `${item.marker || ""}${item.seriesName}<br/>`
  1377. : `${item.marker}${item.seriesName}: ${itemCoord[1]}${unit}<br/>`;
  1378. const extra = formatPointHover(item.data?.point);
  1379. if (extra) html += `${extra}<br/>`;
  1380. const model = item.data?.extra?.model;
  1381. if (!farmMode && model != null && model !== "") {
  1382. html += `机型: ${model}<br/>`;
  1383. }
  1384. const extraHover = farmMode
  1385. ? formatFarmSeriesHover(
  1386. item.data?.extra,
  1387. templateKey || panel.detectorName,
  1388. )
  1389. : "";
  1390. if (extraHover) html += `${extraHover}<br/>`;
  1391. });
  1392. return html;
  1393. },
  1394. },
  1395. legend: hasLegend
  1396. ? {
  1397. type: "scroll",
  1398. bottom: 4,
  1399. left: "center",
  1400. icon: hasScatterLegend ? "circle" : undefined,
  1401. itemWidth: hasScatterLegend ? 10 : 14,
  1402. itemHeight: hasScatterLegend ? 10 : 8,
  1403. textStyle: { color: theme.legend, fontSize: 10 },
  1404. data: legendData,
  1405. }
  1406. : undefined,
  1407. visualMap: visualMaps.length ? visualMaps : undefined,
  1408. xAxis,
  1409. yAxis,
  1410. series,
  1411. };
  1412. }
  1413. export function buildFarmPanelChartOption(panel, options = {}) {
  1414. return buildPanelChartOption(panel, {
  1415. ...options,
  1416. farmMode: true,
  1417. });
  1418. }