anomalyDiagnosticVisual.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807
  1. /**
  2. * 异常检测图表视觉语义(前端全场图 + 报告共用)
  3. *
  4. * 原则:
  5. * - 颜色优先表达诊断结论,不用彩虹色表达大量风机编号
  6. * - 异常使用红色和更高层级,散点统一圆形,避免只依赖颜色
  7. * - 连续变量使用感知均匀、色盲友好的 Viridis 色带
  8. */
  9. export const DIAGNOSTIC_COLORS = {
  10. normal: "#2E90FA",
  11. anomaly: "#F04438",
  12. warning: "#F79009",
  13. reference: "#98A2B3",
  14. contract: "#E11D48",
  15. /** 全场风功率总图合同曲线:与异常红、正常蓝分开 */
  16. farmContract: "#7C3AED",
  17. mean: "#7F56D9",
  18. /** 单机降载点:深琥珀,浅底上要和正常蓝点分开 */
  19. deload: "#A16207",
  20. /** 单机风功率散点:停机 */
  21. shutdown: "#111827",
  22. blade1: "#2E90FA",
  23. blade2: "#12B76A",
  24. blade3: "#7F56D9",
  25. };
  26. export const DIAGNOSTIC_CATEGORICAL_COLORS = [
  27. "#2E90FA",
  28. "#12B76A",
  29. "#F79009",
  30. "#7F56D9",
  31. "#06AED4",
  32. "#EE46BC",
  33. "#6172F3",
  34. "#47CD89",
  35. "#FDB022",
  36. "#84ADFF",
  37. ];
  38. /** 全场总图:正常风机冷色,异常风机红橙暖色;同组内用不同色相区分机组 */
  39. export const FARM_NORMAL_COOL_COLORS = Object.freeze([
  40. "#3B82F6",
  41. "#06B6D4",
  42. "#0EA5E9",
  43. "#2563EB",
  44. "#14B8A6",
  45. "#6366F1",
  46. "#38BDF8",
  47. "#0D9488",
  48. ]);
  49. export const FARM_ANOMALY_WARM_COLORS = Object.freeze([
  50. "#F04438",
  51. "#F97316",
  52. "#EF4444",
  53. "#FB7185",
  54. "#EA580C",
  55. "#E11D48",
  56. "#F59E0B",
  57. "#DC2626",
  58. ]);
  59. export const DIAGNOSTIC_CONTINUOUS_COLORS = [
  60. "#440154",
  61. "#3B528B",
  62. "#21918C",
  63. "#5EC962",
  64. "#FDE725",
  65. ];
  66. /** 报告点数色带:白 → 浅蓝 → 深蓝(浅底报告) */
  67. export const DIAGNOSTIC_COUNT_COLORS = [
  68. "#FFFFFF",
  69. "#D6E8F8",
  70. "#7EB6E0",
  71. "#2E75B6",
  72. "#0B3A6E",
  73. ];
  74. /**
  75. * 点数色带:低点数深、高点数亮。页面使用。
  76. */
  77. export const PAGE_DIAGNOSTIC_COUNT_COLORS = [
  78. "#1a2740",
  79. "#234b73",
  80. "#2f74a8",
  81. "#3d9fd4",
  82. "#6ec8f0",
  83. ];
  84. /**
  85. * 单机风功率散点「停机」点色。页面科技蓝深底需浅色才可见,
  86. * 浅色主题沿用 DIAGNOSTIC_COLORS.shutdown;报告始终用 shutdown 深色。
  87. */
  88. export const PAGE_SHUTDOWN_COLORS = Object.freeze({
  89. dark: "#CBD5E1",
  90. light: DIAGNOSTIC_COLORS.shutdown,
  91. });
  92. /** 报告最小桨距角色带停靠:0% / 10% / 20% / 30% / 100% */
  93. export const PITCH_MIN_REPORT_COLOR_STOPS = [
  94. [0, "#e6f4f5"],
  95. [0.1, "#74ABF7"],
  96. [0.2, "#3E7AF6"],
  97. [0.3, "#0000E4"],
  98. [1, "#020085"],
  99. ];
  100. function hexToRgb(hex) {
  101. const text = String(hex || "").replace("#", "");
  102. return [
  103. parseInt(text.slice(0, 2), 16),
  104. parseInt(text.slice(2, 4), 16),
  105. parseInt(text.slice(4, 6), 16),
  106. ];
  107. }
  108. function rgbToHex(rgb) {
  109. return `#${rgb
  110. .map((value) =>
  111. Math.max(0, Math.min(255, Math.round(value)))
  112. .toString(16)
  113. .padStart(2, "0"),
  114. )
  115. .join("")}`;
  116. }
  117. /** 按停靠位置铺成等距色阶,供 ECharts 连续色带按比例取色 */
  118. export function expandColorStops(stops, steps = 101) {
  119. const sorted = [...stops].sort((a, b) => a[0] - b[0]);
  120. const colors = [];
  121. const last = Math.max(steps - 1, 1);
  122. for (let index = 0; index < steps; index += 1) {
  123. const t = index / last;
  124. let upper = sorted.findIndex((stop) => stop[0] >= t);
  125. if (upper < 0) upper = sorted.length - 1;
  126. const lower = Math.max(0, upper - (sorted[upper][0] === t ? 0 : 1));
  127. const [startT, startColor] = sorted[lower];
  128. const [endT, endColor] = sorted[upper];
  129. if (startT === endT) {
  130. colors.push(endColor);
  131. continue;
  132. }
  133. const ratio = (t - startT) / (endT - startT);
  134. const startRgb = hexToRgb(startColor);
  135. const endRgb = hexToRgb(endColor);
  136. colors.push(
  137. rgbToHex(
  138. startRgb.map(
  139. (value, channel) => value + (endRgb[channel] - value) * ratio,
  140. ),
  141. ),
  142. );
  143. }
  144. return colors;
  145. }
  146. export const PITCH_MIN_REPORT_COUNT_COLORS = expandColorStops(
  147. PITCH_MIN_REPORT_COLOR_STOPS,
  148. );
  149. export const DIAGNOSTIC_FARM_STYLES = Object.freeze({
  150. normal: Object.freeze({
  151. pointOpacity: 0.5,
  152. lineWidth: 1,
  153. lineOpacity: 0.5,
  154. z: 1,
  155. }),
  156. anomaly: Object.freeze({
  157. pointOpacity: 1,
  158. lineWidth: 2.4,
  159. lineOpacity: 1,
  160. z: 20,
  161. }),
  162. contract: Object.freeze({
  163. pointOpacity: 1,
  164. lineWidth: 3.8,
  165. lineOpacity: 1,
  166. z: 40,
  167. }),
  168. category: Object.freeze({
  169. pointOpacity: 0.88,
  170. lineWidth: 1.8,
  171. lineOpacity: 1,
  172. z: 2,
  173. }),
  174. });
  175. /** 全场图:正常点统一色、异常点统一红,图例按异常机组优先 */
  176. export const FARM_STATUS_LEGEND_DETECTORS = new Set([
  177. "wind_power_curve",
  178. "wind_power_scatter",
  179. "yaw_static",
  180. "yaw_twist",
  181. "pitch_coord",
  182. "pitch_regulation",
  183. "ctrl_power_quality",
  184. "ctrl_op_state",
  185. "ctrl_deload",
  186. "aero_cp",
  187. "aero_tsr",
  188. "aero_tsr_wind",
  189. ]);
  190. /** 按散点/点状态上色(正常点蓝、异常点红;以 pointAnomaly 或系列名拆分为准) */
  191. const FARM_POINT_STATUS_COLOR_DETECTORS = new Set([
  192. "wind_power_curve",
  193. "wind_power_scatter",
  194. "yaw_static",
  195. "yaw_twist",
  196. "pitch_coord",
  197. "aero_cp",
  198. "aero_tsr",
  199. "aero_tsr_wind",
  200. ]);
  201. export function usesFarmPointStatusColor(templateKey = "") {
  202. return FARM_POINT_STATUS_COLOR_DETECTORS.has(templateKey);
  203. }
  204. export function usesFarmTurbineStatusColor(templateKey = "") {
  205. return (
  206. FARM_STATUS_LEGEND_DETECTORS.has(templateKey) &&
  207. !FARM_POINT_STATUS_COLOR_DETECTORS.has(templateKey)
  208. );
  209. }
  210. /**
  211. * 静态偏航误差绝对值分档(度):
  212. * [0,3]、 (3,5]、 >5
  213. */
  214. export const YAW_ERROR_ABS_RANGES = Object.freeze([
  215. Object.freeze({
  216. key: "low",
  217. label: "[0,3]",
  218. color: DIAGNOSTIC_COLORS.blade2,
  219. }),
  220. Object.freeze({
  221. key: "mid",
  222. label: "(3,5]",
  223. color: DIAGNOSTIC_COLORS.warning,
  224. }),
  225. Object.freeze({
  226. key: "high",
  227. label: ">5",
  228. color: DIAGNOSTIC_COLORS.anomaly,
  229. }),
  230. ]);
  231. export function classifyYawErrorAbsRange(value) {
  232. const abs = Math.abs(Number(value));
  233. if (!Number.isFinite(abs)) return null;
  234. if (abs <= 3) return YAW_ERROR_ABS_RANGES[0];
  235. if (abs <= 5) return YAW_ERROR_ABS_RANGES[1];
  236. return YAW_ERROR_ABS_RANGES[2];
  237. }
  238. export function yawErrorAbsRangeColor(value) {
  239. return classifyYawErrorAbsRange(value)?.color || DIAGNOSTIC_COLORS.normal;
  240. }
  241. export function countYawErrorAbsRanges(values = []) {
  242. const counts = YAW_ERROR_ABS_RANGES.map(() => 0);
  243. values.forEach((value) => {
  244. const range = classifyYawErrorAbsRange(value);
  245. if (!range) return;
  246. const idx = YAW_ERROR_ABS_RANGES.findIndex(
  247. (item) => item.key === range.key,
  248. );
  249. if (idx >= 0) counts[idx] += 1;
  250. });
  251. return counts;
  252. }
  253. export const FARM_STATUS_NORMAL_LEGEND = "正常机组";
  254. /** 全场单值柱图:正常统一色、异常红,图例优先列出异常机组 */
  255. export function buildFarmStatusBarSeries({
  256. categories = [],
  257. values = [],
  258. extras = [],
  259. anomalyFlags = [],
  260. } = {}) {
  261. const flags = categories.map((_, idx) => Boolean(anomalyFlags[idx]));
  262. const abnormalNames = categories.filter((_, idx) => flags[idx]);
  263. const hasNormal = flags.some((flag) => !flag);
  264. const makeItem = (idx, color) => ({
  265. name: categories[idx],
  266. value: values[idx],
  267. extra: extras[idx],
  268. itemStyle: { color },
  269. });
  270. const series = [];
  271. if (hasNormal) {
  272. series.push({
  273. type: "bar",
  274. name: FARM_STATUS_NORMAL_LEGEND,
  275. color: DIAGNOSTIC_COLORS.normal,
  276. itemStyle: { color: DIAGNOSTIC_COLORS.normal },
  277. data: categories.map((_, idx) =>
  278. flags[idx] ? null : makeItem(idx, DIAGNOSTIC_COLORS.normal),
  279. ),
  280. barMaxWidth: 28,
  281. barGap: "-100%",
  282. });
  283. }
  284. abnormalNames.forEach((name) => {
  285. const hit = categories.indexOf(name);
  286. series.push({
  287. type: "bar",
  288. name,
  289. color: DIAGNOSTIC_COLORS.anomaly,
  290. itemStyle: { color: DIAGNOSTIC_COLORS.anomaly },
  291. data: categories.map((_, idx) =>
  292. idx === hit ? makeItem(idx, DIAGNOSTIC_COLORS.anomaly) : null,
  293. ),
  294. barMaxWidth: 28,
  295. barGap: "-100%",
  296. });
  297. });
  298. return {
  299. legendNames: [
  300. ...(hasNormal ? [FARM_STATUS_NORMAL_LEGEND] : []),
  301. ...abnormalNames,
  302. ],
  303. series,
  304. };
  305. }
  306. export function usesFarmStatusLegend(templateKey = "") {
  307. return FARM_STATUS_LEGEND_DETECTORS.has(templateKey);
  308. }
  309. function seriesText(rowOrLabel) {
  310. if (rowOrLabel && typeof rowOrLabel === "object") {
  311. return `${rowOrLabel.originalLabel || ""} ${rowOrLabel.label || ""}`;
  312. }
  313. return String(rowOrLabel || "");
  314. }
  315. /** 散点图内应叠加为折线的参考/功率曲线(非运行状态散点) */
  316. export function isOverlayCurveSeries(rowOrLabel) {
  317. if (rowOrLabel?.__shared) return true;
  318. const text = seriesText(rowOrLabel);
  319. if (/参考功率|理论功率|合同功率|参考曲线/.test(text)) return true;
  320. if (/近90日.*功率曲线|近90日功率曲线/.test(text)) return true;
  321. return false;
  322. }
  323. /** 全场总图跨机组去重保留一条的参考曲线 */
  324. export function isSharedOverlayCurveSeries(rowOrLabel) {
  325. const text = seriesText(rowOrLabel);
  326. if (/参考功率|理论功率|合同功率|参考曲线/.test(text)) return true;
  327. if (/近90日.*功率曲线|近90日功率曲线/.test(text)) return true;
  328. return false;
  329. }
  330. export function sharedOverlayCurveLabel(label) {
  331. const text = String(label || "");
  332. if (/近90日/.test(text)) return "近90日功率曲线";
  333. if (/合同功率/.test(text)) return "合同功率曲线";
  334. if (/参考功率|理论功率|参考曲线/.test(text)) return "参考功率曲线";
  335. return text;
  336. }
  337. export function isContractPowerCurve(rowOrLabel) {
  338. return /合同功率/.test(seriesText(rowOrLabel));
  339. }
  340. /** 单机图:合同功率曲线在散点/曲线下方,异常点压在正常点上面 */
  341. export const SINGLE_TURBINE_CONTRACT_Z = 1;
  342. export const SINGLE_TURBINE_SERIES_Z = 2;
  343. export const SINGLE_TURBINE_ANOMALY_Z = 8;
  344. export function diagnosticSingleSeriesZ(rowOrLabel) {
  345. const role = classifyDiagnosticSeries(rowOrLabel);
  346. if (role === "contract") return SINGLE_TURBINE_CONTRACT_Z;
  347. if (role === "anomaly" || role === "deload" || role === "shutdown") {
  348. return SINGLE_TURBINE_ANOMALY_Z;
  349. }
  350. return SINGLE_TURBINE_SERIES_Z;
  351. }
  352. function readSeriesCoord(point) {
  353. if (Array.isArray(point)) return point;
  354. if (Array.isArray(point?.value)) return point.value;
  355. return null;
  356. }
  357. /** 单机图里异常系列的坐标范围,用来把被 yrange 裁掉的异常点拉回坐标轴 */
  358. export function anomalySeriesAxisExtent(series = []) {
  359. let xMin = Infinity;
  360. let xMax = -Infinity;
  361. let yMin = Infinity;
  362. let yMax = -Infinity;
  363. let found = false;
  364. (series || []).forEach((item) => {
  365. if (classifyDiagnosticSeries(item?.name) !== "anomaly") return;
  366. (item.data || []).forEach((point) => {
  367. const coord = readSeriesCoord(point);
  368. if (!coord) return;
  369. const x = Number(coord[0]);
  370. const y = Number(coord[1]);
  371. if (Number.isFinite(x)) {
  372. if (x < xMin) xMin = x;
  373. if (x > xMax) xMax = x;
  374. found = true;
  375. }
  376. if (Number.isFinite(y)) {
  377. if (y < yMin) yMin = y;
  378. if (y > yMax) yMax = y;
  379. found = true;
  380. }
  381. });
  382. });
  383. if (!found) return null;
  384. return {
  385. xMin: Number.isFinite(xMin) ? xMin : null,
  386. xMax: Number.isFinite(xMax) ? xMax : null,
  387. yMin: Number.isFinite(yMin) ? yMin : null,
  388. yMax: Number.isFinite(yMax) ? yMax : null,
  389. };
  390. }
  391. /** 已锁定的轴范围若把异常点裁在外面,就扩到能包住这些点 */
  392. export function expandAxisToExtent(axis, dataMin, dataMax) {
  393. if (!axis || dataMin == null || dataMax == null) return axis;
  394. const min = Number(axis.min);
  395. const max = Number(axis.max);
  396. if (Number.isFinite(min) && dataMin < min) axis.min = dataMin;
  397. if (Number.isFinite(max) && dataMax > max) axis.max = dataMax;
  398. return axis;
  399. }
  400. /** 变桨-转速-功率协调:横轴固定从该值起展示 */
  401. export const PITCH_COORD_X_MIN = 1000;
  402. /** 变桨协调仍读取 yrange;横轴最小值固定,不跟 xrange 起点走 */
  403. export function usesDataAxisRange(templateKey) {
  404. return templateKey === "pitch_coord";
  405. }
  406. /**
  407. * 变桨协调横轴:最小固定为 1000。xrange 上限大于 1000 时沿用,否则最大值自适应。
  408. * @returns {boolean} 横轴最小值已锁定
  409. */
  410. export function applyPitchCoordXAxis(axis, xrange) {
  411. if (!axis) return false;
  412. axis.min = PITCH_COORD_X_MIN;
  413. const max = Array.isArray(xrange) ? Number(xrange[1]) : NaN;
  414. if (Number.isFinite(max) && max > PITCH_COORD_X_MIN) {
  415. axis.max = max;
  416. axis.scale = false;
  417. } else {
  418. axis.max = undefined;
  419. axis.scale = true;
  420. }
  421. return true;
  422. }
  423. /**
  424. * 有 xrange / yrange 就设为坐标轴 min/max。
  425. * @returns {boolean} 是否已锁定
  426. */
  427. export function lockAxisToRange(axis, range, convert) {
  428. if (!axis || !Array.isArray(range) || range.length < 2) return false;
  429. if (
  430. range[0] == null ||
  431. range[1] == null ||
  432. range[0] === "" ||
  433. range[1] === ""
  434. ) {
  435. return false;
  436. }
  437. const min = typeof convert === "function" ? convert(range[0]) : range[0];
  438. const max = typeof convert === "function" ? convert(range[1]) : range[1];
  439. if (min == null || max == null || min === "" || max === "") return false;
  440. if (typeof min === "number" && !Number.isFinite(min)) return false;
  441. if (typeof max === "number" && !Number.isFinite(max)) return false;
  442. axis.min = min;
  443. axis.max = max;
  444. axis.scale = false;
  445. return true;
  446. }
  447. export function classifyDiagnosticSeries(rowOrLabel) {
  448. const text = seriesText(rowOrLabel);
  449. if (/合同功率/.test(text)) return "contract";
  450. if (/桨叶\s*1/.test(text)) return "blade1";
  451. if (/桨叶\s*2/.test(text)) return "blade2";
  452. if (/桨叶\s*3/.test(text)) return "blade3";
  453. // 单机风功率散点运行状态(须先于「正常/异常」通用匹配)
  454. if (/停机/.test(text)) return "shutdown";
  455. if (/降载/.test(text) && !/累计降载|降载率|降载占比|降载判定/.test(text)) {
  456. return "deload";
  457. }
  458. if (/异常/.test(text) && !/正常/.test(text)) return "anomaly";
  459. if (/上限|下限|阈值|警戒|频繁/.test(text)) return "warning";
  460. if (/均值|平均|趋势/.test(text)) return "mean";
  461. if (isOverlayCurveSeries(rowOrLabel)) return "reference";
  462. if (/参考|理论|合同|基准|标准/.test(text) || rowOrLabel?.__shared) {
  463. return "reference";
  464. }
  465. if (/正常/.test(text)) return "normal";
  466. return "category";
  467. }
  468. /**
  469. * 系列颜色。options.pageTheme 时「停机」改用页面可见色(深色主题浅灰),
  470. * 报告与单机浅色主题不传该选项,保持 shutdown 深色不变。
  471. */
  472. export function diagnosticSeriesColor(rowOrLabel, index = 0, options = {}) {
  473. const role = classifyDiagnosticSeries(rowOrLabel);
  474. if (options.pageTheme && role === "shutdown") {
  475. return options.darkTheme
  476. ? PAGE_SHUTDOWN_COLORS.dark
  477. : PAGE_SHUTDOWN_COLORS.light;
  478. }
  479. if (DIAGNOSTIC_COLORS[role]) return DIAGNOSTIC_COLORS[role];
  480. const safeIndex = Math.abs(Number(index) || 0);
  481. return DIAGNOSTIC_CATEGORICAL_COLORS[
  482. safeIndex % DIAGNOSTIC_CATEGORICAL_COLORS.length
  483. ];
  484. }
  485. function paletteIndexOf(rowOrLabel, index = 0) {
  486. const turbineIndex = Number(rowOrLabel?.turbineIndex);
  487. if (Number.isFinite(turbineIndex)) return Math.abs(turbineIndex);
  488. return Math.abs(Number(index) || 0);
  489. }
  490. function turbineAnomalyFlag(rowOrLabel) {
  491. if (!rowOrLabel || typeof rowOrLabel !== "object") return null;
  492. // pointAnomaly 拆分后的行级 anomaly 优先于整机 extra.anomaly,避免正常点被整机标红
  493. if (typeof rowOrLabel.anomaly === "boolean") return rowOrLabel.anomaly;
  494. if (typeof rowOrLabel.extra?.anomaly === "boolean")
  495. return rowOrLabel.extra.anomaly;
  496. return null;
  497. }
  498. /**
  499. * 全场总图是否按「异常风机」上色。
  500. */
  501. export function isFarmAnomalyTurbine(rowOrLabel, templateKey = "") {
  502. const flagged = turbineAnomalyFlag(rowOrLabel);
  503. if (typeof flagged === "boolean") return flagged;
  504. return classifyDiagnosticSeries(rowOrLabel) === "anomaly";
  505. }
  506. export function farmStatusPaletteColor(
  507. rowOrLabel,
  508. index = 0,
  509. templateKey = "",
  510. ) {
  511. const palette = isFarmAnomalyTurbine(rowOrLabel, templateKey)
  512. ? FARM_ANOMALY_WARM_COLORS
  513. : FARM_NORMAL_COOL_COLORS;
  514. return palette[paletteIndexOf(rowOrLabel, index) % palette.length];
  515. }
  516. /**
  517. * 全场图颜色语义:
  518. * - 风功率曲线/散点、偏航分析 / 扭缆 / 变桨协调、气动 Cp/TSR:正常点蓝、异常点红(pointAnomaly);
  519. * - 变桨一致性、电能质量、运行状态、降载:异常机组整机红,正常机组统一蓝(无 pointAnomaly 时);有 pointAnomaly 时仍按点拆分后的行级 anomaly;
  520. * - 参考 / 阈值 / 均值保持语义色。
  521. * - 单机「降载黄 / 停机黑」仅用于单机风功率散点;全场总图一律红/蓝。
  522. */
  523. export function diagnosticFarmSeriesColor(
  524. rowOrLabel,
  525. index = 0,
  526. templateKey = "",
  527. ) {
  528. const role = classifyDiagnosticSeries(rowOrLabel);
  529. if (
  530. role === "contract" ||
  531. role === "reference" ||
  532. role === "warning" ||
  533. role === "mean"
  534. ) {
  535. return diagnosticSeriesColor(rowOrLabel, index);
  536. }
  537. if (templateKey === "wind_power_curve") {
  538. return isFarmAnomalyTurbine(rowOrLabel, templateKey)
  539. ? DIAGNOSTIC_COLORS.anomaly
  540. : DIAGNOSTIC_COLORS.normal;
  541. }
  542. if (usesFarmPointStatusColor(templateKey)) {
  543. return role === "anomaly" || role === "deload" || role === "shutdown"
  544. ? DIAGNOSTIC_COLORS.anomaly
  545. : DIAGNOSTIC_COLORS.normal;
  546. }
  547. if (usesFarmTurbineStatusColor(templateKey)) {
  548. // 报告拼接单机图时可能残留 label=降载/停机,按异常红而非单机黄/黑
  549. if (role === "deload" || role === "shutdown") {
  550. return DIAGNOSTIC_COLORS.anomaly;
  551. }
  552. return isFarmAnomalyTurbine(rowOrLabel, templateKey)
  553. ? DIAGNOSTIC_COLORS.anomaly
  554. : DIAGNOSTIC_COLORS.normal;
  555. }
  556. if (role === "deload" || role === "shutdown") {
  557. return diagnosticSeriesColor(rowOrLabel, index);
  558. }
  559. return farmStatusPaletteColor(rowOrLabel, index, templateKey);
  560. }
  561. export function farmTurbineLegendName(rowOrLabel) {
  562. const obj = rowOrLabel && typeof rowOrLabel === "object" ? rowOrLabel : null;
  563. let text = String(obj?.engineName || obj?.turbine || "").trim();
  564. if (!text) {
  565. text = String(obj?.label || rowOrLabel || "")
  566. .replace(/\s*(正常数据|异常点|正常|异常|降载|停机)$/u, "")
  567. .trim();
  568. }
  569. if (!text) return "";
  570. if (/^\d+$/.test(text)) return `${text}号`;
  571. return text;
  572. }
  573. export function farmStatusSeriesName(
  574. row,
  575. legendMode = "normal",
  576. templateKey = "",
  577. ) {
  578. const turbine = farmTurbineLegendName(row);
  579. if (!turbine) return row?.label || "";
  580. if (legendMode !== "anomaly") return turbine;
  581. const role = classifyDiagnosticSeries(row);
  582. const matchLegend = usesFarmTurbineStatusColor(templateKey)
  583. ? isFarmAnomalyTurbine(row, templateKey) ||
  584. role === "deload" ||
  585. role === "shutdown"
  586. : role === "anomaly" || role === "deload" || role === "shutdown";
  587. return matchLegend ? turbine : `${turbine} 正常`;
  588. }
  589. export function pickFarmStatusLegend(rows = [], templateKey = "") {
  590. const map = new Map();
  591. let hasNormal = false;
  592. (rows || []).forEach((row) => {
  593. if (!row || row.__shared) return;
  594. const role = classifyDiagnosticSeries(row);
  595. if (
  596. role === "contract" ||
  597. role === "reference" ||
  598. role === "warning" ||
  599. role === "mean"
  600. ) {
  601. return;
  602. }
  603. const name = farmTurbineLegendName(row);
  604. if (!name) return;
  605. const anomalous = usesFarmTurbineStatusColor(templateKey)
  606. ? isFarmAnomalyTurbine(row, templateKey) ||
  607. role === "deload" ||
  608. role === "shutdown"
  609. : role === "anomaly" ||
  610. role === "deload" ||
  611. role === "shutdown" ||
  612. isFarmAnomalyTurbine(row, templateKey);
  613. if (usesFarmPointStatusColor(templateKey)) {
  614. if (role === "normal") hasNormal = true;
  615. } else if (!anomalous) {
  616. hasNormal = true;
  617. }
  618. map.set(name, Boolean(map.get(name)) || anomalous);
  619. });
  620. const entries = [...map.entries()];
  621. const abnormal = entries.filter(([, flag]) => flag).map(([name]) => name);
  622. if (abnormal.length) {
  623. return {
  624. mode: "anomaly",
  625. names: hasNormal ? [FARM_STATUS_NORMAL_LEGEND, ...abnormal] : abnormal,
  626. };
  627. }
  628. return { mode: "normal", names: entries.map(([name]) => name) };
  629. }
  630. export function buildFarmNormalLegendSeries() {
  631. return {
  632. name: FARM_STATUS_NORMAL_LEGEND,
  633. type: "scatter",
  634. data: [],
  635. symbol: "circle",
  636. color: DIAGNOSTIC_COLORS.normal,
  637. itemStyle: { color: DIAGNOSTIC_COLORS.normal },
  638. tooltip: { show: false },
  639. silent: true,
  640. z: 0,
  641. };
  642. }
  643. export function ensureFarmNormalLegendSeries(series = [], legendPick) {
  644. if (!legendPick?.names?.includes(FARM_STATUS_NORMAL_LEGEND)) return series;
  645. if (series.some((item) => item.name === FARM_STATUS_NORMAL_LEGEND)) {
  646. return series;
  647. }
  648. return [buildFarmNormalLegendSeries(), ...series];
  649. }
  650. /** 全场散点统一点径:正常点与异常点一样大,只靠颜色和绘制顺序区分 */
  651. export const FARM_SCATTER_SYMBOL_SIZE = 4;
  652. export function diagnosticFarmSymbolSize(
  653. _rowOrLabel,
  654. _pointCount = 0,
  655. _templateKey = "",
  656. ) {
  657. return FARM_SCATTER_SYMBOL_SIZE;
  658. }
  659. export function diagnosticFarmStyle(rowOrLabel, templateKey = "") {
  660. const role = classifyDiagnosticSeries(rowOrLabel);
  661. if (role === "contract") {
  662. return DIAGNOSTIC_FARM_STYLES.contract;
  663. }
  664. if (role === "reference" || role === "warning" || role === "mean") {
  665. return Object.freeze({
  666. ...(DIAGNOSTIC_FARM_STYLES[role] || DIAGNOSTIC_FARM_STYLES.category),
  667. z: 30,
  668. });
  669. }
  670. const base = DIAGNOSTIC_FARM_STYLES[role] || DIAGNOSTIC_FARM_STYLES.category;
  671. const pointAnomalyRole =
  672. role === "anomaly" || role === "deload" || role === "shutdown";
  673. const farmAnomaly =
  674. isFarmAnomalyTurbine(rowOrLabel, templateKey) ||
  675. role === "deload" ||
  676. role === "shutdown";
  677. if (pointAnomalyRole) {
  678. const anomaly = DIAGNOSTIC_FARM_STYLES.anomaly;
  679. return {
  680. ...base,
  681. z: anomaly.z,
  682. lineWidth: Math.max(base.lineWidth, anomaly.lineWidth),
  683. lineOpacity: anomaly.lineOpacity,
  684. pointOpacity: 1,
  685. };
  686. }
  687. if (usesFarmTurbineStatusColor(templateKey) && farmAnomaly) {
  688. const anomaly = DIAGNOSTIC_FARM_STYLES.anomaly;
  689. return {
  690. ...base,
  691. z: anomaly.z,
  692. lineWidth: Math.max(base.lineWidth, anomaly.lineWidth),
  693. lineOpacity: anomaly.lineOpacity,
  694. pointOpacity: anomaly.pointOpacity,
  695. };
  696. }
  697. return base;
  698. }
  699. /** 全场总图绘制顺序:异常点压在正常点上面,仍与坐标轴同一层,避免独立 zlevel 丢点 */
  700. export function farmSeriesPaintOrder(rowOrLabel, templateKey = "") {
  701. const style = diagnosticFarmStyle(rowOrLabel, templateKey);
  702. const role = classifyDiagnosticSeries(rowOrLabel);
  703. const lifted =
  704. role === "anomaly" ||
  705. role === "deload" ||
  706. role === "shutdown" ||
  707. (usesFarmTurbineStatusColor(templateKey) &&
  708. isFarmAnomalyTurbine(rowOrLabel, templateKey) &&
  709. role !== "normal" &&
  710. role !== "contract" &&
  711. role !== "reference" &&
  712. role !== "warning" &&
  713. role !== "mean");
  714. return {
  715. z: lifted ? Math.max(Number(style.z) || 0, 20) : Number(style.z) || 1,
  716. z2: lifted ? 30 : 0,
  717. zlevel: 0,
  718. };
  719. }
  720. export function diagnosticBladeStyle(rowOrLabel) {
  721. const role = classifyDiagnosticSeries(rowOrLabel);
  722. if (role === "blade1") {
  723. return { symbol: "rect", lineType: "dashed", showSymbol: true };
  724. }
  725. if (role === "blade2") {
  726. return { symbol: "circle", lineType: "solid", showSymbol: true };
  727. }
  728. if (role === "blade3") {
  729. return { symbol: "triangle", lineType: "dotted", showSymbol: true };
  730. }
  731. return null;
  732. }
  733. export function diagnosticSeriesSymbol(rowOrLabel, fallback = "circle") {
  734. const role = classifyDiagnosticSeries(rowOrLabel);
  735. if (role === "blade1") return "rect";
  736. if (role === "blade2") return "circle";
  737. if (role === "blade3") return "triangle";
  738. return fallback || "circle";
  739. }
  740. export function diagnosticLineType(rowOrLabel) {
  741. const role = classifyDiagnosticSeries(rowOrLabel);
  742. if (role === "contract") return "solid";
  743. if (role === "warning" || role === "reference" || role === "mean") {
  744. return "dashed";
  745. }
  746. if (role === "blade1") return "dashed";
  747. if (role === "blade3") return "dotted";
  748. return "solid";
  749. }