panelBlocks.js 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745
  1. /**
  2. * 面板块的**共享积木**(票 18 从票 17 的 `domain/infrastructurePanels.js` 提出来)。
  3. *
  4. * 为什么单独成一层:票 17 已判定「四块面板的形状是同一件事(几个 KPI + 若干分布/清单),
  5. * 差异只在字段与文案」,并把这句话兑现成了一个共享渲染层。票 18/19/20 沿用同一形状,
  6. * 于是「怎么画一个块」与「某个模式的面板里放哪些块」是两件事,后者各票一份、前者只有一份。
  7. * 不提出来的话 `domain/monitoringPanels.js` 就得 import 「基础设施」那个模块来拿这几个构造函数,
  8. * 那正是票 17 警告的耦合(下游三票会长出十二份实现)。
  9. *
  10. * 本文件全是纯函数:不 import service、不碰 DOM、不自己发请求(票 05 的 D3/D4 守卫盯着)。
  11. *
  12. * 三条纪律(票 05/11/16 定下,票 17 收口在这里,本票一字不改):
  13. * · **缺值显示占位符,不显示虚假零值**:`numberish` 认不出就回 null,界面画 `—`;
  14. * · **过期与空分开**:过期是有值(内容照画 + 保留标识),空是确实没有;
  15. * · **接口词汇不上大屏**:原因码翻人话、认不出的**回空串**,字段缺失只报条数。
  16. *
  17. * 关于 `CODE_TEXT` 的边界(票 18 特别记一笔):这张表**不含点位状态词**。
  18. * `EMPTY` 在这里是新鲜度的空值哨兵「无数据」,而点位状态里的 `EMPTY` 是「无设备」——
  19. * 同一个码在两个词表里意思不同,合成一张就会把「无设备」画成「无数据」。
  20. * 点位状态的中文标签归 `domain/pointStatus.js`(票 09 的唯一一张表),本文件不碰。
  21. */
  22. import { DETAIL_PLACEHOLDER } from './detailFlow.js'
  23. import { SECTION_STATE } from './sectionState.js'
  24. import { collectMissing, missingNote } from './equipmentDetailView.js'
  25. import { industryLabel } from './filterOptions.js'
  26. import { ringSlicePath } from './ringGeometry.js'
  27. /**
  28. * 码 → 中文的**枚举直译表**(跨模式共用的那一部分)。
  29. *
  30. * 两件事:
  31. * 1. 直译后端**自己文档里冻结的**枚举(取值清单逐条取自响应类/服务常量与契约手册);
  32. * 2. **认不出的码回原码**(区域码这类"是个值"的东西照原样带出,不猜、也不显示成空白 ——
  33. * 空白会被读成"没有数据",而"没见过的区域码"其实是真实数据)。
  34. *
  35. * 与原因码不同:原因码认不出必须回空串(那是接口词汇,不该给值班人员看),
  36. * 区域/行业码是**数据值**(和点位编码、设备编码同类),照原样显示才是诚实的。
  37. */
  38. export const CODE_TEXT = {
  39. // 行业(契约手册第 3 节冻结:GAS / WATER_SUPPLY / DRAINAGE)
  40. GAS: '燃气',
  41. WATER_SUPPLY: '供水',
  42. DRAINAGE: '排水',
  43. // 年限分桶(`CockpitInfrastructureBaseArchivesService` 的取值清单)
  44. UNDER_5_YEARS: '5 年以内',
  45. '5_TO_10_YEARS': '5–10 年',
  46. '10_TO_20_YEARS': '10–20 年',
  47. '20_YEARS_OR_OVER': '20 年以上',
  48. // 指标类别(`CockpitInfrastructureOperatingInformationService`)
  49. SUPPLY: '供应',
  50. FLOW: '输送',
  51. PRESSURE: '压力',
  52. QUALITY: '水质',
  53. CAPACITY: '承载',
  54. // 设施规模枚举(票 38 已删除界面上唯一的「规模」读数——真实后端 totals.scale 恒 null)。
  55. // 这三行翻译**保留**:`scale` 仍是后端透传的自由字符串,词表若删,将来任何一处误把它
  56. // 画上屏就直接漏出英文枚举(票 17 真机走查逮到过「规模=medium」)。翻译只有一份。
  57. large: '大型',
  58. medium: '中型',
  59. small: '小型',
  60. // 负荷状态(同服务的两个阈值:0.8 关注、1.0 超载)
  61. NORMAL: '正常',
  62. WATCH: '关注',
  63. OVERLOAD: '超载',
  64. // 新鲜度(契约手册第 2 节的五态字典)
  65. CURRENT: '新鲜',
  66. STALE: '滞后',
  67. EXPIRED: '过期',
  68. // 权属类型(管理信息演示载荷的四个取值)
  69. OWNED: '自有',
  70. LEASED: '租赁',
  71. ENTRUSTED: '委托',
  72. // 应急保障三类对象(演示载荷的 `categoryName()` 就是这三个中文名)
  73. EMERGENCY_RESOURCE: '应急资源',
  74. HAZARD_SOURCE: '危险源',
  75. PROTECTION_TARGET: '防护目标',
  76. // 三个"没有值"的哨兵值 —— **必须映射**,否则会把接口哨兵原样印到大屏上。
  77. // `UNKNOWN` 是区域/负荷/权属的空值哨兵,`EMPTY` 是新鲜度的空值哨兵。
  78. UNKNOWN: '未知',
  79. EMPTY: '无数据',
  80. }
  81. /** 后端 `region`/`area` 的归一空值(契约手册:`region` 空值在响应中归一为 `未分区`)。 */
  82. export const REGION_EMPTY = '未分区'
  83. export const CODE_EMPTY = '未知'
  84. /**
  85. * 用一张给定的词表把码翻成人话;认不出的**回原码**(见 `CODE_TEXT` 的说明)。
  86. * @param {object} dictionary 码 → 中文
  87. * @param {string} code 后端码/值
  88. * @param {object} [options] `{ empty }` 空值时的说法
  89. */
  90. export function textFrom(dictionary, code, { empty = CODE_EMPTY } = {}) {
  91. const value = typeof code === 'string' ? code.trim() : ''
  92. if (!value) return empty
  93. return (dictionary && dictionary[value]) || value
  94. }
  95. /** 码 → 人话(共享词表)。 */
  96. export function codeText(code, options) {
  97. return textFrom(CODE_TEXT, code, options)
  98. }
  99. /** 区域码 → 人话(空值=未分区,这是后端归一后的取值)。 */
  100. export function regionText(code) {
  101. return codeText(code, { empty: REGION_EMPTY })
  102. }
  103. /**
  104. * 数字/数字串 → 数字;认不出回 null(**绝不回 0**:0 会被读成"确实是 0",那是谎话)。
  105. * 后端 BigDecimal 的线上形状不统一(演示载荷是 `"0.32"`),所以两种都认。
  106. */
  107. export function numberish(value) {
  108. if (typeof value === 'number' && Number.isFinite(value)) return value
  109. if (typeof value === 'string' && value.trim() !== '') {
  110. const parsed = Number(value)
  111. if (Number.isFinite(parsed)) return parsed
  112. }
  113. return null
  114. }
  115. /** 数字 + 单位;认不出只显示占位符(不拼出「— m³/h」这种半截读数)。 */
  116. export function numberText(value, unit) {
  117. const parsed = numberish(value)
  118. if (parsed === null) return DETAIL_PLACEHOLDER
  119. return unit ? `${parsed} ${unit}` : String(parsed)
  120. }
  121. /** 带小数的比例读数(后端给的是 0–1 的小数),原样带出、不四舍五入掉精度。 */
  122. export function ratioText(value) {
  123. const parsed = numberish(value)
  124. if (parsed === null) return DETAIL_PLACEHOLDER
  125. return `${(parsed * 100).toFixed(parsed * 100 >= 100 ? 0 : 1)}%`
  126. }
  127. // ================================================================ 分区状态(视图层口语)
  128. /** 有内容可画的两种状态(过期是有值)。 */
  129. export const CONTENT_STATES = [SECTION_STATE.SUCCESS, SECTION_STATE.STALE]
  130. /** 该分区有没有内容可画。 */
  131. export function sectionHasContent(section) {
  132. return CONTENT_STATES.includes((section && section.state) || '')
  133. }
  134. /** 面板内某个**子块**(如后端给的分区状态码)有没有内容。 */
  135. export function childHasContent(state) {
  136. return state === 'READY' || state === 'STALE'
  137. }
  138. /**
  139. * 该面板是否还没取过数(界面据此说"正在取数"而不是"没有数据")。
  140. * 整段缺失(`null`/`undefined`)也算"还没有"——**不能**当成失败:
  141. * "这个分区还没有位置"与"取了但坏了"是两件事,只有后者才有资格说"加载失败"。
  142. */
  143. export function sectionPending(section) {
  144. if (!section) return true
  145. return section.loaded === false
  146. }
  147. /** 没有内容时那一行占位说明(措辞与票 11 的六块分区保持一致,不另造一套)。 */
  148. export function sectionPlaceholder(section) {
  149. // 还没发过请求时说"正在取数",**不能说"没有数据"** ——
  150. // 那是在报告一件我们还没问过的事。
  151. if (sectionPending(section)) return '正在取数…'
  152. const state = (section && section.state) || SECTION_STATE.FAILED
  153. if (state === SECTION_STATE.EMPTY) return '该面板当前没有数据'
  154. if (state === SECTION_STATE.UNAVAILABLE) return '该面板数据不可用(其它面板不受影响)'
  155. if (state === SECTION_STATE.FAILED) return '该面板加载失败(其它面板不受影响)'
  156. if (state === SECTION_STATE.STALE) return '' // 过期是有值,内容照画
  157. return ''
  158. }
  159. // ================================================================ 四种块构造器
  160. /**
  161. * KPI 读数 → 图标语义名(2026-10-13 维护者要求「每一项都可以在左侧增加一个图标」)。
  162. *
  163. * 图标来自批次 1 入库的蓝湖正式切图(`domain/icons.js` 的注册表);
  164. * 按 **KPI key 的语义词**映射而不是逐面板映射:同名 key 在任何面板都指同一指标,
  165. * 一张表覆盖全部 12 处 `kpiBlock` 调用点。未列出的 key 不画图标(渲染层跳过,不抛错)。
  166. */
  167. export const KPI_ICONS = {
  168. // 规模/底数(票 37 走查扩展:值=manifest 的 icon.kpi.* 语义名,mask 染语义色)
  169. facilityCount: 'facilityCount',
  170. smartDeviceCount: 'smartDeviceCount',
  171. deviceCount: 'deviceCount',
  172. equipmentCount: 'deviceCount',
  173. monitoringPointCount: 'monitoringPointCount',
  174. monitoringFacilityCount: 'facility',
  175. infrastructureFacilityCount: 'facility',
  176. industryCount: 'industryCount',
  177. // 运行状态
  178. onlineRate: 'onlineRate',
  179. online: 'onlineRate',
  180. offline: 'offline',
  181. stale: 'stale',
  182. unknownOrMissing: 'unknownOrMissing',
  183. // 预警/报警/处置
  184. warningTotal: 'warning',
  185. alarmTotal: 'alarm',
  186. warningCount: 'warning',
  187. // 票 65:预警效率面板预警族的两个**真实**读数(预警不挂工单 ⇒ 关联/未关联是实况计数)。
  188. warningLinked: 'warning',
  189. warningMissing: 'unknownOrMissing',
  190. alarmCount: 'alarm',
  191. abnormalCount: 'abnormalCount',
  192. offlineCount: 'offline',
  193. '预警-total': 'warning',
  194. '预警-handled': 'resolved',
  195. '预警-pending': 'warning',
  196. '预警-cancelled': 'unknownOrMissing',
  197. '预警-unlinked': 'industryCount',
  198. '预警-rate': 'resolved',
  199. '报警-total': 'alarm',
  200. '报警-handled': 'resolved',
  201. '报警-pending': 'alarm',
  202. '报警-cancelled': 'unknownOrMissing',
  203. '报警-unlinked': 'industryCount',
  204. '报警-rate': 'efficiency',
  205. handlingRate: 'efficiency',
  206. handlingRateView: 'efficiency',
  207. responseDuration: 'time',
  208. closeDuration: 'time',
  209. // 基础档案(票 38:`scale` 规模读数已删除——真实后端 totals.scale 恒 null)
  210. lengthKm: 'facility',
  211. // 应急保障
  212. hazardSourceCount: 'unknownOrMissing',
  213. protectionTargetCount: 'shield',
  214. emergencyResourceCount: 'scale',
  215. // 监测运行
  216. createdAt: 'time',
  217. }
  218. /**
  219. * KPI 图标的**语义色**(票 37 走查扩展:图标颜色随指标语义走)。
  220. * `CockpitIcon` 的 mask 模式下颜色由 currentColor 给,渲染层据此给图标容器上色。
  221. * 未列出的 key 回主蓝(--cyan)——有图标但无特殊语义的读数仍可辨。
  222. */
  223. export const KPI_ICON_COLORS = {
  224. onlineRate: 'var(--lv4, #27d17a)',
  225. online: 'var(--lv4, #27d17a)',
  226. offline: 'var(--muted, #5680b0)',
  227. stale: 'var(--lv2, #ffb020)',
  228. unknownOrMissing: 'var(--lv2, #ffb020)',
  229. warningTotal: 'var(--lv2, #ffb020)',
  230. warningCount: 'var(--lv2, #ffb020)',
  231. warningLinked: 'var(--lv2, #ffb020)',
  232. warningMissing: 'var(--muted, #5680b0)',
  233. '预警-total': 'var(--lv2, #ffb020)',
  234. '预警-pending': 'var(--lv2, #ffb020)',
  235. '预警-rate': 'var(--lv4, #27d17a)',
  236. '预警-handled': 'var(--lv4, #27d17a)',
  237. alarmTotal: 'var(--lv1, #ff3b52)',
  238. alarmCount: 'var(--lv1, #ff3b52)',
  239. '报警-total': 'var(--lv1, #ff3b52)',
  240. '报警-pending': 'var(--lv1, #ff3b52)',
  241. '报警-rate': 'var(--lv4, #27d17a)',
  242. '报警-handled': 'var(--lv4, #27d17a)',
  243. abnormalCount: 'var(--lv1, #ff3b52)',
  244. offlineCount: 'var(--muted, #5680b0)',
  245. handlingRate: 'var(--lv4, #27d17a)',
  246. handlingRateView: 'var(--lv4, #27d17a)',
  247. createdAt: 'var(--muted, #5680b0)',
  248. }
  249. /** KPI 块。`value` 为 null 时界面画占位符(`valueText` 负责)。
  250. * 每项自动带 `icon`(KPI_ICONS 映射,未命中为空串 → 组件不画图标)
  251. * 与 `iconColor`(KPI_ICON_COLORS,未命中回空串 → 渲染层用主蓝)。 */
  252. export function kpiBlock(items, options = {}) {
  253. return {
  254. kind: 'kpi',
  255. layout: options.layout || '',
  256. items: items.map((item) => ({
  257. ...item,
  258. icon: KPI_ICONS[item.key] || '',
  259. iconColor: KPI_ICON_COLORS[item.key] || '',
  260. })),
  261. }
  262. }
  263. /**
  264. * 行 key → 分类色键(2026-10-13 维护者要求:UI 与统计图表用行业语义色区分类别)。
  265. *
  266. * 三类语义优先级:
  267. * 1. **行业行**(燃气/供水/排水/窨井)→ 行业色(与行业切图、地图标记同源,一眼对应);
  268. * 2. **状态行**(指标状态/点位状态/事件等级/工单状态)→ 语义色键(报警=lv1、正常=lv4…);
  269. * 3. 其余类别(片区、年限、类型)→ 图表色序(--chart-c1..c8,按行序取)。
  270. *
  271. * 工单八态(2026-10-13 维护者第三轮:「已取消=红、延期中=黄,区分要更明显」)逐态显式映射:
  272. * 待派单/待接单/已接单/待验收 → 图表蓝系四档(流程推进的中间态,同族但可辨);
  273. * 处理中 → lv3(进行中主蓝);已结案 → lv4(成功绿);
  274. * 延期中 → lv2(黄=需要关注);已取消 → lv1(红=终止)。
  275. *
  276. * 组件按 `tone` 解析颜色:`industry:gas` 这类带前缀的键映射到行业 token,
  277. * 纯键(lv1..lv4/neutral)映射语义 token,`chart:N` 按序映射图表色。
  278. * 返回空串=无语义(组件退回默认主色,不参与分类着色)。
  279. */
  280. export function barTone(label, index) {
  281. const text = String(label || '')
  282. // 1) 行业语义
  283. if (/燃气|GAS/i.test(text)) return 'industry:gas'
  284. if (/供水|WATER/i.test(text)) return 'industry:water'
  285. if (/排水|DRAINAGE/i.test(text)) return 'industry:drainage'
  286. if (/窨井|井盖|MANHOLE/i.test(text)) return 'industry:manhole'
  287. // 2) 工单八态逐态映射(维护者第三轮:「已取消=红、延期中=黄,区分要更明显」)。
  288. // 流程五态各占一个色相(蓝/橙/青/紫/青绿,互不撞色),
  289. // 终态走业务语义色:已结案=绿(完成)、延期中=黄(关注)、已取消=红(终止)。
  290. // 八态八色相,堆叠条/图例一眼分得清。
  291. if (/^待派单$/.test(text)) return 'chart:1'
  292. if (/^待接单$/.test(text)) return 'chart:2'
  293. if (/^已接单$/.test(text)) return 'chart:3'
  294. if (/^处理中$/.test(text)) return 'chart:4'
  295. if (/^待验收$/.test(text)) return 'chart:5'
  296. if (/^已结案$/.test(text)) return 'lv4'
  297. if (/^延期中$/.test(text)) return 'lv2'
  298. if (/^已取消$/.test(text)) return 'lv1'
  299. // 3) 其余状态语义(只认整词,避免「预警分析」这类含字词误命中)
  300. if (/^(报警|1\s*级|ALARM|1级)$/.test(text)) return 'lv1'
  301. if (/^(预警|2\s*级|WARNING|2级)$/.test(text)) return 'lv2'
  302. if (/^(工单处置|3\s*级|3级)$/.test(text)) return 'lv3'
  303. if (/^(正常|在线|已完成|已处理|4\s*级|4级|已解除)$/.test(text)) return 'lv4'
  304. if (/^离线$/.test(text)) return 'point-offline'
  305. if (/^无设备$/.test(text)) return 'point-empty'
  306. // 票 39:「未知/缺失」是监测概览设备状态桶的正式措辞(后端 `unknownOrMissingCount`),
  307. // 与「状态未知」(总体概览同一四态)同语义;不登记就落图表色序——
  308. // 同一个语义在两个面板一个有色一个没有,正是「早晚不一致」的起点。
  309. if (/^(未发布|状态未发布|未知|未知\/缺失)$/.test(text)) return 'point-unknown'
  310. if (/^(未关联设备|无关联设备)$/.test(text)) return 'point-unassociated'
  311. // 票 39:「数据滞后」是同一桶的第二措辞(`staleCount`),语义与「滞后」相同(关注色)。
  312. if (/^(过期|未关联工单|未关联|滞后|数据滞后|无数据)$/.test(text)) return 'neutral'
  313. // 4) 其余类别 → 图表色序(按行序,同块内不重色)
  314. return `chart:${(index % 8) + 1}`
  315. }
  316. /**
  317. * 按计数**从多到少**排序,并列时**保持传入次序**(票 37)。
  318. *
  319. * 为什么手写序号比较而不是直接用 `Array#sort`:引擎的排序稳定性是实现细节,
  320. * 而「后端已排序时不得打乱其并列次序」是本票的验收条文 —— 得由代码结构保证,
  321. * 不能靠"现代引擎恰好稳定"。后端已降序时本函数是恒等变换(同一数组内容、同一顺序)。
  322. *
  323. * @param {Array} rows 待排序行
  324. * @param {(row: object) => number} [countOf] 取计数字段(缺省 `row.count`)
  325. * @returns {Array} 新数组(不改入参)
  326. */
  327. export function sortByCountDesc(rows, countOf = (row) => row.count) {
  328. const list = Array.isArray(rows) ? rows : []
  329. return list
  330. .map((row, index) => ({ row, index, count: Number(countOf(row)) || 0 }))
  331. .sort((a, b) => b.count - a.count || a.index - b.index)
  332. .map((item) => item.row)
  333. }
  334. /**
  335. * 分布块:`ratio` 只用于画条长(相对长度,不冒充占比;全 0 时给 0 而不是 NaN)。
  336. * 每行自动带 `tone` 分类色键(维护者 2026-10-13 要求:行业行用行业色、状态行用语义色)。
  337. *
  338. * `options.action`(票 20 立的接缝,票 37 复用):整块可点,渲染层只转发「哪一行被点了」。
  339. * 只有**带得出 `actionKey` 的行**才可点:展示名(`label`)不是后端筛选码,
  340. * 拿它兜底去发参数就是「拿脏值换后端 400」——所以这里不做 label 兜底,
  341. * 渲染层也按行上有没有 `actionKey` 决定该行是按钮还是普通行。
  342. *
  343. * @param {string} label 块标题
  344. * @param {Array<{code?, label: string, count: number, actionKey?, selected?}>} rows 类别行
  345. * @param {{action?: string}} [options] 整块可点动作(行变按钮)
  346. */
  347. export function barsBlock(label, rows, options = {}) {
  348. const list = Array.isArray(rows) ? rows : []
  349. const max = list.reduce((top, row) => Math.max(top, row.count || 0), 0)
  350. const action = options.action || ''
  351. return {
  352. kind: 'bars',
  353. label,
  354. action,
  355. rows: list.map((row, index) => ({
  356. key: row.code ?? row.label,
  357. label: row.label,
  358. count: row.count,
  359. ratio: max > 0 ? (row.count || 0) / max : 0,
  360. tone: barTone(row.label, index),
  361. hint: row.hint || '',
  362. actionKey: action ? row.actionKey || row.code || '' : '',
  363. // 当前生效的筛选行(渲染层画选中态,用户据此知道"再点一下就取消")。
  364. // `=== true`:只有 domain 明说"选中"才画,`row.selected` 为其它形状(脏值)不误亮。
  365. selected: row.selected === true,
  366. })),
  367. }
  368. }
  369. /**
  370. * 环形图块(2026-10-13 维护者反馈:横向条形图太多、视觉疲劳且不直观——
  371. * 「类别占比、类别少」的场景改用环形图,中心读数给合计)。
  372. *
  373. * 与饼图(`panel-pie`)的分工:饼图是**点位状态分布**专用(带选中高亮、与地图图例同源);
  374. * 本块是**通用类别构成**(行业构成、按事件类型等),扇区色按 `barTone` 语义着色,
  375. * 图例在图右、逐项带计数,中心显示合计。0 计数行保留图例不占扇区(同图例 0 不隐藏的立场)。
  376. *
  377. * `options.action`(如 `'industry'`):图例行可点(联动写全局筛选),接缝与条形图的
  378. * `block.action` 同一条(外壳 `handlePanelAction` 不用改);条形图改环形图不丢交互。
  379. * @param {string} label 块标题
  380. * @param {Array<{code?, label: string, count: number, actionKey?}>} rows 类别行
  381. * @param {{action?: string}} [options] 整块可点动作(图例行变按钮)
  382. */
  383. export function donutBlock(label, rows, options = {}) {
  384. const list = Array.isArray(rows) ? rows : []
  385. const total = list.reduce((sum, row) => sum + (Number(row.count) || 0), 0)
  386. // 扇区几何:SVG 视框 100×100、圆心 (50,50)、外半径 42 / 内半径 26(环形)。
  387. // 只给 count>0 的行出扇区;角度从正上方起顺时针。
  388. // 票 43(重开):`hitPath`=同角度的整楔形(内半径 0)——可见环带太窄,
  389. // 真实指针落在中心孔/环带外都不触发;命中区由渲染层画透明层承接,
  390. // 与 `statusPie.js` 的 `hitPath` 同一个办法。
  391. let angle = 0
  392. const slices = []
  393. for (const row of list) {
  394. const count = Number(row.count) || 0
  395. if (count <= 0 || total <= 0) continue
  396. const sweep = (count / total) * 360
  397. const start = angle
  398. const end = angle + sweep
  399. angle = end
  400. slices.push({
  401. key: row.code ?? row.label,
  402. label: row.label,
  403. count,
  404. ratio: count / total,
  405. tone: barTone(row.label, list.indexOf(row)),
  406. path: ringSlicePath(start, end),
  407. hitPath: ringSlicePath(start, end, 42, 0),
  408. })
  409. }
  410. return {
  411. kind: 'donut',
  412. label,
  413. total,
  414. action: options.action || '',
  415. slices,
  416. rows: list.map((row, index) => ({
  417. key: row.code ?? row.label,
  418. label: row.label,
  419. count: Number(row.count) || 0,
  420. tone: barTone(row.label, index),
  421. actionKey: row.actionKey || row.code || row.key,
  422. })),
  423. }
  424. }
  425. /**
  426. * 可切换维度的**分类柱状图**块(票 38 · 管理信息「片区 × 行业」)。
  427. *
  428. * 与 `barsBlock`(单维度横条)的分工:本块服务「同一份行数据、按两个字段各聚合一遍」的
  429. * 场景(决策 06:本项目只有片区/行业两个维度)。**两个维度都由 domain 预先聚合**
  430. * (`dimensions: [{key,label,rows}]`),切换是纯展示动作 —— 零后端往返、零第二次聚合,
  431. * 组件也不认识「片区」「行业」是什么(它只按 `block.kind` 分派画法)。
  432. *
  433. * 行柱长口径与 `barsBlock` 一致:相对该维度最大项归一(全 0 时给 0 而不是 NaN)。
  434. * 计数为 0 的类别保留行(画零高柱)——0 是真实值,不是缺失。
  435. *
  436. * @param {string} label 块标题
  437. * @param {Array} rows 原始行(如 `districtIndustry[]`:每行带 district/industry/计数)
  438. * @param {object} options
  439. * `dimensions: [{key,label,field,emptyLabel,labelOf}]` 维度清单(field=行上取值字段);
  440. * `countOf(row)` 取主计数(缺省 `row.count`);
  441. * `extras: {名字: 行字段}` 逐维度累加的辅助计数;
  442. * `hintOf(aggRow)` 由聚合行生成悬停说明(如「设备 N 台」);
  443. * `active` 默认维度键(认不出取第一个);`unit`/`emptyText` 文案。
  444. */
  445. export function dimensionBarsBlock(label, rows, options = {}) {
  446. const list = Array.isArray(rows) ? rows : []
  447. const countOf = typeof options.countOf === 'function' ? options.countOf : (row) => (row && row.count) || 0
  448. const hintOf = typeof options.hintOf === 'function' ? options.hintOf : null
  449. const extras = options.extras || {}
  450. const isBlank = (value) => value === null || value === undefined || String(value).trim() === ''
  451. const dimensions = (Array.isArray(options.dimensions) ? options.dimensions : [])
  452. .filter((dim) => dim && dim.key && dim.field)
  453. .map((dim) => {
  454. const labelOf = typeof dim.labelOf === 'function' ? dim.labelOf : (value) => codeText(value)
  455. const byValue = new Map()
  456. for (const row of list) {
  457. if (!row) continue
  458. const raw = row[dim.field]
  459. const blank = isBlank(raw)
  460. const key = blank ? 'UNASSIGNED' : String(raw).trim()
  461. let entry = byValue.get(key)
  462. if (!entry) {
  463. entry = { key, label: blank ? dim.emptyLabel || CODE_EMPTY : labelOf(raw), count: 0 }
  464. for (const name of Object.keys(extras)) entry[name] = 0
  465. byValue.set(key, entry)
  466. }
  467. entry.count += Number(countOf(row)) || 0
  468. for (const [name, source] of Object.entries(extras)) entry[name] += Number(row[source]) || 0
  469. }
  470. const sorted = sortByCountDesc([...byValue.values()], (row) => row.count)
  471. const max = sorted.reduce((top, row) => Math.max(top, row.count), 0)
  472. return {
  473. key: dim.key,
  474. label: dim.label,
  475. rows: sorted.map((row, index) => ({
  476. key: row.key,
  477. label: row.label,
  478. count: row.count,
  479. ratio: max > 0 ? row.count / max : 0,
  480. tone: barTone(row.label, index),
  481. hint: hintOf ? hintOf(row) : '',
  482. })),
  483. }
  484. })
  485. const wanted = options.active || ''
  486. const active = dimensions.find((dim) => dim.key === wanted) || dimensions[0] || null
  487. return {
  488. kind: 'dimbars',
  489. label,
  490. unit: options.unit || '',
  491. emptyText: options.emptyText || '该维度当前没有记录',
  492. dimensions,
  493. // `active` 只是**默认显示**的维度;另一维度的 rows 已经算好,切换不回 domain。
  494. active: active ? active.key : '',
  495. }
  496. }
  497. /** 键值行块。 */
  498. export function rowsBlock(label, rows) {
  499. return { kind: 'rows', label, rows }
  500. }
  501. /**
  502. * 条目列表块(应急保障三段、管理单位的机构清单都用它)。
  503. *
  504. * 票 64 新增可选 `options`(不传就是原形状,既有调用零影响):
  505. * · `paginate: true` —— 声明「这一块的行由渲染层做统一分页」(规范条文 1/2/4:
  506. * 每页 ≤10、页码直达 + 输入框跳页、一页不画控件)。用于**行数可达数千**的块
  507. * (运行信息指标列表实测 6772 条 / 2.3 MB,票 57 首版误按「契约有界」排除,
  508. * 2026-10-19 维护者反馈纠正为接入)。
  509. * · 其余字段原样透传(`emptyText` 等)。
  510. *
  511. * 票 61 新增两种「分页形态」声明(与 `paginate` 互斥,按块各自选择):
  512. * · `serverPage: {page, pageCount, total}` —— 行**已经是后端返回的当前页**,
  513. * 渲染层只画控件、翻页发请求(服务端页码分页,如历史事件单窗);
  514. * · `pageNote: string` —— 翻页区的如实口径说明(多窗合并范围「已合并 N 个时间窗 ·
  515. * 已加载 M 条」;即使一页不到、控件不画,这句说明也要出现——它是口径不是控件)。
  516. * 块**不携带页码状态**:分页是「这一屏怎么看」的视图私有状态(票 46 成例),
  517. * 渲染层按 `blockKey` 记页码、用 `domain/pagination.js` 的 `pagedRows` 单源切窗口;
  518. * 服务端形态的页码状态归 store(请求参数在那儿),块只带当前回显。
  519. * @param {string} label 块标题
  520. * @param {Array} rows 条目行
  521. * @param {string} [emptyText] 空态文案
  522. * @param {{paginate?: boolean, pageScope?: string, serverPage?: object, pageNote?: string}} [options]
  523. */
  524. export function listBlock(label, rows, emptyText, options = {}) {
  525. const block = { kind: 'list', label, rows, emptyText }
  526. if (options.paginate === true) {
  527. block.paginate = true
  528. // `pageScope`=页码归属的筛选条件标识(换行业/片区⇒变,定时刷新⇒不变)。
  529. // 渲染层据此兑现条文 5/6「条件变化回第 1 页、刷新不复位」——键必须是跨重算
  530. // **稳定的标量**,不是块对象身份(票 43 第四轮记过的教训:视图计划每次重算
  531. // 都生成全新对象,按对象记会把每次刷新都读成换筛选)。
  532. block.pageScope = typeof options.pageScope === 'string' ? options.pageScope : ''
  533. }
  534. // 票 61:服务端页码分页的块(行已是后端当前页)与翻页区口径说明。
  535. if (options.serverPage && typeof options.serverPage === 'object') {
  536. block.serverPage = options.serverPage
  537. }
  538. if (typeof options.pageNote === 'string' && options.pageNote) {
  539. block.pageNote = options.pageNote
  540. }
  541. return block
  542. }
  543. /**
  544. * 趋势块(票 18 的「事件趋势」;票 20 的近 7 日趋势可复用)。
  545. *
  546. * `points` 是**后端给的分桶序列**,本层不重采样、不补点:桶的粒度与顺序都是后端语义
  547. * (`monitoring/situation` 按业务时区小时分桶)。条长按最大桶归一,与分布块同一口径。
  548. *
  549. * 2026-10-13 维护者要求(图表形态丰富化):
  550. * · `chart: 'line'` 产出折线几何(时间趋势用折线更精准,见维护者给的图表选型表)——
  551. * 几何在这里算好(SVG 视框 100×40 的 polyline 点列),组件只画,不认后端字段;
  552. * · 缺省仍是柱列(`chart` 为空 → 柱),既有断言与走查读数不受影响。
  553. * @param {string} label 块标题
  554. * @param {Array<{key: string, label: string, count: number, hint?: string}>} points
  555. * @param {{chart?: 'line'|'bar'}} [options] 形态(缺省柱列)
  556. */
  557. export function trendBlock(label, points, options = {}) {
  558. const rows = Array.isArray(points) ? points : []
  559. const max = rows.reduce((top, row) => Math.max(top, row.count || 0), 0)
  560. const chart = options.chart === 'line' ? 'line' : 'bar'
  561. const n = rows.length
  562. const peakIndex = rows.findIndex((point) => (point.count || 0) === max)
  563. // 折线几何(2026-10-13 维护者第三轮:原折线太丑、无坐标对应关系)。
  564. // 统一用 0–100 归一坐标:x 按点数均分、y 按最大值归一(0 贴底、max 贴顶)。
  565. // 组件用同一份 xPct/yPct 画 HTML 圆点与数值标签(保持圆点正圆、文字清晰),
  566. // SVG 只画拉伸无碍的折线 + 面积(preserveAspectRatio=none)。
  567. const xPct = (i) => (n > 1 ? (i / (n - 1)) * 100 : 50)
  568. const yPct = (count) => (max > 0 ? ((Number(count) || 0) / max) * 100 : 0)
  569. const linePoints =
  570. chart === 'line' && n >= 2
  571. ? rows.map((row, i) => ({
  572. key: row.key,
  573. label: row.label,
  574. count: row.count || 0,
  575. displayText: row.displayText || '',
  576. annotate: n <= 8 || i === peakIndex,
  577. x: xPct(i),
  578. y: 100 - yPct(row.count),
  579. xPct: xPct(i),
  580. yPct: yPct(row.count),
  581. }))
  582. : []
  583. return {
  584. kind: 'trend',
  585. label,
  586. max,
  587. chart,
  588. points: rows.map((row) => ({
  589. key: row.key,
  590. label: row.label,
  591. count: row.count || 0,
  592. hint: row.hint || '',
  593. displayText: row.displayText || '',
  594. ratio: max > 0 ? (row.count || 0) / max : 0,
  595. })),
  596. linePoints,
  597. // 面积填充多边形:折线点列 + 右下/左下两角闭合(SVG 0–100 空间,y 向下)。
  598. areaPoints:
  599. linePoints.length >= 2
  600. ? `${linePoints.map((p) => `${p.x},${p.y}`).join(' ')} 100,100 0,100`
  601. : '',
  602. // Y 轴刻度与几何比例一致;零序列只画底部 0。
  603. yTicks:
  604. chart === 'line' && n >= 2
  605. ? (max > 0 ? [0, 0.5, 1] : [0]).map((f) => ({
  606. value: max * f,
  607. pct: f * 100,
  608. }))
  609. : [],
  610. // 点数少时每个点顶标注数值(维护者要「线与数值对应」);多时只标峰值 + 悬停 title。
  611. showInlineLabels: chart === 'line' && n >= 2 && n <= 8,
  612. // X 轴标签抽样步长:最多显示 ~6 个(首尾必显),避免小时粒度挤成一团。
  613. axisIndices: Array.from(new Set(Array.from({ length: Math.min(4, n) }, (_, i) => Math.round(i * (n - 1) / Math.max(1, Math.min(4, n) - 1))))),
  614. axisEvery: chart === 'line' && n >= 2 ? Math.ceil(n / 4) : 1,
  615. }
  616. }
  617. /**
  618. * 百分比堆叠条块(2026-10-13 维护者图表选型表:「占比但类别较多」用百分比堆叠条)。
  619. *
  620. * 与 `barsBlock` 的差异:条长是**占总和的百分比**且只有一条(类别并排堆叠在一根条内),
  621. * 适合类别多、总和有意义的场景(如工单状态分布、监测覆盖)。
  622. * 2026-10-13 维护者反馈「单色系分不清类别」:分段色改按 `barTone` 语义着色
  623. * (工单状态行命中 lv1..lv4/neutral 语义色;含行业词时用行业色),
  624. * `shade` 保留给同语义无法区分时的兜底(组件侧 tone 为空才用)。
  625. *
  626. * 票 43「类别间小间隔」的根因与修法:组件原先对 `left` 与 `width` **各自独立取整**,
  627. * 相邻段之间就会露出 `round(nextStart) − (round(start) + round(width))` 的底色缝
  628. * (工单 9 张时「待验收→已结案」边界实测 1 个百分点)。那不是语义边界——
  629. * 条段边界改在这里由**同一个累加器**取整:`startPct`/`widthPct` 逐行接力
  630. * (每段起点=上一段终点,末段终点恒为 100),连续由构造保证,而不是靠运气舍入。
  631. * `start`/`ratio` 原样保留(占比口径不变,票 43 非目标:不改统计字段与状态口径)。
  632. * @param {string} label 块标题
  633. * @param {Array<{key?, label: string, count: number}>} rows
  634. */
  635. export function stackedBlock(label, rows) {
  636. const list = Array.isArray(rows) ? rows : []
  637. const total = list.reduce((sum, row) => sum + (Number(row.count) || 0), 0)
  638. // 同块内同语义 tone 的出现次数(如工单四个待办态都是 lv3):>1 时组件用 shade 深浅区分。
  639. const toneCount = new Map()
  640. for (const row of list) {
  641. const tone = barTone(row.label, 0)
  642. toneCount.set(tone, (toneCount.get(tone) || 0) + 1)
  643. }
  644. let acc = 0
  645. let prevPct = 0
  646. return {
  647. kind: 'stacked',
  648. label,
  649. total,
  650. rows: list.map((row, i) => {
  651. const count = Number(row.count) || 0
  652. const start = total > 0 ? acc / total : 0
  653. acc += count
  654. // 边界只取整一次:本段终点就是下一段起点(`prevPct` 接力),无缝由构造保证。
  655. const endPct = total > 0 ? Math.round((acc / total) * 100) : 0
  656. const startPct = prevPct
  657. prevPct = endPct
  658. const tone = barTone(row.label, i)
  659. return {
  660. key: row.key ?? row.label,
  661. label: row.label,
  662. count,
  663. // 占比与条内起点/宽度(百分比堆叠),0 计数的行保留图例但不占条宽。
  664. ratio: total > 0 ? count / total : 0,
  665. start,
  666. // 票 43:渲染层用这两个**已接力的整数百分比**画条段(不再各自取整)。
  667. startPct,
  668. widthPct: endPct - startPct,
  669. tone,
  670. // 该语义在块内是否重复(组件据此决定是否用 shade 深浅区分)。
  671. toneShared: (toneCount.get(tone) || 0) > 1,
  672. shade: list.length > 1 ? i / (list.length - 1) : 1,
  673. }
  674. }),
  675. }
  676. }
  677. // ================================================================ 可定位对象与缺失
  678. /** 一个"可定位对象"条目(`pointCode` 是**点位的**编码,不是对象的编码)。 */
  679. export function locateEntries(source, label, objects, pointCodes) {
  680. return (pointCodes || []).map((pointCode) => ({
  681. key: `${source}|${pointCode}`,
  682. label,
  683. pointCode,
  684. objectCode: (objects && objects.code) || '',
  685. objectName: (objects && objects.name) || '',
  686. }))
  687. }
  688. /** 收集一个面板视图里全部可定位对象(走查与统计用)。 */
  689. export function panelLocateEntries(view) {
  690. const entries = []
  691. for (const block of (view && view.blocks) || []) {
  692. for (const row of block.rows || []) {
  693. for (const point of row.points || []) entries.push(point)
  694. }
  695. }
  696. for (const entry of (view && view.locate) || []) entries.push(entry)
  697. return entries
  698. }
  699. /** 收集一个面板视图里真实的字段缺失(含嵌套层,票 05 的 `missing` 清单口径)。 */
  700. export function panelMissingFields(data) {
  701. return collectMissing(data)
  702. }
  703. /** 字段缺失 → 界面那一行(只报条数,路径进 tooltip;票 11 的立场)。 */
  704. export function panelMissingNote(data) {
  705. return missingNote(data)
  706. }
  707. /** 行业码 → 展示名(认不出的回空串;本文件不另造行业词表)。 */
  708. export function industryText(code) {
  709. return industryLabel(code) || ''
  710. }