anomalyWindPower3D.mjs 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866
  1. /**
  2. * 风功率曲线 / 风功率散点 — 全场 3D 图表(前端 + 报告 Node 共用)
  3. * 展示坐标:X = 风机名称,Y = 风速,Z = 功率(竖直)
  4. * 原始 panel 数据仍为 xData=风速、yData=功率
  5. */
  6. import {
  7. classifyDiagnosticSeries,
  8. diagnosticSeriesColor,
  9. isSharedOverlayCurveSeries,
  10. isContractPowerCurve,
  11. DIAGNOSTIC_COLORS,
  12. DIAGNOSTIC_FARM_STYLES,
  13. } from "./anomalyDiagnosticVisual.mjs";
  14. import {
  15. formatReportValueAxisLabel,
  16. isPointAnomalyFlag,
  17. readPointAnomalyFlags,
  18. resolvePanelSubtitle,
  19. } from "./anomalyDetectorPlotAdapter.mjs";
  20. export const WIND_POWER_3D_TEMPLATE_KEYS = new Set([
  21. "wind_power_curve",
  22. "wind_power_scatter",
  23. ]);
  24. export const WIND_POWER_3D_AXIS = {
  25. x: "风机名称",
  26. y: "风速 (m/s)",
  27. z: "有功功率 (kW)",
  28. };
  29. /** 参考图视角:功率轴竖直,风机与风速轴分别向左、右前方展开 */
  30. const REFERENCE_VIEW_CONTROL = {
  31. alpha: 25,
  32. beta: 45,
  33. center: [0, -4, 0],
  34. };
  35. /** Y 轴(机组)最多展示的名称数(折线/散点数据仍全部绘制) */
  36. export const FARM_WIND_POWER_3D_TURBINE_LABEL_COUNT = 8;
  37. /** 报告导出时更少标签,避免 3D 轴文字挤成一团 */
  38. export const FARM_WIND_POWER_3D_REPORT_LABEL_COUNT = 6;
  39. /** 机组在 Y 轴上的间距(数值越大,各风机切片越分开) */
  40. const TURBINE_Y_GAP = 10;
  41. /** 全场 3D 渐变色:提高饱和度与明度,避免深色底上看不清 */
  42. function farmTurbineGradientColor(turbineIndex, maxIndex, isAnomaly = false) {
  43. const t =
  44. maxIndex <= 1 ? 0 : Math.max(0, Math.min(1, (turbineIndex - 1) / (maxIndex - 1)));
  45. const hue = 88 + t * 172;
  46. if (isAnomaly) return `hsla(${hue}, 92%, 64%, 1)`;
  47. return `hsla(${hue}, 88%, 66%, 1)`;
  48. }
  49. function isPowerCurveAnomalyTurbine(row) {
  50. if (row?.anomaly === true || row?.extra?.anomaly === true) return true;
  51. if (row?.anomaly === false || row?.extra?.anomaly === false) return false;
  52. return isAnomalyRow(row);
  53. }
  54. /** 图例放在三维图右侧,避免压住底部机组轴和曲线 */
  55. function buildSideLegend(names, { legendColor, top }) {
  56. if (!names.length) return null;
  57. return {
  58. type: names.length > 16 ? "scroll" : "plain",
  59. orient: "vertical",
  60. right: 6,
  61. top,
  62. bottom: 16,
  63. width: 118,
  64. itemWidth: 10,
  65. itemHeight: 10,
  66. icon: "circle",
  67. itemGap: 8,
  68. textStyle: { color: legendColor, fontSize: 11 },
  69. formatter: (name) => String(name || "").replace(/\s+异常$/, ""),
  70. data: names,
  71. };
  72. }
  73. function isAnomalyRow(row) {
  74. const text = `${row?.originalLabel || ""} ${row?.label || ""}`;
  75. return /异常/.test(text) && !/正常/.test(text);
  76. }
  77. /** 行上仍带 pointAnomaly 时按同下标拆成正常/异常,与报告总图一致 */
  78. function splitScatterRowByPointAnomaly(row) {
  79. const xData = Array.isArray(row?.xData) ? row.xData : [];
  80. const yData = Array.isArray(row?.yData) ? row.yData : [];
  81. const len = Math.min(xData.length, yData.length);
  82. const alreadySplit =
  83. /正常|异常/.test(String(row?.originalLabel || "")) &&
  84. !Array.isArray(row?.pointAnomaly);
  85. const flags = alreadySplit ? null : readPointAnomalyFlags(row);
  86. if (!len || !flags || !flags.length) return [row];
  87. const normal = { x: [], y: [] };
  88. const anomaly = { x: [], y: [] };
  89. for (let i = 0; i < len; i += 1) {
  90. const bucket = isPointAnomalyFlag(flags[i]) ? anomaly : normal;
  91. bucket.x.push(xData[i]);
  92. bucket.y.push(yData[i]);
  93. }
  94. const baseLabel = String(row?.engineName || row?.label || "")
  95. .replace(/\s*(正常数据|异常点|正常|异常)$/u, "")
  96. .trim();
  97. const make = (bucket, kind) => {
  98. if (!bucket.x.length) return null;
  99. return {
  100. ...row,
  101. label: `${baseLabel} ${kind}`.trim(),
  102. originalLabel: kind,
  103. xData: bucket.x,
  104. yData: bucket.y,
  105. pointAnomaly: undefined,
  106. anomaly: kind === "异常",
  107. extra: { ...(row.extra || {}), anomaly: kind === "异常" },
  108. };
  109. };
  110. return [make(normal, "正常"), make(anomaly, "异常")].filter(Boolean);
  111. }
  112. function resolveFarmSeriesColor(
  113. row,
  114. index,
  115. turbineMeta,
  116. pickColor,
  117. farmMode,
  118. templateKey = "",
  119. ) {
  120. if (!farmMode) return pickColor(row.label, index, row, farmMode);
  121. const role = classifyDiagnosticSeries(row);
  122. if (
  123. role === "contract" &&
  124. (templateKey === "wind_power_curve" || templateKey === "wind_power_scatter")
  125. ) {
  126. return DIAGNOSTIC_COLORS.farmContract;
  127. }
  128. if (
  129. role === "contract" ||
  130. role === "reference" ||
  131. role === "warning" ||
  132. role === "mean"
  133. ) {
  134. return diagnosticSeriesColor(row, index);
  135. }
  136. if (typeof pickColor === "function") {
  137. return pickColor(row.label, index, row, farmMode);
  138. }
  139. // 无外部 pickColor 时:风功率等状态色检测器走正常蓝/异常红,其余保留渐变
  140. // 全场不用单机降载黄/停机黑
  141. if (
  142. templateKey === "wind_power_curve" ||
  143. templateKey === "wind_power_scatter"
  144. ) {
  145. return role === "anomaly" || role === "deload" || role === "shutdown"
  146. ? DIAGNOSTIC_COLORS.anomaly
  147. : DIAGNOSTIC_COLORS.normal;
  148. }
  149. if (role === "deload" || role === "shutdown") {
  150. return DIAGNOSTIC_COLORS.anomaly;
  151. }
  152. const ti = resolveTurbineIndex(row, turbineMeta);
  153. return farmTurbineGradientColor(ti, turbineMeta.maxIndex, role === "anomaly");
  154. }
  155. function resolveFarmSeriesVisual(row, templateKey, turbineMax, reportMode) {
  156. const isRef = isReferenceLineRow(row);
  157. const isScatter = !isRef && isMarkerRow(row, templateKey);
  158. const isAnomaly = isAnomalyRow(row);
  159. const dense = turbineMax > 10;
  160. if (isRef) {
  161. const contract = isContractPowerCurve(row);
  162. return {
  163. lineWidth: contract
  164. ? DIAGNOSTIC_FARM_STYLES.contract.lineWidth
  165. : 2.2,
  166. lineOpacity: 1,
  167. lineType: contract ? "solid" : "dashed",
  168. itemOpacity: 1,
  169. symbolSize: 3,
  170. };
  171. }
  172. if (isScatter) {
  173. // 报告白底用小点、低透明度;页面深色底必须不透明,否则正常蓝点会消失
  174. let symbolSize;
  175. let itemOpacity;
  176. if (reportMode && templateKey === "wind_power_scatter") {
  177. // 正常点略小、略淡;异常点只大一圈,纯红不描边
  178. const normalSize = dense ? 4 : 5;
  179. symbolSize = isAnomaly ? normalSize + 2 : normalSize;
  180. itemOpacity = isAnomaly ? 1 : dense ? 0.45 : 0.62;
  181. } else if (reportMode) {
  182. symbolSize = dense ? 2 : 3;
  183. itemOpacity = dense
  184. ? isAnomaly
  185. ? 0.68
  186. : 0.4
  187. : isAnomaly
  188. ? 0.85
  189. : 0.55;
  190. } else {
  191. symbolSize = dense ? 3 : 4;
  192. itemOpacity = 1;
  193. }
  194. return {
  195. lineWidth: 1,
  196. lineOpacity: 1,
  197. lineType: "solid",
  198. itemOpacity,
  199. symbolSize,
  200. };
  201. }
  202. return {
  203. lineWidth: dense ? (reportMode ? 2 : 1.9) : 2.4,
  204. lineOpacity: isAnomaly ? 1 : dense ? 0.92 : 0.98,
  205. lineType: "solid",
  206. itemOpacity: isAnomaly ? 1 : 0.92,
  207. symbolSize: dense ? (reportMode ? 0 : 1.5) : 2,
  208. };
  209. }
  210. function buildAxisSplitStyle(splitColor, show = true) {
  211. return {
  212. show,
  213. lineStyle: { color: splitColor, type: "solid", opacity: show ? 0.35 : 0 },
  214. };
  215. }
  216. export function isWindPower3DTemplate(templateKey) {
  217. return WIND_POWER_3D_TEMPLATE_KEYS.has(String(templateKey || ""));
  218. }
  219. /** 仅全场(farmMode)风功率曲线/散点使用 3D,单机保持 2D */
  220. export function shouldUseWindPower3D(templateKey, farmMode = false) {
  221. return Boolean(farmMode) && isWindPower3DTemplate(templateKey);
  222. }
  223. function getRowArrays(row) {
  224. const xData = Array.isArray(row?.xData) ? row.xData : [];
  225. const yData = Array.isArray(row?.yData) ? row.yData : [];
  226. const zData = Array.isArray(row?.zData) ? row.zData : [];
  227. return { xData, yData, zData };
  228. }
  229. function formatEngineLabel(name) {
  230. if (name == null || name === "") return "";
  231. const text = String(name);
  232. if (/号$/.test(text)) return text;
  233. if (/^\d+$/.test(text)) return `${text}号`;
  234. return text;
  235. }
  236. function resolveTurbineName(row) {
  237. if (row?.engineName) return formatEngineLabel(row.engineName);
  238. const label = String(row?.label || "");
  239. const head = label.split(/\s+/)[0] || "";
  240. if (/号/.test(head) || /^\d+$/.test(head)) return formatEngineLabel(head);
  241. return head;
  242. }
  243. function buildTurbineAxisMeta(rows = []) {
  244. const nameToIndex = new Map();
  245. let maxIndex = 0;
  246. rows.forEach((row) => {
  247. if (isReferenceLineRow(row)) return;
  248. const name = resolveTurbineName(row);
  249. let turbineIndex =
  250. row?.turbineIndex != null && Number.isFinite(Number(row.turbineIndex))
  251. ? Number(row.turbineIndex) + 1
  252. : null;
  253. if (turbineIndex == null && name) {
  254. if (!nameToIndex.has(name)) {
  255. maxIndex += 1;
  256. nameToIndex.set(name, maxIndex);
  257. }
  258. turbineIndex = nameToIndex.get(name);
  259. }
  260. if (turbineIndex != null) {
  261. maxIndex = Math.max(maxIndex, turbineIndex);
  262. if (name) nameToIndex.set(name, turbineIndex);
  263. }
  264. });
  265. const indexToName = new Map();
  266. nameToIndex.forEach((index, name) => indexToName.set(index, name));
  267. return { maxIndex, indexToName, nameToIndex };
  268. }
  269. function resolveTurbineIndex(row, meta) {
  270. if (row?.turbineIndex != null && Number.isFinite(Number(row.turbineIndex))) {
  271. return Number(row.turbineIndex) + 1;
  272. }
  273. const name = resolveTurbineName(row);
  274. if (name && meta.nameToIndex.has(name)) return meta.nameToIndex.get(name);
  275. return 1;
  276. }
  277. /** ECharts 3D 坐标:[机组位置, 风速, 功率] */
  278. function toChartPoint(windSpeed, power, turbineIndex) {
  279. return [turbineIndex * TURBINE_Y_GAP, windSpeed, power];
  280. }
  281. function resolveTurbineIndexFromPosition(position) {
  282. return Math.round(Number(position) / TURBINE_Y_GAP);
  283. }
  284. function resolveFarmTriplets(row, meta) {
  285. const { xData, yData, zData } = getRowArrays(row);
  286. const powerArr = zData.length ? zData : yData;
  287. const turbineIndex = resolveTurbineIndex(row, meta);
  288. const len = Math.min(xData.length, powerArr.length);
  289. const triplets = [];
  290. for (let i = 0; i < len; i += 1) {
  291. const windSpeed = Number(xData[i]);
  292. const power = Number(powerArr[i]);
  293. if (!Number.isFinite(windSpeed) || !Number.isFinite(power)) continue;
  294. triplets.push(toChartPoint(windSpeed, power, turbineIndex));
  295. }
  296. return triplets;
  297. }
  298. function isReferenceLineRow(row) {
  299. if (row?.__shared) return true;
  300. return isSharedOverlayCurveSeries(row);
  301. }
  302. function readRowModel(row) {
  303. const model = row?.model ?? row?.extra?.model;
  304. if (model == null || String(model).trim() === "") return "";
  305. return String(model).trim();
  306. }
  307. function isMarkerRow(row, templateKey) {
  308. if (isReferenceLineRow(row)) return false;
  309. if (templateKey === "wind_power_curve") return false;
  310. if (templateKey === "wind_power_scatter") return true;
  311. const mode = String(row?.mode || "lines");
  312. return mode.includes("markers") && !mode.includes("lines");
  313. }
  314. function buildReferenceLineTriplets(row) {
  315. const { xData, yData, zData } = getRowArrays(row);
  316. const powerArr = zData.length ? zData : yData;
  317. const len = Math.min(xData.length, powerArr.length);
  318. const triplets = [];
  319. for (let i = 0; i < len; i += 1) {
  320. const windSpeed = Number(xData[i]);
  321. const power = Number(powerArr[i]);
  322. if (!Number.isFinite(windSpeed) || !Number.isFinite(power)) continue;
  323. triplets.push(toChartPoint(windSpeed, power, 0));
  324. }
  325. return triplets;
  326. }
  327. function formatTurbineAxisLabel(index, indexToName) {
  328. if (index === 0) return "";
  329. return indexToName.get(index) || `${index}号`;
  330. }
  331. /** 机组较多时风机名称轴仅抽样展示,避免标签重叠 */
  332. function buildTurbineAxisLabelConfig(
  333. maxIndex,
  334. indexToName,
  335. reportMode = false,
  336. ) {
  337. const maxLabels = reportMode
  338. ? FARM_WIND_POWER_3D_REPORT_LABEL_COUNT
  339. : FARM_WIND_POWER_3D_TURBINE_LABEL_COUNT;
  340. const labeledIndex = new Set();
  341. if (maxIndex <= maxLabels) {
  342. for (let index = 1; index <= maxIndex; index += 1) labeledIndex.add(index);
  343. } else {
  344. labeledIndex.add(1);
  345. labeledIndex.add(maxIndex);
  346. const reserved = labeledIndex.size;
  347. const slots = Math.max(0, maxLabels - reserved);
  348. if (slots > 0 && maxIndex > 2) {
  349. const step = Math.max(1, Math.round((maxIndex - 1) / (slots + 1)));
  350. for (let index = 1 + step; index < maxIndex; index += step) {
  351. labeledIndex.add(index);
  352. if (labeledIndex.size >= maxLabels) break;
  353. }
  354. }
  355. }
  356. const labelMap = {};
  357. labeledIndex.forEach((index) => {
  358. labelMap[String(index * TURBINE_Y_GAP)] = formatTurbineAxisLabel(
  359. index,
  360. indexToName,
  361. );
  362. });
  363. return {
  364. interval: TURBINE_Y_GAP,
  365. labeledIndex,
  366. labelMap,
  367. gap: TURBINE_Y_GAP,
  368. formatter(v) {
  369. const position = Number(v);
  370. if (!Number.isFinite(position)) return "";
  371. const index = resolveTurbineIndexFromPosition(position);
  372. if (index < 0 || index > maxIndex) return "";
  373. if (Math.abs(position - index * TURBINE_Y_GAP) > 0.01) return "";
  374. if (!labeledIndex.has(index)) return "";
  375. return formatTurbineAxisLabel(index, indexToName);
  376. },
  377. };
  378. }
  379. function computePlotExtents(series = []) {
  380. let windMin = Infinity;
  381. let windMax = -Infinity;
  382. let powerMin = Infinity;
  383. let powerMax = -Infinity;
  384. series.forEach((item) => {
  385. (item.data || []).forEach((point) => {
  386. if (!Array.isArray(point) || point.length < 3) return;
  387. const [, windSpeed, power] = point;
  388. if (Number.isFinite(windSpeed)) {
  389. windMin = Math.min(windMin, windSpeed);
  390. windMax = Math.max(windMax, windSpeed);
  391. }
  392. if (Number.isFinite(power)) {
  393. powerMin = Math.min(powerMin, power);
  394. powerMax = Math.max(powerMax, power);
  395. }
  396. });
  397. });
  398. if (!Number.isFinite(windMin) || !Number.isFinite(windMax)) {
  399. windMin = 0;
  400. windMax = 25;
  401. }
  402. if (!Number.isFinite(powerMin) || !Number.isFinite(powerMax)) {
  403. powerMin = 0;
  404. powerMax = 2000;
  405. }
  406. const windPad = Math.max(0.5, (windMax - windMin) * 0.04);
  407. const powerPad = Math.max(50, Math.abs(powerMax - powerMin) * 0.06);
  408. return {
  409. windMin: windMin - windPad,
  410. windMax: windMax + windPad,
  411. powerMin: powerMin - powerPad,
  412. powerMax: powerMax + powerPad,
  413. };
  414. }
  415. function resolveWindSpeedAxisName(panel) {
  416. const name = String(panel?.xaxis || "").trim();
  417. if (/风速/.test(name)) return name;
  418. return WIND_POWER_3D_AXIS.y;
  419. }
  420. function resolvePowerAxisName(panel) {
  421. const name = String(panel?.yaxis || "").trim();
  422. if (/功率/.test(name)) return name;
  423. return WIND_POWER_3D_AXIS.z;
  424. }
  425. function buildGrid3DLayout({
  426. hasLegend,
  427. legendRows = 0,
  428. hasSubtitle,
  429. reportMode,
  430. templateKey = "",
  431. legendSide = false,
  432. }) {
  433. const titleBlock = hasSubtitle ? 50 : 40;
  434. const reportLook = reportMode || templateKey === "wind_power_scatter";
  435. const bottom = legendSide
  436. ? 52
  437. : legendRows > 1
  438. ? 108
  439. : legendRows === 1
  440. ? 86
  441. : 72;
  442. return {
  443. boxWidth: reportLook ? 96 : 108,
  444. boxHeight: reportLook ? 88 : 96,
  445. boxDepth: reportLook ? 158 : 176,
  446. top: titleBlock,
  447. left: legendSide ? 4 : hasLegend ? 18 : "5%",
  448. right: legendSide ? 156 : hasLegend ? 112 : "5%",
  449. bottom,
  450. viewControl: {
  451. projection: "orthographic",
  452. autoRotate: false,
  453. distance: reportLook ? 225 : 218,
  454. orthographicSize: reportLook ? 215 : 150,
  455. alpha: REFERENCE_VIEW_CONTROL.alpha,
  456. beta: REFERENCE_VIEW_CONTROL.beta,
  457. minAlpha: 8,
  458. maxAlpha: 62,
  459. rotateSensitivity: 1,
  460. zoomSensitivity: 1,
  461. panSensitivity: 0.8,
  462. center: [...REFERENCE_VIEW_CONTROL.center],
  463. },
  464. };
  465. }
  466. function defaultPickColor(label, index) {
  467. const palette = ["#1E7CF8", "#7C5CFF", "#16A34A", "#F59E0B", "#EF4444"];
  468. const text = String(label || "");
  469. if (/合同功率/.test(text)) return DIAGNOSTIC_COLORS.contract;
  470. if (/参考|理论/.test(text)) return "#64748B";
  471. if (/异常/.test(text) && !/正常/.test(text)) return "#EF4444";
  472. if (/正常/.test(text)) return "#1E7CF8";
  473. return palette[index % palette.length];
  474. }
  475. /**
  476. * @param {object} params
  477. * @param {object} params.panel - { title, xaxis, yaxis, data }
  478. * @param {string} params.templateKey
  479. * @param {string} [params.title]
  480. * @param {boolean} [params.farmMode]
  481. * @param {function} [params.pickColor]
  482. * @param {object} [params.theme]
  483. * @param {boolean} [params.reportMode] - 报告白底样式
  484. */
  485. export function buildWindPower3DChartOption({
  486. panel,
  487. templateKey,
  488. title = "",
  489. farmMode = false,
  490. pickColor = defaultPickColor,
  491. theme = {},
  492. reportMode = false,
  493. }) {
  494. const sourceRows = panel?.data || [];
  495. const isFarmPowerCurve =
  496. farmMode && templateKey === "wind_power_curve";
  497. // JSON 里的机组曲线全部绘制。报告图例不列出正常机组。
  498. const rows = sourceRows;
  499. const turbineMeta = buildTurbineAxisMeta(rows);
  500. const turbineMax = Math.max(turbineMeta.maxIndex, 1);
  501. const turbineSeries = [];
  502. const referenceSeries = [];
  503. const legendNames = [];
  504. rows.forEach((sourceRow, index) => {
  505. const expanded =
  506. templateKey === "wind_power_scatter"
  507. ? splitScatterRowByPointAnomaly(sourceRow)
  508. : [sourceRow];
  509. expanded.forEach((row) => {
  510. let color = resolveFarmSeriesColor(
  511. row,
  512. index,
  513. turbineMeta,
  514. pickColor,
  515. farmMode,
  516. templateKey,
  517. );
  518. if (
  519. reportMode &&
  520. templateKey === "wind_power_scatter" &&
  521. isAnomalyRow(row) &&
  522. !isReferenceLineRow(row)
  523. ) {
  524. color = "#FF1E1E";
  525. }
  526. const visual = resolveFarmSeriesVisual(row, templateKey, turbineMax, reportMode);
  527. if (isReferenceLineRow(row)) {
  528. const data = buildReferenceLineTriplets(row);
  529. if (!data.length) return;
  530. referenceSeries.push({
  531. name: row.label,
  532. type: "line3D",
  533. __model: readRowModel(row),
  534. data,
  535. lineStyle: {
  536. width: visual.lineWidth,
  537. color,
  538. type: visual.lineType,
  539. opacity: visual.lineOpacity,
  540. },
  541. itemStyle: { color, opacity: visual.itemOpacity },
  542. });
  543. legendNames.push(row.label);
  544. return;
  545. }
  546. const triplets = resolveFarmTriplets(row, turbineMeta);
  547. if (!triplets.length) return;
  548. const seriesName =
  549. isFarmPowerCurve && isPowerCurveAnomalyTurbine(row)
  550. ? String(row.engineName || resolveTurbineName(row) || row.label || "")
  551. : row.label;
  552. const useScatter = isMarkerRow(row, templateKey);
  553. const plotData = useScatter
  554. ? triplets
  555. : [...triplets].sort((a, b) => a[1] - b[1]);
  556. const item = {
  557. name: seriesName,
  558. type: useScatter ? "scatter3D" : "line3D",
  559. __model: readRowModel(row),
  560. data: plotData,
  561. shading: "color",
  562. symbol: "circle",
  563. symbolSize: visual.symbolSize,
  564. lineStyle: useScatter
  565. ? undefined
  566. : {
  567. width: visual.lineWidth,
  568. color,
  569. type: visual.lineType,
  570. opacity: visual.lineOpacity,
  571. },
  572. itemStyle: {
  573. color,
  574. opacity: visual.itemOpacity,
  575. ...(visual.borderWidth
  576. ? {
  577. borderWidth: visual.borderWidth,
  578. borderColor: visual.borderColor || "#ffffff",
  579. }
  580. : {}),
  581. },
  582. };
  583. turbineSeries.push(item);
  584. const hideNormalLegend =
  585. reportMode && isFarmPowerCurve && !isPowerCurveAnomalyTurbine(row);
  586. if (!hideNormalLegend) legendNames.push(seriesName);
  587. });
  588. });
  589. turbineSeries.sort((a, b) => {
  590. const rank = (name) =>
  591. /异常/.test(String(name || "")) && !/正常/.test(String(name || ""))
  592. ? 1
  593. : 0;
  594. return rank(a.name) - rank(b.name);
  595. });
  596. const series = [...turbineSeries, ...referenceSeries];
  597. const plotExtents = computePlotExtents(series);
  598. const axisColor = theme.axis || (reportMode ? "#64748b" : "#8da3c7");
  599. const legendColor = theme.legend || (reportMode ? "#334155" : "#cde6ff");
  600. const titleColor = theme.title || (reportMode ? "#0f172a" : "#e3f2ff");
  601. const splitColor = theme.split || (reportMode ? "#e2e8f0" : "rgba(98, 121, 186, 0.16)");
  602. const bg = reportMode ? "#eef4fb" : "transparent";
  603. const legendData = legendNames;
  604. const chartTitle = title || panel?.title || "";
  605. const sideLegend = isFarmPowerCurve
  606. ? buildSideLegend(legendData, {
  607. legendColor,
  608. top: chartTitle ? (reportMode ? 36 : 40) : 12,
  609. })
  610. : null;
  611. const hasLegend = sideLegend ? legendData.length > 0 : legendData.length > 1;
  612. const chartSubtitle = resolvePanelSubtitle(panel);
  613. const hasReference = rows.some(isReferenceLineRow);
  614. const turbineAxisMax = (hasReference ? turbineMax + 0.5 : turbineMax) * TURBINE_Y_GAP;
  615. const turbineAxisLabel = buildTurbineAxisLabelConfig(
  616. turbineMax,
  617. turbineMeta.indexToName,
  618. reportMode,
  619. );
  620. const grid3DLayout = buildGrid3DLayout({
  621. hasLegend,
  622. legendSide: Boolean(sideLegend),
  623. hasSubtitle: Boolean(chartSubtitle),
  624. reportMode,
  625. templateKey,
  626. });
  627. return {
  628. backgroundColor: bg,
  629. animation: reportMode ? false : true,
  630. animationDuration: reportMode ? 0 : undefined,
  631. title: chartTitle
  632. ? {
  633. text: chartTitle,
  634. subtext: chartSubtitle,
  635. left: "center",
  636. top: reportMode ? 8 : 4,
  637. textStyle: {
  638. fontSize: reportMode ? 13 : 12,
  639. color: titleColor,
  640. fontWeight: reportMode ? 600 : 500,
  641. },
  642. subtextStyle: {
  643. fontSize: 10,
  644. color: axisColor,
  645. opacity: 0.82,
  646. },
  647. itemGap: chartSubtitle ? 4 : 8,
  648. }
  649. : undefined,
  650. toolbox: reportMode
  651. ? undefined
  652. : {
  653. show: true,
  654. top: 4,
  655. right: sideLegend ? 168 : hasLegend ? 112 : 12,
  656. itemSize: 14,
  657. itemGap: 10,
  658. iconStyle: {
  659. borderColor: axisColor,
  660. },
  661. emphasis: {
  662. iconStyle: {
  663. borderColor: titleColor,
  664. },
  665. },
  666. feature: {
  667. restore: {
  668. show: true,
  669. title: "还原视角",
  670. },
  671. saveAsImage: {
  672. show: true,
  673. title: "保存图片",
  674. name: chartTitle || "全场风功率图",
  675. backgroundColor: "#0f172a",
  676. pixelRatio: 2,
  677. },
  678. },
  679. },
  680. tooltip: {
  681. show: true,
  682. formatter(params) {
  683. if (!params) return "";
  684. const val = params.value || params.data;
  685. if (!Array.isArray(val)) return "";
  686. const [turbinePosition, windSpeed, power] = val;
  687. const turbineText = formatTurbineAxisLabel(
  688. resolveTurbineIndexFromPosition(turbinePosition),
  689. turbineMeta.indexToName,
  690. );
  691. const turbineLine = turbineText
  692. ? `<br/>${WIND_POWER_3D_AXIS.x}: ${turbineText}`
  693. : "";
  694. const model = series[params.seriesIndex]?.__model;
  695. const modelLine = model ? `<br/>机型: ${model}` : "";
  696. return `${params.marker || ""}${params.seriesName || ""}${modelLine}<br/>${WIND_POWER_3D_AXIS.y}: ${windSpeed}${turbineLine}<br/>${WIND_POWER_3D_AXIS.z}: ${power}`;
  697. },
  698. },
  699. legend: sideLegend
  700. ? sideLegend
  701. : hasLegend
  702. ? {
  703. type: "scroll",
  704. orient: "vertical",
  705. right: reportMode ? 4 : 0,
  706. top: chartTitle ? (reportMode ? 40 : 36) : 12,
  707. bottom: 8,
  708. width: 96,
  709. itemWidth: 12,
  710. itemHeight: 12,
  711. icon: "circle",
  712. itemGap: 5,
  713. pageButtonItemGap: 6,
  714. pageIconSize: 10,
  715. textStyle: { color: legendColor, fontSize: 10 },
  716. data: legendData,
  717. }
  718. : undefined,
  719. grid3D: {
  720. ...grid3DLayout,
  721. light: {
  722. main: {
  723. intensity: 1.35,
  724. shadow: reportMode && templateKey !== "wind_power_scatter",
  725. alpha: 35,
  726. beta: 120,
  727. },
  728. ambient: {
  729. intensity:
  730. reportMode && templateKey === "wind_power_scatter" ? 1.05 : 0.92,
  731. },
  732. },
  733. },
  734. xAxis3D: {
  735. type: "value",
  736. name: WIND_POWER_3D_AXIS.x,
  737. min: 0,
  738. max: turbineAxisMax,
  739. interval: turbineAxisLabel.interval,
  740. nameGap: 28,
  741. nameTextStyle: { color: axisColor, fontSize: 10 },
  742. // Puppeteer 无法序列化 formatter,报告侧靠此字段重建抽样标签
  743. _turbineLabelMeta: {
  744. gap: turbineAxisLabel.gap,
  745. labels: turbineAxisLabel.labelMap,
  746. },
  747. axisLabel: {
  748. color: axisColor,
  749. fontSize: reportMode ? 8 : 9,
  750. hideOverlap: true,
  751. showMinLabel: true,
  752. showMaxLabel: true,
  753. margin: 4,
  754. formatter: turbineAxisLabel.formatter,
  755. },
  756. axisLine: { lineStyle: { color: splitColor, opacity: 0.65 } },
  757. splitLine: buildAxisSplitStyle(splitColor, true),
  758. },
  759. yAxis3D: {
  760. type: "value",
  761. name: resolveWindSpeedAxisName(panel),
  762. min: plotExtents.windMin,
  763. max: plotExtents.windMax,
  764. splitNumber: 5,
  765. nameGap: 22,
  766. nameTextStyle: { color: axisColor, fontSize: 10, fontWeight: 500 },
  767. axisLabel: {
  768. color: axisColor,
  769. fontSize: 9,
  770. margin: 4,
  771. formatter: reportMode
  772. ? (value) => formatReportValueAxisLabel(value)
  773. : undefined,
  774. },
  775. axisLine: { lineStyle: { color: splitColor, opacity: 0.8 } },
  776. splitLine: buildAxisSplitStyle(splitColor, true),
  777. },
  778. zAxis3D: {
  779. type: "value",
  780. name: resolvePowerAxisName(panel),
  781. min: plotExtents.powerMin,
  782. max: plotExtents.powerMax,
  783. splitNumber: 5,
  784. nameGap: 24,
  785. nameTextStyle: { color: axisColor, fontSize: 10, fontWeight: 500 },
  786. axisLabel: {
  787. color: axisColor,
  788. fontSize: 9,
  789. margin: 4,
  790. formatter: reportMode
  791. ? (value) => formatReportValueAxisLabel(value)
  792. : undefined,
  793. },
  794. axisLine: { lineStyle: { color: splitColor, opacity: 0.8 } },
  795. splitLine: buildAxisSplitStyle(splitColor, true),
  796. },
  797. series,
  798. };
  799. }
  800. export function buildWindPower3DPanelOption(panel, options = {}) {
  801. return buildWindPower3DChartOption({
  802. panel,
  803. templateKey: options.templateKey || "",
  804. title: options.title || panel?.title || "",
  805. farmMode: Boolean(options.farmMode),
  806. pickColor: options.pickColor,
  807. theme: options.theme || {},
  808. reportMode: Boolean(options.reportMode),
  809. });
  810. }