panelBlocks.js 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  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. alarmCount: 'alarm',
  188. abnormalCount: 'abnormalCount',
  189. offlineCount: 'offline',
  190. '预警-total': 'warning',
  191. '预警-handled': 'resolved',
  192. '预警-pending': 'warning',
  193. '预警-cancelled': 'unknownOrMissing',
  194. '预警-unlinked': 'industryCount',
  195. '预警-rate': 'resolved',
  196. '报警-total': 'alarm',
  197. '报警-handled': 'resolved',
  198. '报警-pending': 'alarm',
  199. '报警-cancelled': 'unknownOrMissing',
  200. '报警-unlinked': 'industryCount',
  201. '报警-rate': 'efficiency',
  202. handlingRate: 'efficiency',
  203. handlingRateView: 'efficiency',
  204. responseDuration: 'time',
  205. closeDuration: 'time',
  206. // 基础档案(票 38:`scale` 规模读数已删除——真实后端 totals.scale 恒 null)
  207. lengthKm: 'facility',
  208. // 应急保障
  209. hazardSourceCount: 'unknownOrMissing',
  210. protectionTargetCount: 'shield',
  211. emergencyResourceCount: 'scale',
  212. // 监测运行
  213. createdAt: 'time',
  214. }
  215. /**
  216. * KPI 图标的**语义色**(票 37 走查扩展:图标颜色随指标语义走)。
  217. * `CockpitIcon` 的 mask 模式下颜色由 currentColor 给,渲染层据此给图标容器上色。
  218. * 未列出的 key 回主蓝(--cyan)——有图标但无特殊语义的读数仍可辨。
  219. */
  220. export const KPI_ICON_COLORS = {
  221. onlineRate: 'var(--lv4, #27d17a)',
  222. online: 'var(--lv4, #27d17a)',
  223. offline: 'var(--muted, #5680b0)',
  224. stale: 'var(--lv2, #ffb020)',
  225. unknownOrMissing: 'var(--lv2, #ffb020)',
  226. warningTotal: 'var(--lv2, #ffb020)',
  227. warningCount: 'var(--lv2, #ffb020)',
  228. '预警-total': 'var(--lv2, #ffb020)',
  229. '预警-pending': 'var(--lv2, #ffb020)',
  230. '预警-rate': 'var(--lv4, #27d17a)',
  231. '预警-handled': 'var(--lv4, #27d17a)',
  232. alarmTotal: 'var(--lv1, #ff3b52)',
  233. alarmCount: 'var(--lv1, #ff3b52)',
  234. '报警-total': 'var(--lv1, #ff3b52)',
  235. '报警-pending': 'var(--lv1, #ff3b52)',
  236. '报警-rate': 'var(--lv4, #27d17a)',
  237. '报警-handled': 'var(--lv4, #27d17a)',
  238. abnormalCount: 'var(--lv1, #ff3b52)',
  239. offlineCount: 'var(--muted, #5680b0)',
  240. handlingRate: 'var(--lv4, #27d17a)',
  241. handlingRateView: 'var(--lv4, #27d17a)',
  242. createdAt: 'var(--muted, #5680b0)',
  243. }
  244. /** KPI 块。`value` 为 null 时界面画占位符(`valueText` 负责)。
  245. * 每项自动带 `icon`(KPI_ICONS 映射,未命中为空串 → 组件不画图标)
  246. * 与 `iconColor`(KPI_ICON_COLORS,未命中回空串 → 渲染层用主蓝)。 */
  247. export function kpiBlock(items, options = {}) {
  248. return {
  249. kind: 'kpi',
  250. layout: options.layout || '',
  251. items: items.map((item) => ({
  252. ...item,
  253. icon: KPI_ICONS[item.key] || '',
  254. iconColor: KPI_ICON_COLORS[item.key] || '',
  255. })),
  256. }
  257. }
  258. /**
  259. * 行 key → 分类色键(2026-10-13 维护者要求:UI 与统计图表用行业语义色区分类别)。
  260. *
  261. * 三类语义优先级:
  262. * 1. **行业行**(燃气/供水/排水/窨井)→ 行业色(与行业切图、地图标记同源,一眼对应);
  263. * 2. **状态行**(指标状态/点位状态/事件等级/工单状态)→ 语义色键(报警=lv1、正常=lv4…);
  264. * 3. 其余类别(片区、年限、类型)→ 图表色序(--chart-c1..c8,按行序取)。
  265. *
  266. * 工单八态(2026-10-13 维护者第三轮:「已取消=红、延期中=黄,区分要更明显」)逐态显式映射:
  267. * 待派单/待接单/已接单/待验收 → 图表蓝系四档(流程推进的中间态,同族但可辨);
  268. * 处理中 → lv3(进行中主蓝);已结案 → lv4(成功绿);
  269. * 延期中 → lv2(黄=需要关注);已取消 → lv1(红=终止)。
  270. *
  271. * 组件按 `tone` 解析颜色:`industry:gas` 这类带前缀的键映射到行业 token,
  272. * 纯键(lv1..lv4/neutral)映射语义 token,`chart:N` 按序映射图表色。
  273. * 返回空串=无语义(组件退回默认主色,不参与分类着色)。
  274. */
  275. export function barTone(label, index) {
  276. const text = String(label || '')
  277. // 1) 行业语义
  278. if (/燃气|GAS/i.test(text)) return 'industry:gas'
  279. if (/供水|WATER/i.test(text)) return 'industry:water'
  280. if (/排水|DRAINAGE/i.test(text)) return 'industry:drainage'
  281. if (/窨井|井盖|MANHOLE/i.test(text)) return 'industry:manhole'
  282. // 2) 工单八态逐态映射(维护者第三轮:「已取消=红、延期中=黄,区分要更明显」)。
  283. // 流程五态各占一个色相(蓝/橙/青/紫/青绿,互不撞色),
  284. // 终态走业务语义色:已结案=绿(完成)、延期中=黄(关注)、已取消=红(终止)。
  285. // 八态八色相,堆叠条/图例一眼分得清。
  286. if (/^待派单$/.test(text)) return 'chart:1'
  287. if (/^待接单$/.test(text)) return 'chart:2'
  288. if (/^已接单$/.test(text)) return 'chart:3'
  289. if (/^处理中$/.test(text)) return 'chart:4'
  290. if (/^待验收$/.test(text)) return 'chart:5'
  291. if (/^已结案$/.test(text)) return 'lv4'
  292. if (/^延期中$/.test(text)) return 'lv2'
  293. if (/^已取消$/.test(text)) return 'lv1'
  294. // 3) 其余状态语义(只认整词,避免「预警分析」这类含字词误命中)
  295. if (/^(报警|1\s*级|ALARM|1级)$/.test(text)) return 'lv1'
  296. if (/^(预警|2\s*级|WARNING|2级)$/.test(text)) return 'lv2'
  297. if (/^(工单处置|3\s*级|3级)$/.test(text)) return 'lv3'
  298. if (/^(正常|在线|已完成|已处理|4\s*级|4级|已解除)$/.test(text)) return 'lv4'
  299. if (/^离线$/.test(text)) return 'point-offline'
  300. if (/^无设备$/.test(text)) return 'point-empty'
  301. // 票 39:「未知/缺失」是监测概览设备状态桶的正式措辞(后端 `unknownOrMissingCount`),
  302. // 与「状态未知」(总体概览同一四态)同语义;不登记就落图表色序——
  303. // 同一个语义在两个面板一个有色一个没有,正是「早晚不一致」的起点。
  304. if (/^(未发布|状态未发布|未知|未知\/缺失)$/.test(text)) return 'point-unknown'
  305. if (/^(未关联设备|无关联设备)$/.test(text)) return 'point-unassociated'
  306. // 票 39:「数据滞后」是同一桶的第二措辞(`staleCount`),语义与「滞后」相同(关注色)。
  307. if (/^(过期|未关联工单|未关联|滞后|数据滞后|无数据)$/.test(text)) return 'neutral'
  308. // 4) 其余类别 → 图表色序(按行序,同块内不重色)
  309. return `chart:${(index % 8) + 1}`
  310. }
  311. /**
  312. * 按计数**从多到少**排序,并列时**保持传入次序**(票 37)。
  313. *
  314. * 为什么手写序号比较而不是直接用 `Array#sort`:引擎的排序稳定性是实现细节,
  315. * 而「后端已排序时不得打乱其并列次序」是本票的验收条文 —— 得由代码结构保证,
  316. * 不能靠"现代引擎恰好稳定"。后端已降序时本函数是恒等变换(同一数组内容、同一顺序)。
  317. *
  318. * @param {Array} rows 待排序行
  319. * @param {(row: object) => number} [countOf] 取计数字段(缺省 `row.count`)
  320. * @returns {Array} 新数组(不改入参)
  321. */
  322. export function sortByCountDesc(rows, countOf = (row) => row.count) {
  323. const list = Array.isArray(rows) ? rows : []
  324. return list
  325. .map((row, index) => ({ row, index, count: Number(countOf(row)) || 0 }))
  326. .sort((a, b) => b.count - a.count || a.index - b.index)
  327. .map((item) => item.row)
  328. }
  329. /**
  330. * 分布块:`ratio` 只用于画条长(相对长度,不冒充占比;全 0 时给 0 而不是 NaN)。
  331. * 每行自动带 `tone` 分类色键(维护者 2026-10-13 要求:行业行用行业色、状态行用语义色)。
  332. *
  333. * `options.action`(票 20 立的接缝,票 37 复用):整块可点,渲染层只转发「哪一行被点了」。
  334. * 只有**带得出 `actionKey` 的行**才可点:展示名(`label`)不是后端筛选码,
  335. * 拿它兜底去发参数就是「拿脏值换后端 400」——所以这里不做 label 兜底,
  336. * 渲染层也按行上有没有 `actionKey` 决定该行是按钮还是普通行。
  337. *
  338. * @param {string} label 块标题
  339. * @param {Array<{code?, label: string, count: number, actionKey?, selected?}>} rows 类别行
  340. * @param {{action?: string}} [options] 整块可点动作(行变按钮)
  341. */
  342. export function barsBlock(label, rows, options = {}) {
  343. const list = Array.isArray(rows) ? rows : []
  344. const max = list.reduce((top, row) => Math.max(top, row.count || 0), 0)
  345. const action = options.action || ''
  346. return {
  347. kind: 'bars',
  348. label,
  349. action,
  350. rows: list.map((row, index) => ({
  351. key: row.code ?? row.label,
  352. label: row.label,
  353. count: row.count,
  354. ratio: max > 0 ? (row.count || 0) / max : 0,
  355. tone: barTone(row.label, index),
  356. hint: row.hint || '',
  357. actionKey: action ? row.actionKey || row.code || '' : '',
  358. // 当前生效的筛选行(渲染层画选中态,用户据此知道"再点一下就取消")。
  359. // `=== true`:只有 domain 明说"选中"才画,`row.selected` 为其它形状(脏值)不误亮。
  360. selected: row.selected === true,
  361. })),
  362. }
  363. }
  364. /**
  365. * 环形图块(2026-10-13 维护者反馈:横向条形图太多、视觉疲劳且不直观——
  366. * 「类别占比、类别少」的场景改用环形图,中心读数给合计)。
  367. *
  368. * 与饼图(`panel-pie`)的分工:饼图是**点位状态分布**专用(带选中高亮、与地图图例同源);
  369. * 本块是**通用类别构成**(行业构成、按事件类型等),扇区色按 `barTone` 语义着色,
  370. * 图例在图右、逐项带计数,中心显示合计。0 计数行保留图例不占扇区(同图例 0 不隐藏的立场)。
  371. *
  372. * `options.action`(如 `'industry'`):图例行可点(联动写全局筛选),接缝与条形图的
  373. * `block.action` 同一条(外壳 `handlePanelAction` 不用改);条形图改环形图不丢交互。
  374. * @param {string} label 块标题
  375. * @param {Array<{code?, label: string, count: number, actionKey?}>} rows 类别行
  376. * @param {{action?: string}} [options] 整块可点动作(图例行变按钮)
  377. */
  378. export function donutBlock(label, rows, options = {}) {
  379. const list = Array.isArray(rows) ? rows : []
  380. const total = list.reduce((sum, row) => sum + (Number(row.count) || 0), 0)
  381. // 扇区几何:SVG 视框 100×100、圆心 (50,50)、外半径 42 / 内半径 26(环形)。
  382. // 只给 count>0 的行出扇区;角度从正上方起顺时针。
  383. // 票 43(重开):`hitPath`=同角度的整楔形(内半径 0)——可见环带太窄,
  384. // 真实指针落在中心孔/环带外都不触发;命中区由渲染层画透明层承接,
  385. // 与 `statusPie.js` 的 `hitPath` 同一个办法。
  386. let angle = 0
  387. const slices = []
  388. for (const row of list) {
  389. const count = Number(row.count) || 0
  390. if (count <= 0 || total <= 0) continue
  391. const sweep = (count / total) * 360
  392. const start = angle
  393. const end = angle + sweep
  394. angle = end
  395. slices.push({
  396. key: row.code ?? row.label,
  397. label: row.label,
  398. count,
  399. ratio: count / total,
  400. tone: barTone(row.label, list.indexOf(row)),
  401. path: ringSlicePath(start, end),
  402. hitPath: ringSlicePath(start, end, 42, 0),
  403. })
  404. }
  405. return {
  406. kind: 'donut',
  407. label,
  408. total,
  409. action: options.action || '',
  410. slices,
  411. rows: list.map((row, index) => ({
  412. key: row.code ?? row.label,
  413. label: row.label,
  414. count: Number(row.count) || 0,
  415. tone: barTone(row.label, index),
  416. actionKey: row.actionKey || row.code || row.key,
  417. })),
  418. }
  419. }
  420. /**
  421. * 可切换维度的**分类柱状图**块(票 38 · 管理信息「片区 × 行业」)。
  422. *
  423. * 与 `barsBlock`(单维度横条)的分工:本块服务「同一份行数据、按两个字段各聚合一遍」的
  424. * 场景(决策 06:本项目只有片区/行业两个维度)。**两个维度都由 domain 预先聚合**
  425. * (`dimensions: [{key,label,rows}]`),切换是纯展示动作 —— 零后端往返、零第二次聚合,
  426. * 组件也不认识「片区」「行业」是什么(它只按 `block.kind` 分派画法)。
  427. *
  428. * 行柱长口径与 `barsBlock` 一致:相对该维度最大项归一(全 0 时给 0 而不是 NaN)。
  429. * 计数为 0 的类别保留行(画零高柱)——0 是真实值,不是缺失。
  430. *
  431. * @param {string} label 块标题
  432. * @param {Array} rows 原始行(如 `districtIndustry[]`:每行带 district/industry/计数)
  433. * @param {object} options
  434. * `dimensions: [{key,label,field,emptyLabel,labelOf}]` 维度清单(field=行上取值字段);
  435. * `countOf(row)` 取主计数(缺省 `row.count`);
  436. * `extras: {名字: 行字段}` 逐维度累加的辅助计数;
  437. * `hintOf(aggRow)` 由聚合行生成悬停说明(如「设备 N 台」);
  438. * `active` 默认维度键(认不出取第一个);`unit`/`emptyText` 文案。
  439. */
  440. export function dimensionBarsBlock(label, rows, options = {}) {
  441. const list = Array.isArray(rows) ? rows : []
  442. const countOf = typeof options.countOf === 'function' ? options.countOf : (row) => (row && row.count) || 0
  443. const hintOf = typeof options.hintOf === 'function' ? options.hintOf : null
  444. const extras = options.extras || {}
  445. const isBlank = (value) => value === null || value === undefined || String(value).trim() === ''
  446. const dimensions = (Array.isArray(options.dimensions) ? options.dimensions : [])
  447. .filter((dim) => dim && dim.key && dim.field)
  448. .map((dim) => {
  449. const labelOf = typeof dim.labelOf === 'function' ? dim.labelOf : (value) => codeText(value)
  450. const byValue = new Map()
  451. for (const row of list) {
  452. if (!row) continue
  453. const raw = row[dim.field]
  454. const blank = isBlank(raw)
  455. const key = blank ? 'UNASSIGNED' : String(raw).trim()
  456. let entry = byValue.get(key)
  457. if (!entry) {
  458. entry = { key, label: blank ? dim.emptyLabel || CODE_EMPTY : labelOf(raw), count: 0 }
  459. for (const name of Object.keys(extras)) entry[name] = 0
  460. byValue.set(key, entry)
  461. }
  462. entry.count += Number(countOf(row)) || 0
  463. for (const [name, source] of Object.entries(extras)) entry[name] += Number(row[source]) || 0
  464. }
  465. const sorted = sortByCountDesc([...byValue.values()], (row) => row.count)
  466. const max = sorted.reduce((top, row) => Math.max(top, row.count), 0)
  467. return {
  468. key: dim.key,
  469. label: dim.label,
  470. rows: sorted.map((row, index) => ({
  471. key: row.key,
  472. label: row.label,
  473. count: row.count,
  474. ratio: max > 0 ? row.count / max : 0,
  475. tone: barTone(row.label, index),
  476. hint: hintOf ? hintOf(row) : '',
  477. })),
  478. }
  479. })
  480. const wanted = options.active || ''
  481. const active = dimensions.find((dim) => dim.key === wanted) || dimensions[0] || null
  482. return {
  483. kind: 'dimbars',
  484. label,
  485. unit: options.unit || '',
  486. emptyText: options.emptyText || '该维度当前没有记录',
  487. dimensions,
  488. // `active` 只是**默认显示**的维度;另一维度的 rows 已经算好,切换不回 domain。
  489. active: active ? active.key : '',
  490. }
  491. }
  492. /** 键值行块。 */
  493. export function rowsBlock(label, rows) {
  494. return { kind: 'rows', label, rows }
  495. }
  496. /**
  497. * 条目列表块(应急保障三段、管理单位的机构清单都用它)。
  498. *
  499. * 票 64 新增可选 `options`(不传就是原形状,既有调用零影响):
  500. * · `paginate: true` —— 声明「这一块的行由渲染层做统一分页」(规范条文 1/2/4:
  501. * 每页 ≤10、页码直达 + 输入框跳页、一页不画控件)。用于**行数可达数千**的块
  502. * (运行信息指标列表实测 6772 条 / 2.3 MB,票 57 首版误按「契约有界」排除,
  503. * 2026-10-19 维护者反馈纠正为接入)。
  504. * · 其余字段原样透传(`emptyText` 等)。
  505. * 块**不携带页码状态**:分页是「这一屏怎么看」的视图私有状态(票 46 成例),
  506. * 渲染层按 `blockKey` 记页码、用 `domain/pagination.js` 的 `pagedRows` 单源切窗口。
  507. * @param {string} label 块标题
  508. * @param {Array} rows 条目行
  509. * @param {string} [emptyText] 空态文案
  510. * @param {{paginate?: boolean}} [options]
  511. */
  512. export function listBlock(label, rows, emptyText, options = {}) {
  513. const block = { kind: 'list', label, rows, emptyText }
  514. if (options.paginate === true) {
  515. block.paginate = true
  516. // `pageScope`=页码归属的筛选条件标识(换行业/片区⇒变,定时刷新⇒不变)。
  517. // 渲染层据此兑现条文 5/6「条件变化回第 1 页、刷新不复位」——键必须是跨重算
  518. // **稳定的标量**,不是块对象身份(票 43 第四轮记过的教训:视图计划每次重算
  519. // 都生成全新对象,按对象记会把每次刷新都读成换筛选)。
  520. block.pageScope = typeof options.pageScope === 'string' ? options.pageScope : ''
  521. }
  522. return block
  523. }
  524. /**
  525. * 趋势块(票 18 的「事件趋势」;票 20 的近 7 日趋势可复用)。
  526. *
  527. * `points` 是**后端给的分桶序列**,本层不重采样、不补点:桶的粒度与顺序都是后端语义
  528. * (`monitoring/situation` 按业务时区小时分桶)。条长按最大桶归一,与分布块同一口径。
  529. *
  530. * 2026-10-13 维护者要求(图表形态丰富化):
  531. * · `chart: 'line'` 产出折线几何(时间趋势用折线更精准,见维护者给的图表选型表)——
  532. * 几何在这里算好(SVG 视框 100×40 的 polyline 点列),组件只画,不认后端字段;
  533. * · 缺省仍是柱列(`chart` 为空 → 柱),既有断言与走查读数不受影响。
  534. * @param {string} label 块标题
  535. * @param {Array<{key: string, label: string, count: number, hint?: string}>} points
  536. * @param {{chart?: 'line'|'bar'}} [options] 形态(缺省柱列)
  537. */
  538. export function trendBlock(label, points, options = {}) {
  539. const rows = Array.isArray(points) ? points : []
  540. const max = rows.reduce((top, row) => Math.max(top, row.count || 0), 0)
  541. const chart = options.chart === 'line' ? 'line' : 'bar'
  542. const n = rows.length
  543. const peakIndex = rows.findIndex((point) => (point.count || 0) === max)
  544. // 折线几何(2026-10-13 维护者第三轮:原折线太丑、无坐标对应关系)。
  545. // 统一用 0–100 归一坐标:x 按点数均分、y 按最大值归一(0 贴底、max 贴顶)。
  546. // 组件用同一份 xPct/yPct 画 HTML 圆点与数值标签(保持圆点正圆、文字清晰),
  547. // SVG 只画拉伸无碍的折线 + 面积(preserveAspectRatio=none)。
  548. const xPct = (i) => (n > 1 ? (i / (n - 1)) * 100 : 50)
  549. const yPct = (count) => (max > 0 ? ((Number(count) || 0) / max) * 100 : 0)
  550. const linePoints =
  551. chart === 'line' && n >= 2
  552. ? rows.map((row, i) => ({
  553. key: row.key,
  554. label: row.label,
  555. count: row.count || 0,
  556. displayText: row.displayText || '',
  557. annotate: n <= 8 || i === peakIndex,
  558. x: xPct(i),
  559. y: 100 - yPct(row.count),
  560. xPct: xPct(i),
  561. yPct: yPct(row.count),
  562. }))
  563. : []
  564. return {
  565. kind: 'trend',
  566. label,
  567. max,
  568. chart,
  569. points: rows.map((row) => ({
  570. key: row.key,
  571. label: row.label,
  572. count: row.count || 0,
  573. hint: row.hint || '',
  574. displayText: row.displayText || '',
  575. ratio: max > 0 ? (row.count || 0) / max : 0,
  576. })),
  577. linePoints,
  578. // 面积填充多边形:折线点列 + 右下/左下两角闭合(SVG 0–100 空间,y 向下)。
  579. areaPoints:
  580. linePoints.length >= 2
  581. ? `${linePoints.map((p) => `${p.x},${p.y}`).join(' ')} 100,100 0,100`
  582. : '',
  583. // Y 轴刻度与几何比例一致;零序列只画底部 0。
  584. yTicks:
  585. chart === 'line' && n >= 2
  586. ? (max > 0 ? [0, 0.5, 1] : [0]).map((f) => ({
  587. value: max * f,
  588. pct: f * 100,
  589. }))
  590. : [],
  591. // 点数少时每个点顶标注数值(维护者要「线与数值对应」);多时只标峰值 + 悬停 title。
  592. showInlineLabels: chart === 'line' && n >= 2 && n <= 8,
  593. // X 轴标签抽样步长:最多显示 ~6 个(首尾必显),避免小时粒度挤成一团。
  594. 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))))),
  595. axisEvery: chart === 'line' && n >= 2 ? Math.ceil(n / 4) : 1,
  596. }
  597. }
  598. /**
  599. * 百分比堆叠条块(2026-10-13 维护者图表选型表:「占比但类别较多」用百分比堆叠条)。
  600. *
  601. * 与 `barsBlock` 的差异:条长是**占总和的百分比**且只有一条(类别并排堆叠在一根条内),
  602. * 适合类别多、总和有意义的场景(如工单状态分布、监测覆盖)。
  603. * 2026-10-13 维护者反馈「单色系分不清类别」:分段色改按 `barTone` 语义着色
  604. * (工单状态行命中 lv1..lv4/neutral 语义色;含行业词时用行业色),
  605. * `shade` 保留给同语义无法区分时的兜底(组件侧 tone 为空才用)。
  606. *
  607. * 票 43「类别间小间隔」的根因与修法:组件原先对 `left` 与 `width` **各自独立取整**,
  608. * 相邻段之间就会露出 `round(nextStart) − (round(start) + round(width))` 的底色缝
  609. * (工单 9 张时「待验收→已结案」边界实测 1 个百分点)。那不是语义边界——
  610. * 条段边界改在这里由**同一个累加器**取整:`startPct`/`widthPct` 逐行接力
  611. * (每段起点=上一段终点,末段终点恒为 100),连续由构造保证,而不是靠运气舍入。
  612. * `start`/`ratio` 原样保留(占比口径不变,票 43 非目标:不改统计字段与状态口径)。
  613. * @param {string} label 块标题
  614. * @param {Array<{key?, label: string, count: number}>} rows
  615. */
  616. export function stackedBlock(label, rows) {
  617. const list = Array.isArray(rows) ? rows : []
  618. const total = list.reduce((sum, row) => sum + (Number(row.count) || 0), 0)
  619. // 同块内同语义 tone 的出现次数(如工单四个待办态都是 lv3):>1 时组件用 shade 深浅区分。
  620. const toneCount = new Map()
  621. for (const row of list) {
  622. const tone = barTone(row.label, 0)
  623. toneCount.set(tone, (toneCount.get(tone) || 0) + 1)
  624. }
  625. let acc = 0
  626. let prevPct = 0
  627. return {
  628. kind: 'stacked',
  629. label,
  630. total,
  631. rows: list.map((row, i) => {
  632. const count = Number(row.count) || 0
  633. const start = total > 0 ? acc / total : 0
  634. acc += count
  635. // 边界只取整一次:本段终点就是下一段起点(`prevPct` 接力),无缝由构造保证。
  636. const endPct = total > 0 ? Math.round((acc / total) * 100) : 0
  637. const startPct = prevPct
  638. prevPct = endPct
  639. const tone = barTone(row.label, i)
  640. return {
  641. key: row.key ?? row.label,
  642. label: row.label,
  643. count,
  644. // 占比与条内起点/宽度(百分比堆叠),0 计数的行保留图例但不占条宽。
  645. ratio: total > 0 ? count / total : 0,
  646. start,
  647. // 票 43:渲染层用这两个**已接力的整数百分比**画条段(不再各自取整)。
  648. startPct,
  649. widthPct: endPct - startPct,
  650. tone,
  651. // 该语义在块内是否重复(组件据此决定是否用 shade 深浅区分)。
  652. toneShared: (toneCount.get(tone) || 0) > 1,
  653. shade: list.length > 1 ? i / (list.length - 1) : 1,
  654. }
  655. }),
  656. }
  657. }
  658. // ================================================================ 可定位对象与缺失
  659. /** 一个"可定位对象"条目(`pointCode` 是**点位的**编码,不是对象的编码)。 */
  660. export function locateEntries(source, label, objects, pointCodes) {
  661. return (pointCodes || []).map((pointCode) => ({
  662. key: `${source}|${pointCode}`,
  663. label,
  664. pointCode,
  665. objectCode: (objects && objects.code) || '',
  666. objectName: (objects && objects.name) || '',
  667. }))
  668. }
  669. /** 收集一个面板视图里全部可定位对象(走查与统计用)。 */
  670. export function panelLocateEntries(view) {
  671. const entries = []
  672. for (const block of (view && view.blocks) || []) {
  673. for (const row of block.rows || []) {
  674. for (const point of row.points || []) entries.push(point)
  675. }
  676. }
  677. for (const entry of (view && view.locate) || []) entries.push(entry)
  678. return entries
  679. }
  680. /** 收集一个面板视图里真实的字段缺失(含嵌套层,票 05 的 `missing` 清单口径)。 */
  681. export function panelMissingFields(data) {
  682. return collectMissing(data)
  683. }
  684. /** 字段缺失 → 界面那一行(只报条数,路径进 tooltip;票 11 的立场)。 */
  685. export function panelMissingNote(data) {
  686. return missingNote(data)
  687. }
  688. /** 行业码 → 展示名(认不出的回空串;本文件不另造行业词表)。 */
  689. export function industryText(code) {
  690. return industryLabel(code) || ''
  691. }