anomalyWindPower3D.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856
  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 isMarkerRow(row, templateKey) {
  303. if (isReferenceLineRow(row)) return false;
  304. if (templateKey === "wind_power_curve") return false;
  305. if (templateKey === "wind_power_scatter") return true;
  306. const mode = String(row?.mode || "lines");
  307. return mode.includes("markers") && !mode.includes("lines");
  308. }
  309. function buildReferenceLineTriplets(row) {
  310. const { xData, yData, zData } = getRowArrays(row);
  311. const powerArr = zData.length ? zData : yData;
  312. const len = Math.min(xData.length, powerArr.length);
  313. const triplets = [];
  314. for (let i = 0; i < len; i += 1) {
  315. const windSpeed = Number(xData[i]);
  316. const power = Number(powerArr[i]);
  317. if (!Number.isFinite(windSpeed) || !Number.isFinite(power)) continue;
  318. triplets.push(toChartPoint(windSpeed, power, 0));
  319. }
  320. return triplets;
  321. }
  322. function formatTurbineAxisLabel(index, indexToName) {
  323. if (index === 0) return "";
  324. return indexToName.get(index) || `${index}号`;
  325. }
  326. /** 机组较多时风机名称轴仅抽样展示,避免标签重叠 */
  327. function buildTurbineAxisLabelConfig(
  328. maxIndex,
  329. indexToName,
  330. reportMode = false,
  331. ) {
  332. const maxLabels = reportMode
  333. ? FARM_WIND_POWER_3D_REPORT_LABEL_COUNT
  334. : FARM_WIND_POWER_3D_TURBINE_LABEL_COUNT;
  335. const labeledIndex = new Set();
  336. if (maxIndex <= maxLabels) {
  337. for (let index = 1; index <= maxIndex; index += 1) labeledIndex.add(index);
  338. } else {
  339. labeledIndex.add(1);
  340. labeledIndex.add(maxIndex);
  341. const reserved = labeledIndex.size;
  342. const slots = Math.max(0, maxLabels - reserved);
  343. if (slots > 0 && maxIndex > 2) {
  344. const step = Math.max(1, Math.round((maxIndex - 1) / (slots + 1)));
  345. for (let index = 1 + step; index < maxIndex; index += step) {
  346. labeledIndex.add(index);
  347. if (labeledIndex.size >= maxLabels) break;
  348. }
  349. }
  350. }
  351. const labelMap = {};
  352. labeledIndex.forEach((index) => {
  353. labelMap[String(index * TURBINE_Y_GAP)] = formatTurbineAxisLabel(
  354. index,
  355. indexToName,
  356. );
  357. });
  358. return {
  359. interval: TURBINE_Y_GAP,
  360. labeledIndex,
  361. labelMap,
  362. gap: TURBINE_Y_GAP,
  363. formatter(v) {
  364. const position = Number(v);
  365. if (!Number.isFinite(position)) return "";
  366. const index = resolveTurbineIndexFromPosition(position);
  367. if (index < 0 || index > maxIndex) return "";
  368. if (Math.abs(position - index * TURBINE_Y_GAP) > 0.01) return "";
  369. if (!labeledIndex.has(index)) return "";
  370. return formatTurbineAxisLabel(index, indexToName);
  371. },
  372. };
  373. }
  374. function computePlotExtents(series = []) {
  375. let windMin = Infinity;
  376. let windMax = -Infinity;
  377. let powerMin = Infinity;
  378. let powerMax = -Infinity;
  379. series.forEach((item) => {
  380. (item.data || []).forEach((point) => {
  381. if (!Array.isArray(point) || point.length < 3) return;
  382. const [, windSpeed, power] = point;
  383. if (Number.isFinite(windSpeed)) {
  384. windMin = Math.min(windMin, windSpeed);
  385. windMax = Math.max(windMax, windSpeed);
  386. }
  387. if (Number.isFinite(power)) {
  388. powerMin = Math.min(powerMin, power);
  389. powerMax = Math.max(powerMax, power);
  390. }
  391. });
  392. });
  393. if (!Number.isFinite(windMin) || !Number.isFinite(windMax)) {
  394. windMin = 0;
  395. windMax = 25;
  396. }
  397. if (!Number.isFinite(powerMin) || !Number.isFinite(powerMax)) {
  398. powerMin = 0;
  399. powerMax = 2000;
  400. }
  401. const windPad = Math.max(0.5, (windMax - windMin) * 0.04);
  402. const powerPad = Math.max(50, Math.abs(powerMax - powerMin) * 0.06);
  403. return {
  404. windMin: windMin - windPad,
  405. windMax: windMax + windPad,
  406. powerMin: powerMin - powerPad,
  407. powerMax: powerMax + powerPad,
  408. };
  409. }
  410. function resolveWindSpeedAxisName(panel) {
  411. const name = String(panel?.xaxis || "").trim();
  412. if (/风速/.test(name)) return name;
  413. return WIND_POWER_3D_AXIS.y;
  414. }
  415. function resolvePowerAxisName(panel) {
  416. const name = String(panel?.yaxis || "").trim();
  417. if (/功率/.test(name)) return name;
  418. return WIND_POWER_3D_AXIS.z;
  419. }
  420. function buildGrid3DLayout({
  421. hasLegend,
  422. legendRows = 0,
  423. hasSubtitle,
  424. reportMode,
  425. templateKey = "",
  426. legendSide = false,
  427. }) {
  428. const titleBlock = hasSubtitle ? 50 : 40;
  429. const reportLook = reportMode || templateKey === "wind_power_scatter";
  430. const bottom = legendSide
  431. ? 52
  432. : legendRows > 1
  433. ? 108
  434. : legendRows === 1
  435. ? 86
  436. : 72;
  437. return {
  438. boxWidth: reportLook ? 96 : 108,
  439. boxHeight: reportLook ? 88 : 96,
  440. boxDepth: reportLook ? 158 : 176,
  441. top: titleBlock,
  442. left: legendSide ? 4 : hasLegend ? 18 : "5%",
  443. right: legendSide ? 156 : hasLegend ? 112 : "5%",
  444. bottom,
  445. viewControl: {
  446. projection: "orthographic",
  447. autoRotate: false,
  448. distance: reportLook ? 225 : 218,
  449. orthographicSize: reportLook ? 215 : 150,
  450. alpha: REFERENCE_VIEW_CONTROL.alpha,
  451. beta: REFERENCE_VIEW_CONTROL.beta,
  452. minAlpha: 8,
  453. maxAlpha: 62,
  454. rotateSensitivity: 1,
  455. zoomSensitivity: 1,
  456. panSensitivity: 0.8,
  457. center: [...REFERENCE_VIEW_CONTROL.center],
  458. },
  459. };
  460. }
  461. function defaultPickColor(label, index) {
  462. const palette = ["#1E7CF8", "#7C5CFF", "#16A34A", "#F59E0B", "#EF4444"];
  463. const text = String(label || "");
  464. if (/合同功率/.test(text)) return DIAGNOSTIC_COLORS.contract;
  465. if (/参考|理论/.test(text)) return "#64748B";
  466. if (/异常/.test(text) && !/正常/.test(text)) return "#EF4444";
  467. if (/正常/.test(text)) return "#1E7CF8";
  468. return palette[index % palette.length];
  469. }
  470. /**
  471. * @param {object} params
  472. * @param {object} params.panel - { title, xaxis, yaxis, data }
  473. * @param {string} params.templateKey
  474. * @param {string} [params.title]
  475. * @param {boolean} [params.farmMode]
  476. * @param {function} [params.pickColor]
  477. * @param {object} [params.theme]
  478. * @param {boolean} [params.reportMode] - 报告白底样式
  479. */
  480. export function buildWindPower3DChartOption({
  481. panel,
  482. templateKey,
  483. title = "",
  484. farmMode = false,
  485. pickColor = defaultPickColor,
  486. theme = {},
  487. reportMode = false,
  488. }) {
  489. const sourceRows = panel?.data || [];
  490. const isFarmPowerCurve =
  491. farmMode && templateKey === "wind_power_curve";
  492. // JSON 里的机组曲线全部绘制。报告图例不列出正常机组。
  493. const rows = sourceRows;
  494. const turbineMeta = buildTurbineAxisMeta(rows);
  495. const turbineMax = Math.max(turbineMeta.maxIndex, 1);
  496. const turbineSeries = [];
  497. const referenceSeries = [];
  498. const legendNames = [];
  499. rows.forEach((sourceRow, index) => {
  500. const expanded =
  501. templateKey === "wind_power_scatter"
  502. ? splitScatterRowByPointAnomaly(sourceRow)
  503. : [sourceRow];
  504. expanded.forEach((row) => {
  505. let color = resolveFarmSeriesColor(
  506. row,
  507. index,
  508. turbineMeta,
  509. pickColor,
  510. farmMode,
  511. templateKey,
  512. );
  513. if (
  514. reportMode &&
  515. templateKey === "wind_power_scatter" &&
  516. isAnomalyRow(row) &&
  517. !isReferenceLineRow(row)
  518. ) {
  519. color = "#FF1E1E";
  520. }
  521. const visual = resolveFarmSeriesVisual(row, templateKey, turbineMax, reportMode);
  522. if (isReferenceLineRow(row)) {
  523. const data = buildReferenceLineTriplets(row);
  524. if (!data.length) return;
  525. referenceSeries.push({
  526. name: row.label,
  527. type: "line3D",
  528. data,
  529. lineStyle: {
  530. width: visual.lineWidth,
  531. color,
  532. type: visual.lineType,
  533. opacity: visual.lineOpacity,
  534. },
  535. itemStyle: { color, opacity: visual.itemOpacity },
  536. });
  537. legendNames.push(row.label);
  538. return;
  539. }
  540. const triplets = resolveFarmTriplets(row, turbineMeta);
  541. if (!triplets.length) return;
  542. const seriesName =
  543. isFarmPowerCurve && isPowerCurveAnomalyTurbine(row)
  544. ? String(row.engineName || resolveTurbineName(row) || row.label || "")
  545. : row.label;
  546. const useScatter = isMarkerRow(row, templateKey);
  547. const plotData = useScatter
  548. ? triplets
  549. : [...triplets].sort((a, b) => a[1] - b[1]);
  550. const item = {
  551. name: seriesName,
  552. type: useScatter ? "scatter3D" : "line3D",
  553. data: plotData,
  554. shading: "color",
  555. symbol: "circle",
  556. symbolSize: visual.symbolSize,
  557. lineStyle: useScatter
  558. ? undefined
  559. : {
  560. width: visual.lineWidth,
  561. color,
  562. type: visual.lineType,
  563. opacity: visual.lineOpacity,
  564. },
  565. itemStyle: {
  566. color,
  567. opacity: visual.itemOpacity,
  568. ...(visual.borderWidth
  569. ? {
  570. borderWidth: visual.borderWidth,
  571. borderColor: visual.borderColor || "#ffffff",
  572. }
  573. : {}),
  574. },
  575. };
  576. turbineSeries.push(item);
  577. const hideNormalLegend =
  578. reportMode && isFarmPowerCurve && !isPowerCurveAnomalyTurbine(row);
  579. if (!hideNormalLegend) legendNames.push(seriesName);
  580. });
  581. });
  582. turbineSeries.sort((a, b) => {
  583. const rank = (name) =>
  584. /异常/.test(String(name || "")) && !/正常/.test(String(name || ""))
  585. ? 1
  586. : 0;
  587. return rank(a.name) - rank(b.name);
  588. });
  589. const series = [...turbineSeries, ...referenceSeries];
  590. const plotExtents = computePlotExtents(series);
  591. const axisColor = theme.axis || (reportMode ? "#64748b" : "#8da3c7");
  592. const legendColor = theme.legend || (reportMode ? "#334155" : "#cde6ff");
  593. const titleColor = theme.title || (reportMode ? "#0f172a" : "#e3f2ff");
  594. const splitColor = theme.split || (reportMode ? "#e2e8f0" : "rgba(98, 121, 186, 0.16)");
  595. const bg = reportMode ? "#eef4fb" : "transparent";
  596. const legendData = legendNames;
  597. const chartTitle = title || panel?.title || "";
  598. const sideLegend = isFarmPowerCurve
  599. ? buildSideLegend(legendData, {
  600. legendColor,
  601. top: chartTitle ? (reportMode ? 36 : 40) : 12,
  602. })
  603. : null;
  604. const hasLegend = sideLegend ? legendData.length > 0 : legendData.length > 1;
  605. const chartSubtitle = resolvePanelSubtitle(panel);
  606. const hasReference = rows.some(isReferenceLineRow);
  607. const turbineAxisMax = (hasReference ? turbineMax + 0.5 : turbineMax) * TURBINE_Y_GAP;
  608. const turbineAxisLabel = buildTurbineAxisLabelConfig(
  609. turbineMax,
  610. turbineMeta.indexToName,
  611. reportMode,
  612. );
  613. const grid3DLayout = buildGrid3DLayout({
  614. hasLegend,
  615. legendSide: Boolean(sideLegend),
  616. hasSubtitle: Boolean(chartSubtitle),
  617. reportMode,
  618. templateKey,
  619. });
  620. return {
  621. backgroundColor: bg,
  622. animation: reportMode ? false : true,
  623. animationDuration: reportMode ? 0 : undefined,
  624. title: chartTitle
  625. ? {
  626. text: chartTitle,
  627. subtext: chartSubtitle,
  628. left: "center",
  629. top: reportMode ? 8 : 4,
  630. textStyle: {
  631. fontSize: reportMode ? 13 : 12,
  632. color: titleColor,
  633. fontWeight: reportMode ? 600 : 500,
  634. },
  635. subtextStyle: {
  636. fontSize: 10,
  637. color: axisColor,
  638. opacity: 0.82,
  639. },
  640. itemGap: chartSubtitle ? 4 : 8,
  641. }
  642. : undefined,
  643. toolbox: reportMode
  644. ? undefined
  645. : {
  646. show: true,
  647. top: 4,
  648. right: sideLegend ? 168 : hasLegend ? 112 : 12,
  649. itemSize: 14,
  650. itemGap: 10,
  651. iconStyle: {
  652. borderColor: axisColor,
  653. },
  654. emphasis: {
  655. iconStyle: {
  656. borderColor: titleColor,
  657. },
  658. },
  659. feature: {
  660. restore: {
  661. show: true,
  662. title: "还原视角",
  663. },
  664. saveAsImage: {
  665. show: true,
  666. title: "保存图片",
  667. name: chartTitle || "全场风功率图",
  668. backgroundColor: "#0f172a",
  669. pixelRatio: 2,
  670. },
  671. },
  672. },
  673. tooltip: {
  674. show: true,
  675. formatter(params) {
  676. if (!params) return "";
  677. const val = params.value || params.data;
  678. if (!Array.isArray(val)) return "";
  679. const [turbinePosition, windSpeed, power] = val;
  680. const turbineText = formatTurbineAxisLabel(
  681. resolveTurbineIndexFromPosition(turbinePosition),
  682. turbineMeta.indexToName,
  683. );
  684. const turbineLine = turbineText
  685. ? `<br/>${WIND_POWER_3D_AXIS.x}: ${turbineText}`
  686. : "";
  687. return `${params.marker || ""}${params.seriesName || ""}<br/>${WIND_POWER_3D_AXIS.y}: ${windSpeed}${turbineLine}<br/>${WIND_POWER_3D_AXIS.z}: ${power}`;
  688. },
  689. },
  690. legend: sideLegend
  691. ? sideLegend
  692. : hasLegend
  693. ? {
  694. type: "scroll",
  695. orient: "vertical",
  696. right: reportMode ? 4 : 0,
  697. top: chartTitle ? (reportMode ? 40 : 36) : 12,
  698. bottom: 8,
  699. width: 96,
  700. itemWidth: 12,
  701. itemHeight: 12,
  702. icon: "circle",
  703. itemGap: 5,
  704. pageButtonItemGap: 6,
  705. pageIconSize: 10,
  706. textStyle: { color: legendColor, fontSize: 10 },
  707. data: legendData,
  708. }
  709. : undefined,
  710. grid3D: {
  711. ...grid3DLayout,
  712. light: {
  713. main: {
  714. intensity: 1.35,
  715. shadow: reportMode && templateKey !== "wind_power_scatter",
  716. alpha: 35,
  717. beta: 120,
  718. },
  719. ambient: {
  720. intensity:
  721. reportMode && templateKey === "wind_power_scatter" ? 1.05 : 0.92,
  722. },
  723. },
  724. },
  725. xAxis3D: {
  726. type: "value",
  727. name: WIND_POWER_3D_AXIS.x,
  728. min: 0,
  729. max: turbineAxisMax,
  730. interval: turbineAxisLabel.interval,
  731. nameGap: 28,
  732. nameTextStyle: { color: axisColor, fontSize: 10 },
  733. // Puppeteer 无法序列化 formatter,报告侧靠此字段重建抽样标签
  734. _turbineLabelMeta: {
  735. gap: turbineAxisLabel.gap,
  736. labels: turbineAxisLabel.labelMap,
  737. },
  738. axisLabel: {
  739. color: axisColor,
  740. fontSize: reportMode ? 8 : 9,
  741. hideOverlap: true,
  742. showMinLabel: true,
  743. showMaxLabel: true,
  744. margin: 4,
  745. formatter: turbineAxisLabel.formatter,
  746. },
  747. axisLine: { lineStyle: { color: splitColor, opacity: 0.65 } },
  748. splitLine: buildAxisSplitStyle(splitColor, true),
  749. },
  750. yAxis3D: {
  751. type: "value",
  752. name: resolveWindSpeedAxisName(panel),
  753. min: plotExtents.windMin,
  754. max: plotExtents.windMax,
  755. splitNumber: 5,
  756. nameGap: 22,
  757. nameTextStyle: { color: axisColor, fontSize: 10, fontWeight: 500 },
  758. axisLabel: {
  759. color: axisColor,
  760. fontSize: 9,
  761. margin: 4,
  762. formatter: reportMode
  763. ? (value) => formatReportValueAxisLabel(value)
  764. : undefined,
  765. },
  766. axisLine: { lineStyle: { color: splitColor, opacity: 0.8 } },
  767. splitLine: buildAxisSplitStyle(splitColor, true),
  768. },
  769. zAxis3D: {
  770. type: "value",
  771. name: resolvePowerAxisName(panel),
  772. min: plotExtents.powerMin,
  773. max: plotExtents.powerMax,
  774. splitNumber: 5,
  775. nameGap: 24,
  776. nameTextStyle: { color: axisColor, fontSize: 10, fontWeight: 500 },
  777. axisLabel: {
  778. color: axisColor,
  779. fontSize: 9,
  780. margin: 4,
  781. formatter: reportMode
  782. ? (value) => formatReportValueAxisLabel(value)
  783. : undefined,
  784. },
  785. axisLine: { lineStyle: { color: splitColor, opacity: 0.8 } },
  786. splitLine: buildAxisSplitStyle(splitColor, true),
  787. },
  788. series,
  789. };
  790. }
  791. export function buildWindPower3DPanelOption(panel, options = {}) {
  792. return buildWindPower3DChartOption({
  793. panel,
  794. templateKey: options.templateKey || "",
  795. title: options.title || panel?.title || "",
  796. farmMode: Boolean(options.farmMode),
  797. pickColor: options.pickColor,
  798. theme: options.theme || {},
  799. reportMode: Boolean(options.reportMode),
  800. });
  801. }