| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726 |
- /**
- * 面板块的**共享积木**(票 18 从票 17 的 `domain/infrastructurePanels.js` 提出来)。
- *
- * 为什么单独成一层:票 17 已判定「四块面板的形状是同一件事(几个 KPI + 若干分布/清单),
- * 差异只在字段与文案」,并把这句话兑现成了一个共享渲染层。票 18/19/20 沿用同一形状,
- * 于是「怎么画一个块」与「某个模式的面板里放哪些块」是两件事,后者各票一份、前者只有一份。
- * 不提出来的话 `domain/monitoringPanels.js` 就得 import 「基础设施」那个模块来拿这几个构造函数,
- * 那正是票 17 警告的耦合(下游三票会长出十二份实现)。
- *
- * 本文件全是纯函数:不 import service、不碰 DOM、不自己发请求(票 05 的 D3/D4 守卫盯着)。
- *
- * 三条纪律(票 05/11/16 定下,票 17 收口在这里,本票一字不改):
- * · **缺值显示占位符,不显示虚假零值**:`numberish` 认不出就回 null,界面画 `—`;
- * · **过期与空分开**:过期是有值(内容照画 + 保留标识),空是确实没有;
- * · **接口词汇不上大屏**:原因码翻人话、认不出的**回空串**,字段缺失只报条数。
- *
- * 关于 `CODE_TEXT` 的边界(票 18 特别记一笔):这张表**不含点位状态词**。
- * `EMPTY` 在这里是新鲜度的空值哨兵「无数据」,而点位状态里的 `EMPTY` 是「无设备」——
- * 同一个码在两个词表里意思不同,合成一张就会把「无设备」画成「无数据」。
- * 点位状态的中文标签归 `domain/pointStatus.js`(票 09 的唯一一张表),本文件不碰。
- */
- import { DETAIL_PLACEHOLDER } from './detailFlow.js'
- import { SECTION_STATE } from './sectionState.js'
- import { collectMissing, missingNote } from './equipmentDetailView.js'
- import { industryLabel } from './filterOptions.js'
- import { ringSlicePath } from './ringGeometry.js'
- /**
- * 码 → 中文的**枚举直译表**(跨模式共用的那一部分)。
- *
- * 两件事:
- * 1. 直译后端**自己文档里冻结的**枚举(取值清单逐条取自响应类/服务常量与契约手册);
- * 2. **认不出的码回原码**(区域码这类"是个值"的东西照原样带出,不猜、也不显示成空白 ——
- * 空白会被读成"没有数据",而"没见过的区域码"其实是真实数据)。
- *
- * 与原因码不同:原因码认不出必须回空串(那是接口词汇,不该给值班人员看),
- * 区域/行业码是**数据值**(和点位编码、设备编码同类),照原样显示才是诚实的。
- */
- export const CODE_TEXT = {
- // 行业(契约手册第 3 节冻结:GAS / WATER_SUPPLY / DRAINAGE)
- GAS: '燃气',
- WATER_SUPPLY: '供水',
- DRAINAGE: '排水',
- // 年限分桶(`CockpitInfrastructureBaseArchivesService` 的取值清单)
- UNDER_5_YEARS: '5 年以内',
- '5_TO_10_YEARS': '5–10 年',
- '10_TO_20_YEARS': '10–20 年',
- '20_YEARS_OR_OVER': '20 年以上',
- // 指标类别(`CockpitInfrastructureOperatingInformationService`)
- SUPPLY: '供应',
- FLOW: '输送',
- PRESSURE: '压力',
- QUALITY: '水质',
- CAPACITY: '承载',
- // 设施规模枚举(票 38 已删除界面上唯一的「规模」读数——真实后端 totals.scale 恒 null)。
- // 这三行翻译**保留**:`scale` 仍是后端透传的自由字符串,词表若删,将来任何一处误把它
- // 画上屏就直接漏出英文枚举(票 17 真机走查逮到过「规模=medium」)。翻译只有一份。
- large: '大型',
- medium: '中型',
- small: '小型',
- // 负荷状态(同服务的两个阈值:0.8 关注、1.0 超载)
- NORMAL: '正常',
- WATCH: '关注',
- OVERLOAD: '超载',
- // 新鲜度(契约手册第 2 节的五态字典)
- CURRENT: '新鲜',
- STALE: '滞后',
- EXPIRED: '过期',
- // 权属类型(管理信息演示载荷的四个取值)
- OWNED: '自有',
- LEASED: '租赁',
- ENTRUSTED: '委托',
- // 应急保障三类对象(演示载荷的 `categoryName()` 就是这三个中文名)
- EMERGENCY_RESOURCE: '应急资源',
- HAZARD_SOURCE: '危险源',
- PROTECTION_TARGET: '防护目标',
- // 三个"没有值"的哨兵值 —— **必须映射**,否则会把接口哨兵原样印到大屏上。
- // `UNKNOWN` 是区域/负荷/权属的空值哨兵,`EMPTY` 是新鲜度的空值哨兵。
- UNKNOWN: '未知',
- EMPTY: '无数据',
- }
- /** 后端 `region`/`area` 的归一空值(契约手册:`region` 空值在响应中归一为 `未分区`)。 */
- export const REGION_EMPTY = '未分区'
- export const CODE_EMPTY = '未知'
- /**
- * 用一张给定的词表把码翻成人话;认不出的**回原码**(见 `CODE_TEXT` 的说明)。
- * @param {object} dictionary 码 → 中文
- * @param {string} code 后端码/值
- * @param {object} [options] `{ empty }` 空值时的说法
- */
- export function textFrom(dictionary, code, { empty = CODE_EMPTY } = {}) {
- const value = typeof code === 'string' ? code.trim() : ''
- if (!value) return empty
- return (dictionary && dictionary[value]) || value
- }
- /** 码 → 人话(共享词表)。 */
- export function codeText(code, options) {
- return textFrom(CODE_TEXT, code, options)
- }
- /** 区域码 → 人话(空值=未分区,这是后端归一后的取值)。 */
- export function regionText(code) {
- return codeText(code, { empty: REGION_EMPTY })
- }
- /**
- * 数字/数字串 → 数字;认不出回 null(**绝不回 0**:0 会被读成"确实是 0",那是谎话)。
- * 后端 BigDecimal 的线上形状不统一(演示载荷是 `"0.32"`),所以两种都认。
- */
- export function numberish(value) {
- if (typeof value === 'number' && Number.isFinite(value)) return value
- if (typeof value === 'string' && value.trim() !== '') {
- const parsed = Number(value)
- if (Number.isFinite(parsed)) return parsed
- }
- return null
- }
- /** 数字 + 单位;认不出只显示占位符(不拼出「— m³/h」这种半截读数)。 */
- export function numberText(value, unit) {
- const parsed = numberish(value)
- if (parsed === null) return DETAIL_PLACEHOLDER
- return unit ? `${parsed} ${unit}` : String(parsed)
- }
- /** 带小数的比例读数(后端给的是 0–1 的小数),原样带出、不四舍五入掉精度。 */
- export function ratioText(value) {
- const parsed = numberish(value)
- if (parsed === null) return DETAIL_PLACEHOLDER
- return `${(parsed * 100).toFixed(parsed * 100 >= 100 ? 0 : 1)}%`
- }
- // ================================================================ 分区状态(视图层口语)
- /** 有内容可画的两种状态(过期是有值)。 */
- export const CONTENT_STATES = [SECTION_STATE.SUCCESS, SECTION_STATE.STALE]
- /** 该分区有没有内容可画。 */
- export function sectionHasContent(section) {
- return CONTENT_STATES.includes((section && section.state) || '')
- }
- /** 面板内某个**子块**(如后端给的分区状态码)有没有内容。 */
- export function childHasContent(state) {
- return state === 'READY' || state === 'STALE'
- }
- /**
- * 该面板是否还没取过数(界面据此说"正在取数"而不是"没有数据")。
- * 整段缺失(`null`/`undefined`)也算"还没有"——**不能**当成失败:
- * "这个分区还没有位置"与"取了但坏了"是两件事,只有后者才有资格说"加载失败"。
- */
- export function sectionPending(section) {
- if (!section) return true
- return section.loaded === false
- }
- /** 没有内容时那一行占位说明(措辞与票 11 的六块分区保持一致,不另造一套)。 */
- export function sectionPlaceholder(section) {
- // 还没发过请求时说"正在取数",**不能说"没有数据"** ——
- // 那是在报告一件我们还没问过的事。
- if (sectionPending(section)) return '正在取数…'
- const state = (section && section.state) || SECTION_STATE.FAILED
- if (state === SECTION_STATE.EMPTY) return '该面板当前没有数据'
- if (state === SECTION_STATE.UNAVAILABLE) return '该面板数据不可用(其它面板不受影响)'
- if (state === SECTION_STATE.FAILED) return '该面板加载失败(其它面板不受影响)'
- if (state === SECTION_STATE.STALE) return '' // 过期是有值,内容照画
- return ''
- }
- // ================================================================ 四种块构造器
- /**
- * KPI 读数 → 图标语义名(2026-10-13 维护者要求「每一项都可以在左侧增加一个图标」)。
- *
- * 图标来自批次 1 入库的蓝湖正式切图(`domain/icons.js` 的注册表);
- * 按 **KPI key 的语义词**映射而不是逐面板映射:同名 key 在任何面板都指同一指标,
- * 一张表覆盖全部 12 处 `kpiBlock` 调用点。未列出的 key 不画图标(渲染层跳过,不抛错)。
- */
- export const KPI_ICONS = {
- // 规模/底数(票 37 走查扩展:值=manifest 的 icon.kpi.* 语义名,mask 染语义色)
- facilityCount: 'facilityCount',
- smartDeviceCount: 'smartDeviceCount',
- deviceCount: 'deviceCount',
- equipmentCount: 'deviceCount',
- monitoringPointCount: 'monitoringPointCount',
- monitoringFacilityCount: 'facility',
- infrastructureFacilityCount: 'facility',
- industryCount: 'industryCount',
- // 运行状态
- onlineRate: 'onlineRate',
- online: 'onlineRate',
- offline: 'offline',
- stale: 'stale',
- unknownOrMissing: 'unknownOrMissing',
- // 预警/报警/处置
- warningTotal: 'warning',
- alarmTotal: 'alarm',
- warningCount: 'warning',
- alarmCount: 'alarm',
- abnormalCount: 'abnormalCount',
- offlineCount: 'offline',
- '预警-total': 'warning',
- '预警-handled': 'resolved',
- '预警-pending': 'warning',
- '预警-cancelled': 'unknownOrMissing',
- '预警-unlinked': 'industryCount',
- '预警-rate': 'resolved',
- '报警-total': 'alarm',
- '报警-handled': 'resolved',
- '报警-pending': 'alarm',
- '报警-cancelled': 'unknownOrMissing',
- '报警-unlinked': 'industryCount',
- '报警-rate': 'efficiency',
- handlingRate: 'efficiency',
- handlingRateView: 'efficiency',
- responseDuration: 'time',
- closeDuration: 'time',
- // 基础档案(票 38:`scale` 规模读数已删除——真实后端 totals.scale 恒 null)
- lengthKm: 'facility',
- // 应急保障
- hazardSourceCount: 'unknownOrMissing',
- protectionTargetCount: 'shield',
- emergencyResourceCount: 'scale',
- // 监测运行
- createdAt: 'time',
- }
- /**
- * KPI 图标的**语义色**(票 37 走查扩展:图标颜色随指标语义走)。
- * `CockpitIcon` 的 mask 模式下颜色由 currentColor 给,渲染层据此给图标容器上色。
- * 未列出的 key 回主蓝(--cyan)——有图标但无特殊语义的读数仍可辨。
- */
- export const KPI_ICON_COLORS = {
- onlineRate: 'var(--lv4, #27d17a)',
- online: 'var(--lv4, #27d17a)',
- offline: 'var(--muted, #5680b0)',
- stale: 'var(--lv2, #ffb020)',
- unknownOrMissing: 'var(--lv2, #ffb020)',
- warningTotal: 'var(--lv2, #ffb020)',
- warningCount: 'var(--lv2, #ffb020)',
- '预警-total': 'var(--lv2, #ffb020)',
- '预警-pending': 'var(--lv2, #ffb020)',
- '预警-rate': 'var(--lv4, #27d17a)',
- '预警-handled': 'var(--lv4, #27d17a)',
- alarmTotal: 'var(--lv1, #ff3b52)',
- alarmCount: 'var(--lv1, #ff3b52)',
- '报警-total': 'var(--lv1, #ff3b52)',
- '报警-pending': 'var(--lv1, #ff3b52)',
- '报警-rate': 'var(--lv4, #27d17a)',
- '报警-handled': 'var(--lv4, #27d17a)',
- abnormalCount: 'var(--lv1, #ff3b52)',
- offlineCount: 'var(--muted, #5680b0)',
- handlingRate: 'var(--lv4, #27d17a)',
- handlingRateView: 'var(--lv4, #27d17a)',
- createdAt: 'var(--muted, #5680b0)',
- }
- /** KPI 块。`value` 为 null 时界面画占位符(`valueText` 负责)。
- * 每项自动带 `icon`(KPI_ICONS 映射,未命中为空串 → 组件不画图标)
- * 与 `iconColor`(KPI_ICON_COLORS,未命中回空串 → 渲染层用主蓝)。 */
- export function kpiBlock(items, options = {}) {
- return {
- kind: 'kpi',
- layout: options.layout || '',
- items: items.map((item) => ({
- ...item,
- icon: KPI_ICONS[item.key] || '',
- iconColor: KPI_ICON_COLORS[item.key] || '',
- })),
- }
- }
- /**
- * 行 key → 分类色键(2026-10-13 维护者要求:UI 与统计图表用行业语义色区分类别)。
- *
- * 三类语义优先级:
- * 1. **行业行**(燃气/供水/排水/窨井)→ 行业色(与行业切图、地图标记同源,一眼对应);
- * 2. **状态行**(指标状态/点位状态/事件等级/工单状态)→ 语义色键(报警=lv1、正常=lv4…);
- * 3. 其余类别(片区、年限、类型)→ 图表色序(--chart-c1..c8,按行序取)。
- *
- * 工单八态(2026-10-13 维护者第三轮:「已取消=红、延期中=黄,区分要更明显」)逐态显式映射:
- * 待派单/待接单/已接单/待验收 → 图表蓝系四档(流程推进的中间态,同族但可辨);
- * 处理中 → lv3(进行中主蓝);已结案 → lv4(成功绿);
- * 延期中 → lv2(黄=需要关注);已取消 → lv1(红=终止)。
- *
- * 组件按 `tone` 解析颜色:`industry:gas` 这类带前缀的键映射到行业 token,
- * 纯键(lv1..lv4/neutral)映射语义 token,`chart:N` 按序映射图表色。
- * 返回空串=无语义(组件退回默认主色,不参与分类着色)。
- */
- export function barTone(label, index) {
- const text = String(label || '')
- // 1) 行业语义
- if (/燃气|GAS/i.test(text)) return 'industry:gas'
- if (/供水|WATER/i.test(text)) return 'industry:water'
- if (/排水|DRAINAGE/i.test(text)) return 'industry:drainage'
- if (/窨井|井盖|MANHOLE/i.test(text)) return 'industry:manhole'
- // 2) 工单八态逐态映射(维护者第三轮:「已取消=红、延期中=黄,区分要更明显」)。
- // 流程五态各占一个色相(蓝/橙/青/紫/青绿,互不撞色),
- // 终态走业务语义色:已结案=绿(完成)、延期中=黄(关注)、已取消=红(终止)。
- // 八态八色相,堆叠条/图例一眼分得清。
- if (/^待派单$/.test(text)) return 'chart:1'
- if (/^待接单$/.test(text)) return 'chart:2'
- if (/^已接单$/.test(text)) return 'chart:3'
- if (/^处理中$/.test(text)) return 'chart:4'
- if (/^待验收$/.test(text)) return 'chart:5'
- if (/^已结案$/.test(text)) return 'lv4'
- if (/^延期中$/.test(text)) return 'lv2'
- if (/^已取消$/.test(text)) return 'lv1'
- // 3) 其余状态语义(只认整词,避免「预警分析」这类含字词误命中)
- if (/^(报警|1\s*级|ALARM|1级)$/.test(text)) return 'lv1'
- if (/^(预警|2\s*级|WARNING|2级)$/.test(text)) return 'lv2'
- if (/^(工单处置|3\s*级|3级)$/.test(text)) return 'lv3'
- if (/^(正常|在线|已完成|已处理|4\s*级|4级|已解除)$/.test(text)) return 'lv4'
- if (/^离线$/.test(text)) return 'point-offline'
- if (/^无设备$/.test(text)) return 'point-empty'
- // 票 39:「未知/缺失」是监测概览设备状态桶的正式措辞(后端 `unknownOrMissingCount`),
- // 与「状态未知」(总体概览同一四态)同语义;不登记就落图表色序——
- // 同一个语义在两个面板一个有色一个没有,正是「早晚不一致」的起点。
- if (/^(未发布|状态未发布|未知|未知\/缺失)$/.test(text)) return 'point-unknown'
- if (/^(未关联设备|无关联设备)$/.test(text)) return 'point-unassociated'
- // 票 39:「数据滞后」是同一桶的第二措辞(`staleCount`),语义与「滞后」相同(关注色)。
- if (/^(过期|未关联工单|未关联|滞后|数据滞后|无数据)$/.test(text)) return 'neutral'
- // 4) 其余类别 → 图表色序(按行序,同块内不重色)
- return `chart:${(index % 8) + 1}`
- }
- /**
- * 按计数**从多到少**排序,并列时**保持传入次序**(票 37)。
- *
- * 为什么手写序号比较而不是直接用 `Array#sort`:引擎的排序稳定性是实现细节,
- * 而「后端已排序时不得打乱其并列次序」是本票的验收条文 —— 得由代码结构保证,
- * 不能靠"现代引擎恰好稳定"。后端已降序时本函数是恒等变换(同一数组内容、同一顺序)。
- *
- * @param {Array} rows 待排序行
- * @param {(row: object) => number} [countOf] 取计数字段(缺省 `row.count`)
- * @returns {Array} 新数组(不改入参)
- */
- export function sortByCountDesc(rows, countOf = (row) => row.count) {
- const list = Array.isArray(rows) ? rows : []
- return list
- .map((row, index) => ({ row, index, count: Number(countOf(row)) || 0 }))
- .sort((a, b) => b.count - a.count || a.index - b.index)
- .map((item) => item.row)
- }
- /**
- * 分布块:`ratio` 只用于画条长(相对长度,不冒充占比;全 0 时给 0 而不是 NaN)。
- * 每行自动带 `tone` 分类色键(维护者 2026-10-13 要求:行业行用行业色、状态行用语义色)。
- *
- * `options.action`(票 20 立的接缝,票 37 复用):整块可点,渲染层只转发「哪一行被点了」。
- * 只有**带得出 `actionKey` 的行**才可点:展示名(`label`)不是后端筛选码,
- * 拿它兜底去发参数就是「拿脏值换后端 400」——所以这里不做 label 兜底,
- * 渲染层也按行上有没有 `actionKey` 决定该行是按钮还是普通行。
- *
- * @param {string} label 块标题
- * @param {Array<{code?, label: string, count: number, actionKey?, selected?}>} rows 类别行
- * @param {{action?: string}} [options] 整块可点动作(行变按钮)
- */
- export function barsBlock(label, rows, options = {}) {
- const list = Array.isArray(rows) ? rows : []
- const max = list.reduce((top, row) => Math.max(top, row.count || 0), 0)
- const action = options.action || ''
- return {
- kind: 'bars',
- label,
- action,
- rows: list.map((row, index) => ({
- key: row.code ?? row.label,
- label: row.label,
- count: row.count,
- ratio: max > 0 ? (row.count || 0) / max : 0,
- tone: barTone(row.label, index),
- hint: row.hint || '',
- actionKey: action ? row.actionKey || row.code || '' : '',
- // 当前生效的筛选行(渲染层画选中态,用户据此知道"再点一下就取消")。
- // `=== true`:只有 domain 明说"选中"才画,`row.selected` 为其它形状(脏值)不误亮。
- selected: row.selected === true,
- })),
- }
- }
- /**
- * 环形图块(2026-10-13 维护者反馈:横向条形图太多、视觉疲劳且不直观——
- * 「类别占比、类别少」的场景改用环形图,中心读数给合计)。
- *
- * 与饼图(`panel-pie`)的分工:饼图是**点位状态分布**专用(带选中高亮、与地图图例同源);
- * 本块是**通用类别构成**(行业构成、按事件类型等),扇区色按 `barTone` 语义着色,
- * 图例在图右、逐项带计数,中心显示合计。0 计数行保留图例不占扇区(同图例 0 不隐藏的立场)。
- *
- * `options.action`(如 `'industry'`):图例行可点(联动写全局筛选),接缝与条形图的
- * `block.action` 同一条(外壳 `handlePanelAction` 不用改);条形图改环形图不丢交互。
- * @param {string} label 块标题
- * @param {Array<{code?, label: string, count: number, actionKey?}>} rows 类别行
- * @param {{action?: string}} [options] 整块可点动作(图例行变按钮)
- */
- export function donutBlock(label, rows, options = {}) {
- const list = Array.isArray(rows) ? rows : []
- const total = list.reduce((sum, row) => sum + (Number(row.count) || 0), 0)
- // 扇区几何:SVG 视框 100×100、圆心 (50,50)、外半径 42 / 内半径 26(环形)。
- // 只给 count>0 的行出扇区;角度从正上方起顺时针。
- // 票 43(重开):`hitPath`=同角度的整楔形(内半径 0)——可见环带太窄,
- // 真实指针落在中心孔/环带外都不触发;命中区由渲染层画透明层承接,
- // 与 `statusPie.js` 的 `hitPath` 同一个办法。
- let angle = 0
- const slices = []
- for (const row of list) {
- const count = Number(row.count) || 0
- if (count <= 0 || total <= 0) continue
- const sweep = (count / total) * 360
- const start = angle
- const end = angle + sweep
- angle = end
- slices.push({
- key: row.code ?? row.label,
- label: row.label,
- count,
- ratio: count / total,
- tone: barTone(row.label, list.indexOf(row)),
- path: ringSlicePath(start, end),
- hitPath: ringSlicePath(start, end, 42, 0),
- })
- }
- return {
- kind: 'donut',
- label,
- total,
- action: options.action || '',
- slices,
- rows: list.map((row, index) => ({
- key: row.code ?? row.label,
- label: row.label,
- count: Number(row.count) || 0,
- tone: barTone(row.label, index),
- actionKey: row.actionKey || row.code || row.key,
- })),
- }
- }
- /**
- * 可切换维度的**分类柱状图**块(票 38 · 管理信息「片区 × 行业」)。
- *
- * 与 `barsBlock`(单维度横条)的分工:本块服务「同一份行数据、按两个字段各聚合一遍」的
- * 场景(决策 06:本项目只有片区/行业两个维度)。**两个维度都由 domain 预先聚合**
- * (`dimensions: [{key,label,rows}]`),切换是纯展示动作 —— 零后端往返、零第二次聚合,
- * 组件也不认识「片区」「行业」是什么(它只按 `block.kind` 分派画法)。
- *
- * 行柱长口径与 `barsBlock` 一致:相对该维度最大项归一(全 0 时给 0 而不是 NaN)。
- * 计数为 0 的类别保留行(画零高柱)——0 是真实值,不是缺失。
- *
- * @param {string} label 块标题
- * @param {Array} rows 原始行(如 `districtIndustry[]`:每行带 district/industry/计数)
- * @param {object} options
- * `dimensions: [{key,label,field,emptyLabel,labelOf}]` 维度清单(field=行上取值字段);
- * `countOf(row)` 取主计数(缺省 `row.count`);
- * `extras: {名字: 行字段}` 逐维度累加的辅助计数;
- * `hintOf(aggRow)` 由聚合行生成悬停说明(如「设备 N 台」);
- * `active` 默认维度键(认不出取第一个);`unit`/`emptyText` 文案。
- */
- export function dimensionBarsBlock(label, rows, options = {}) {
- const list = Array.isArray(rows) ? rows : []
- const countOf = typeof options.countOf === 'function' ? options.countOf : (row) => (row && row.count) || 0
- const hintOf = typeof options.hintOf === 'function' ? options.hintOf : null
- const extras = options.extras || {}
- const isBlank = (value) => value === null || value === undefined || String(value).trim() === ''
- const dimensions = (Array.isArray(options.dimensions) ? options.dimensions : [])
- .filter((dim) => dim && dim.key && dim.field)
- .map((dim) => {
- const labelOf = typeof dim.labelOf === 'function' ? dim.labelOf : (value) => codeText(value)
- const byValue = new Map()
- for (const row of list) {
- if (!row) continue
- const raw = row[dim.field]
- const blank = isBlank(raw)
- const key = blank ? 'UNASSIGNED' : String(raw).trim()
- let entry = byValue.get(key)
- if (!entry) {
- entry = { key, label: blank ? dim.emptyLabel || CODE_EMPTY : labelOf(raw), count: 0 }
- for (const name of Object.keys(extras)) entry[name] = 0
- byValue.set(key, entry)
- }
- entry.count += Number(countOf(row)) || 0
- for (const [name, source] of Object.entries(extras)) entry[name] += Number(row[source]) || 0
- }
- const sorted = sortByCountDesc([...byValue.values()], (row) => row.count)
- const max = sorted.reduce((top, row) => Math.max(top, row.count), 0)
- return {
- key: dim.key,
- label: dim.label,
- rows: sorted.map((row, index) => ({
- key: row.key,
- label: row.label,
- count: row.count,
- ratio: max > 0 ? row.count / max : 0,
- tone: barTone(row.label, index),
- hint: hintOf ? hintOf(row) : '',
- })),
- }
- })
- const wanted = options.active || ''
- const active = dimensions.find((dim) => dim.key === wanted) || dimensions[0] || null
- return {
- kind: 'dimbars',
- label,
- unit: options.unit || '',
- emptyText: options.emptyText || '该维度当前没有记录',
- dimensions,
- // `active` 只是**默认显示**的维度;另一维度的 rows 已经算好,切换不回 domain。
- active: active ? active.key : '',
- }
- }
- /** 键值行块。 */
- export function rowsBlock(label, rows) {
- return { kind: 'rows', label, rows }
- }
- /**
- * 条目列表块(应急保障三段、管理单位的机构清单都用它)。
- *
- * 票 64 新增可选 `options`(不传就是原形状,既有调用零影响):
- * · `paginate: true` —— 声明「这一块的行由渲染层做统一分页」(规范条文 1/2/4:
- * 每页 ≤10、页码直达 + 输入框跳页、一页不画控件)。用于**行数可达数千**的块
- * (运行信息指标列表实测 6772 条 / 2.3 MB,票 57 首版误按「契约有界」排除,
- * 2026-10-19 维护者反馈纠正为接入)。
- * · 其余字段原样透传(`emptyText` 等)。
- * 块**不携带页码状态**:分页是「这一屏怎么看」的视图私有状态(票 46 成例),
- * 渲染层按 `blockKey` 记页码、用 `domain/pagination.js` 的 `pagedRows` 单源切窗口。
- * @param {string} label 块标题
- * @param {Array} rows 条目行
- * @param {string} [emptyText] 空态文案
- * @param {{paginate?: boolean}} [options]
- */
- export function listBlock(label, rows, emptyText, options = {}) {
- const block = { kind: 'list', label, rows, emptyText }
- if (options.paginate === true) {
- block.paginate = true
- // `pageScope`=页码归属的筛选条件标识(换行业/片区⇒变,定时刷新⇒不变)。
- // 渲染层据此兑现条文 5/6「条件变化回第 1 页、刷新不复位」——键必须是跨重算
- // **稳定的标量**,不是块对象身份(票 43 第四轮记过的教训:视图计划每次重算
- // 都生成全新对象,按对象记会把每次刷新都读成换筛选)。
- block.pageScope = typeof options.pageScope === 'string' ? options.pageScope : ''
- }
- return block
- }
- /**
- * 趋势块(票 18 的「事件趋势」;票 20 的近 7 日趋势可复用)。
- *
- * `points` 是**后端给的分桶序列**,本层不重采样、不补点:桶的粒度与顺序都是后端语义
- * (`monitoring/situation` 按业务时区小时分桶)。条长按最大桶归一,与分布块同一口径。
- *
- * 2026-10-13 维护者要求(图表形态丰富化):
- * · `chart: 'line'` 产出折线几何(时间趋势用折线更精准,见维护者给的图表选型表)——
- * 几何在这里算好(SVG 视框 100×40 的 polyline 点列),组件只画,不认后端字段;
- * · 缺省仍是柱列(`chart` 为空 → 柱),既有断言与走查读数不受影响。
- * @param {string} label 块标题
- * @param {Array<{key: string, label: string, count: number, hint?: string}>} points
- * @param {{chart?: 'line'|'bar'}} [options] 形态(缺省柱列)
- */
- export function trendBlock(label, points, options = {}) {
- const rows = Array.isArray(points) ? points : []
- const max = rows.reduce((top, row) => Math.max(top, row.count || 0), 0)
- const chart = options.chart === 'line' ? 'line' : 'bar'
- const n = rows.length
- const peakIndex = rows.findIndex((point) => (point.count || 0) === max)
- // 折线几何(2026-10-13 维护者第三轮:原折线太丑、无坐标对应关系)。
- // 统一用 0–100 归一坐标:x 按点数均分、y 按最大值归一(0 贴底、max 贴顶)。
- // 组件用同一份 xPct/yPct 画 HTML 圆点与数值标签(保持圆点正圆、文字清晰),
- // SVG 只画拉伸无碍的折线 + 面积(preserveAspectRatio=none)。
- const xPct = (i) => (n > 1 ? (i / (n - 1)) * 100 : 50)
- const yPct = (count) => (max > 0 ? ((Number(count) || 0) / max) * 100 : 0)
- const linePoints =
- chart === 'line' && n >= 2
- ? rows.map((row, i) => ({
- key: row.key,
- label: row.label,
- count: row.count || 0,
- displayText: row.displayText || '',
- annotate: n <= 8 || i === peakIndex,
- x: xPct(i),
- y: 100 - yPct(row.count),
- xPct: xPct(i),
- yPct: yPct(row.count),
- }))
- : []
- return {
- kind: 'trend',
- label,
- max,
- chart,
- points: rows.map((row) => ({
- key: row.key,
- label: row.label,
- count: row.count || 0,
- hint: row.hint || '',
- displayText: row.displayText || '',
- ratio: max > 0 ? (row.count || 0) / max : 0,
- })),
- linePoints,
- // 面积填充多边形:折线点列 + 右下/左下两角闭合(SVG 0–100 空间,y 向下)。
- areaPoints:
- linePoints.length >= 2
- ? `${linePoints.map((p) => `${p.x},${p.y}`).join(' ')} 100,100 0,100`
- : '',
- // Y 轴刻度与几何比例一致;零序列只画底部 0。
- yTicks:
- chart === 'line' && n >= 2
- ? (max > 0 ? [0, 0.5, 1] : [0]).map((f) => ({
- value: max * f,
- pct: f * 100,
- }))
- : [],
- // 点数少时每个点顶标注数值(维护者要「线与数值对应」);多时只标峰值 + 悬停 title。
- showInlineLabels: chart === 'line' && n >= 2 && n <= 8,
- // X 轴标签抽样步长:最多显示 ~6 个(首尾必显),避免小时粒度挤成一团。
- 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))))),
- axisEvery: chart === 'line' && n >= 2 ? Math.ceil(n / 4) : 1,
- }
- }
- /**
- * 百分比堆叠条块(2026-10-13 维护者图表选型表:「占比但类别较多」用百分比堆叠条)。
- *
- * 与 `barsBlock` 的差异:条长是**占总和的百分比**且只有一条(类别并排堆叠在一根条内),
- * 适合类别多、总和有意义的场景(如工单状态分布、监测覆盖)。
- * 2026-10-13 维护者反馈「单色系分不清类别」:分段色改按 `barTone` 语义着色
- * (工单状态行命中 lv1..lv4/neutral 语义色;含行业词时用行业色),
- * `shade` 保留给同语义无法区分时的兜底(组件侧 tone 为空才用)。
- *
- * 票 43「类别间小间隔」的根因与修法:组件原先对 `left` 与 `width` **各自独立取整**,
- * 相邻段之间就会露出 `round(nextStart) − (round(start) + round(width))` 的底色缝
- * (工单 9 张时「待验收→已结案」边界实测 1 个百分点)。那不是语义边界——
- * 条段边界改在这里由**同一个累加器**取整:`startPct`/`widthPct` 逐行接力
- * (每段起点=上一段终点,末段终点恒为 100),连续由构造保证,而不是靠运气舍入。
- * `start`/`ratio` 原样保留(占比口径不变,票 43 非目标:不改统计字段与状态口径)。
- * @param {string} label 块标题
- * @param {Array<{key?, label: string, count: number}>} rows
- */
- export function stackedBlock(label, rows) {
- const list = Array.isArray(rows) ? rows : []
- const total = list.reduce((sum, row) => sum + (Number(row.count) || 0), 0)
- // 同块内同语义 tone 的出现次数(如工单四个待办态都是 lv3):>1 时组件用 shade 深浅区分。
- const toneCount = new Map()
- for (const row of list) {
- const tone = barTone(row.label, 0)
- toneCount.set(tone, (toneCount.get(tone) || 0) + 1)
- }
- let acc = 0
- let prevPct = 0
- return {
- kind: 'stacked',
- label,
- total,
- rows: list.map((row, i) => {
- const count = Number(row.count) || 0
- const start = total > 0 ? acc / total : 0
- acc += count
- // 边界只取整一次:本段终点就是下一段起点(`prevPct` 接力),无缝由构造保证。
- const endPct = total > 0 ? Math.round((acc / total) * 100) : 0
- const startPct = prevPct
- prevPct = endPct
- const tone = barTone(row.label, i)
- return {
- key: row.key ?? row.label,
- label: row.label,
- count,
- // 占比与条内起点/宽度(百分比堆叠),0 计数的行保留图例但不占条宽。
- ratio: total > 0 ? count / total : 0,
- start,
- // 票 43:渲染层用这两个**已接力的整数百分比**画条段(不再各自取整)。
- startPct,
- widthPct: endPct - startPct,
- tone,
- // 该语义在块内是否重复(组件据此决定是否用 shade 深浅区分)。
- toneShared: (toneCount.get(tone) || 0) > 1,
- shade: list.length > 1 ? i / (list.length - 1) : 1,
- }
- }),
- }
- }
- // ================================================================ 可定位对象与缺失
- /** 一个"可定位对象"条目(`pointCode` 是**点位的**编码,不是对象的编码)。 */
- export function locateEntries(source, label, objects, pointCodes) {
- return (pointCodes || []).map((pointCode) => ({
- key: `${source}|${pointCode}`,
- label,
- pointCode,
- objectCode: (objects && objects.code) || '',
- objectName: (objects && objects.name) || '',
- }))
- }
- /** 收集一个面板视图里全部可定位对象(走查与统计用)。 */
- export function panelLocateEntries(view) {
- const entries = []
- for (const block of (view && view.blocks) || []) {
- for (const row of block.rows || []) {
- for (const point of row.points || []) entries.push(point)
- }
- }
- for (const entry of (view && view.locate) || []) entries.push(entry)
- return entries
- }
- /** 收集一个面板视图里真实的字段缺失(含嵌套层,票 05 的 `missing` 清单口径)。 */
- export function panelMissingFields(data) {
- return collectMissing(data)
- }
- /** 字段缺失 → 界面那一行(只报条数,路径进 tooltip;票 11 的立场)。 */
- export function panelMissingNote(data) {
- return missingNote(data)
- }
- /** 行业码 → 展示名(认不出的回空串;本文件不另造行业词表)。 */
- export function industryText(code) {
- return industryLabel(code) || ''
- }
|