/** * 面板块的**共享积木**(票 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 { kpiFitVars } from './kpiFit.js' import { SECTION_STATE } from './sectionState.js' import { collectMissing, missingNote } from './equipmentDetailView.js' import { industryLabel } from './filterOptions.js' import { ringSlicePath } from './ringGeometry.js' /** 卡片行可进入的详情对象类型。 */ export const CARD_ACTION = Object.freeze({ POINT: 'point', DEVICE: 'device', EVENT: 'event', WORK_ORDER: 'work-order', FACILITY: 'facility', EMERGENCY: 'emergency', NONE: 'none', }) const CARD_ACTION_KINDS = Object.freeze([ CARD_ACTION.POINT, CARD_ACTION.DEVICE, CARD_ACTION.EVENT, CARD_ACTION.WORK_ORDER, CARD_ACTION.FACILITY, CARD_ACTION.EMERGENCY, ]) /** * 列表行 → 统一详情动作。 * * 身份必须由 domain 显式给出;渲染层不再用 `row.code` 猜它是监测点。缺编码、未知类型 * 或自检失败一律返回 none,让界面保留普通卡片形态并给出如实说明。 */ export function cardActionFor(row) { const source = row && typeof row === 'object' ? row : {} const kind = typeof source.identityKind === 'string' ? source.identityKind.trim() : '' const code = typeof source.identityCode === 'string' ? source.identityCode.trim() : '' if (source.identityValid === false || !CARD_ACTION_KINDS.includes(kind) || !code) { return { kind: CARD_ACTION.NONE, code: '', reason: source.identityReason || '缺少编码,无法展开' } } return { kind, code, reason: source.identityReason || '' } } /** * 码 → 中文的**枚举直译表**(跨模式共用的那一部分)。 * * 两件事: * 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 = '未知' /** * 用给定词表显示中文;未登记机器码显示未知,已发布中文保留;业务标识不走此函数。 * @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 if (dictionary && Object.hasOwn(dictionary, value)) return dictionary[value] return /\p{Script=Han}/u.test(value) && !/\p{Script=Latin}/u.test(value) ? value : CODE_EMPTY } /** 码 → 人话(共享词表)。 */ 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)}%` } // ================================================================ 差值(较昨日) /** * 差值色调(规格 §6.6):**正=红、负=绿、0=中性**。 * * 注意这与「好坏」无关 —— 报警多了是红、少了是绿,是值班习惯(红=需要关注), * 而**不是**在暗示"增加就是坏事"。所以色调只由**符号**决定,不看绝对值。 * 复用项目既有语义色键(lv1 红 / lv4 绿 / neutral 中性),不另造一套颜色词。 */ export function deltaTone(delta) { const value = numberish(delta) if (value === null || value === 0) return 'neutral' return value > 0 ? 'lv1' : 'lv4' } /** * 差值文案(规格 §6.6 原句格式):`较昨日 +6` / `较昨日 -3` / `较昨日 0`。 * 认不出的差值回空串 —— 不画一个假的「较昨日 0」。 */ export function deltaText(delta) { const value = numberish(delta) if (value === null) return '' if (value > 0) return `较昨日 +${value}` if (value < 0) return `较昨日 -${Math.abs(value)}` return '较昨日 0' } // ================================================================ 分区状态(视图层口语) /** 有内容可画的两种状态(过期是有值)。 */ 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 '' } // ================================================================ 高屏补充块(票 63) /** * 将一个真实统计块标为「只在面板确有竖向富余时出现」。 * 渲染层按面板体实测高度与基线内容高度决定是否纳入 DOM;普通屏默认不增加内容高度。 */ export function supplementalBlock(block, minExtraPx = 150) { return { ...block, supplemental: true, minExtraPx: Math.max(1, Number(minExtraPx) || 150) } } /** * 高屏补充块可见判据。非法/未测量输入一律隐藏;边界包含等号。 * 纯函数供渲染层与逻辑闸门共用,避免用视口宽度或面板 id 猜空间。 */ export function supplementalBlocksVisible({ hostHeightPx, baselineContentPx, minExtraPx }) { const host = Number(hostHeightPx) const baseline = Number(baselineContentPx) const required = Number(minExtraPx) if (![host, baseline, required].every(Number.isFinite) || host <= 0 || baseline < 0 || required <= 0) return false return host - baseline >= required } // ================================================================ 空态保设计(票 72) /** * 空/失败/不可用时**数值位**的占位符(票 72,维护者 2026-10-27 反馈第 2 条)。 * * 复用既有的缺值占位符(`—`,来自 `detailFlow.js`),**不另造第二个字符**: * 「没有这个数」在全仓只有一种写法,走查与读屏都只认它。 * 与 `0` 的区别是**语义**而不是排版:`0` 断言"真的是零",无数据时写 0 就是伪造读数 * (与票 05/11 的「不得显示虚假零值」同一条纪律)。 */ export const EMPTY_VALUE = DETAIL_PLACEHOLDER /** 该文本是不是"没有这个数"的占位符(不是 0,也不是空串)。 */ export function isPlaceholderText(text) { return text === DETAIL_PLACEHOLDER } /** * 无数据时**保留面板设计**的视图(票 72)。 * * 三种状态口径(与 `sectionPlaceholder` 同一套判定,不另立第二份): * · 还没取过数(pending)→ 原样返回:界面仍说"正在取数…"(那是一件还没问过的事); * · 有值(SUCCESS / STALE)→ 原样返回:过期是**有值但旧**,保旧值(票 16 的立场); * · 其余(EMPTY / FAILED / UNAVAILABLE,含分区缺失)→ `designOnly: true`: * 块结构(标题/单位/图例/坐标轴/静态类别名)照旧,**数值位一律占位符**。 * * 为什么收在注册表出口这一处(`panelViews.js` 的 `panelView()`)而不是逐面板各写一遍: * 面板已经 15 个、还会再加;逐面板写必然漏,而漏掉的表现正是"这块面板没数据时又变回一句文案"。 * 收在这里则新面板**自动继承**,闸门再按 `implementedPanelIds()` 做正向普查(漏实现即红)。 * * @param {object} view 已由各模式 domain 算好的视图计划 * @param {object|null} section 分区(`domain/sectionState.js` 的 `section()`) * @returns {object} 原视图,或带 `designOnly` 的空态视图 */ export function emptyDesignView(view, section) { if (!view) return view if (sectionPending(section)) return view if (sectionHasContent(section)) return view return { ...view, // 分区状态一字不改:`hasContent` 仍为 false(状态标识与摘要都按真实状态说), // 只是内容区改画设计骨架。 hasContent: false, designOnly: true, blocks: (view.blocks || []).map(emptyDesignBlock), } } /** * 单个块的空态化:结构/标题/类别名/单位/坐标轴**一律不动**,只把数值位换成占位符。 * 数据驱动的行(如"按类型分布")没有数据时本来就没有类别名可画 —— 那部分由渲染层的 * 块级占位符兜底,不在这里编造假类别。 */ function emptyDesignBlock(block) { if (!block || typeof block !== 'object') return block const design = { ...block, placeholder: true } switch (block.kind) { case 'kpi': // 读数与差值("较昨日 +6")在没有数的时候同样是断言,得一起撤。 design.items = (block.items || []).map((item) => ({ ...item, value: EMPTY_VALUE, full: '', delta: undefined, deltaText: '', })) break case 'cards': case 'bars': design.rows = placeholderRows(block.rows) break case 'stacked': design.total = null design.rows = placeholderRows(block.rows).map((row) => ({ ...row, ratio: 0, start: 0, startPct: 0, widthPct: 0, })) break case 'pie': // 饼图的八行图例是**固定桶**(点位状态),没有数据也要在位:只撤计数与扇区几何。 design.ready = true design.total = null design.slices = [] design.rows = placeholderRows(block.rows) break case 'donut': design.total = null design.slices = [] design.rows = placeholderRows(block.rows) break case 'donuts': // 多环块(票 66):环的行业名与共用图例的片区名都是**固定类别**,没有数也要在位 —— // 只撤合计与扇区几何(不画环),图例计数换占位符。 // 没有任何数据时(失败/空/不可用)`groups` 本身是空的,此时用块自带的固定轴 // 长出骨架:**轴由 domain 声明,渲染层与这里都不认识行业/片区词典**(票 18 纪律)。 design.groups = (block.groups || []).length ? block.groups.map((group) => ({ ...group, total: null, placeholder: true, slices: [] })) : (block.axis || []).map((item) => ({ key: item.key, label: item.label, unit: block.unit || '', hint: '', total: null, placeholder: true, slices: [], })) design.legend = (block.legend || []).map((row) => ({ ...row, count: null, placeholder: true })) break case 'dimbars': design.dimensions = (block.dimensions || []).map((dim) => ({ ...dim, rows: placeholderRows(dim.rows), })) break case 'rows': design.rows = (block.rows || []).map((row) => ({ ...row, value: EMPTY_VALUE, placeholder: true, })) break case 'trend': // 时间轴骨架要按"当前窗口的桶"画,而桶是后端给的 —— 没有数据时连桶都没有。 // 这里不伪造一串空桶(那会画出一条全 0 的假线),只清掉点/线/序列, // 由渲染层画块级占位符;桶来源恢复后自然长回来。 design.max = null design.points = [] design.linePoints = [] design.series = [] design.areaPoints = '' break case 'list': // 清单的行就是数据本身;块标题与占位符位由渲染层画。 design.rows = [] break default: break } return design } /** 行集合的空态化:类别名/色键/可点动作全留,只把计数换成占位符。 */ function placeholderRows(rows) { return (Array.isArray(rows) ? rows : []).map((row) => ({ ...row, count: null, placeholder: true, })) } // ================================================================ 四种块构造器 /** /** * 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', // 票 78:预警效率面板预警族的新读数(票 74 契约)——已解除率与平均处置时长。 // 语义图标复用既有词表:已解除率=resolved(同「预警-handled」),时长=time(同结案时长)。 warningResolvedRate: 'resolved', warningDuration: 'time', // 票 70:3+2 版式新增的两族成员。语义图标复用既有词表,不新造切图: // · 预警族「未解除」「未处置」同属「预警族读数」(warning); // · 报警族「未关联工单」沿用 industryCount(素材清单原义就是「行业数/未关联工单」); // · 报警族「已关闭报警」=已了结(resolved)。 warningUnresolved: 'warning', warningUnhandled: 'warning', alarmUnlinked: 'industryCount', alarmClosed: 'resolved', 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', // 票 02 新增的四模块读数键: pipeCount: 'facility', warningToday: 'warning', warningUnresolved: 'warning', alarmToday: 'alarm', alarmConverted: 'resolved', alarmUnconverted: 'unknownOrMissing', alarmRate: 'efficiency', // 基础档案(票 38:`scale` 规模读数已删除——真实后端 totals.scale 恒 null) lengthKm: 'facility', // 总体概览四模块(票 02):读数键映射到**既有**语义图标(不另造切图)。 disposalDuration: 'time', // 票 66:管理信息统计形态的两个新读数键 —— 「覆盖片区」与「覆盖行业」同属「维度覆盖数」, // 共用同一枚维度计数图标(资产库没有片区图标,不新造切图);「未分区设施」是数据质量桶, // 走「未知/缺失」的既有图标。 districtCount: 'industryCount', unassignedFacility: 'unknownOrMissing', // 应急保障 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)', // 票 66:未分区是数据质量桶,与「未知/缺失」同一档关注色(黄),不冒充片区色。 unassignedFacility: 'var(--lv2, #ffb020)', warningTotal: 'var(--lv2, #ffb020)', warningCount: 'var(--lv2, #ffb020)', // 票 78:已解除率走「已解除」的语义色(与「预警-rate」同源);时长不另配色(同响应/结案时长)。 warningResolvedRate: 'var(--lv4, #27d17a)', // 票 70:3+2 新增成员的语义色 —— 未解除/未处置是「还没了结」的预警(黄), // 未关联工单是待补的关联(黄),已关闭报警是已了结(绿)。 warningUnresolved: 'var(--lv2, #ffb020)', warningUnhandled: 'var(--lv2, #ffb020)', alarmUnlinked: 'var(--lv2, #ffb020)', alarmClosed: 'var(--lv4, #27d17a)', '预警-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)', // 总体概览四模块(票 02) warningToday: 'var(--lv2, #ffb020)', warningUnresolved: 'var(--lv2, #ffb020)', alarmToday: 'var(--lv1, #ff3b52)', alarmConverted: 'var(--lv4, #27d17a)', alarmUnconverted: 'var(--lv2, #ffb020)', alarmRate: 'var(--lv4, #27d17a)', } /** * KPI 块。`value` 为 null 时界面画占位符(`valueText` 负责)。 * * 每项自动带: * · `icon`(KPI_ICONS 映射,未命中为空串 → 组件不画图标) * · `iconColor`(KPI_ICON_COLORS,未命中回空串 → 渲染层用主蓝) * · `deltaText`/`deltaTone`(**只有给了数字 `delta` 才出现**:规格 §6.6 的 * 「较昨日 +6 / -3 / 0」与正红负绿;没给差值就没有这一行,不画假的 0) * · `fitVars`(维护者 2026-10-28:指标卡不出现省略号)——标签/读数/口径小字的**宽度预算**, * 由 `domain/kpiFit.js` 按文本与卡内图标算;渲染层用它在窄格降字号(见 PanelBlocks.vue * 的 `font-size: max(... min(... 100cqw ...))`),而不是把字裁成 `…`。 */ export function kpiBlock(items, options = {}) { return { kind: 'kpi', // 版式(2026-10-23 视觉对齐):缺省=「图标+标签 → 读数 → 说明」; // `value-first` =「读数 → 标签」(感知设备顶部四卡,设计稿里读数在上、标签在下, // 且每卡顶部一条语义色横条)。版式是**通用呈现意图**,渲染层不认识任何指标键。 layout: options.layout || '', aside: options.aside || '', // 维护者 2026-10-29:读数强调档(放大一号)。通用呈现意图,由调用方声明, // 渲染层不认识任何面板身份。 emphasis: options.emphasis === true, items: items.map((item) => { // value-first 版式(读数在上、标签在下)的卡片按设计稿不画图标: // 版式说了算,不靠逐项传 icon 空串来关掉(那是把呈现意图散到每个调用点)。 const iconsOff = options.layout === 'value-first' const icon = iconsOff ? '' : KPI_ICONS[item.key] || '' const enriched = { ...item, icon, iconColor: iconsOff ? '' : KPI_ICON_COLORS[item.key] || '', // 读数色 / 卡顶横条 / 卡内说明:都由 domain 给语义,渲染层只查色表。 valueTone: item.valueTone || '', accent: item.accent || '', caption: item.caption || '', detail: item.detail || '', // 票 70:栅格跨度(3+2 版式)。domain 给的是**意图**(占几栏), // 渲染层只照着摆(6 栏栅格下 span 2 / span 3),两边不各写一份排版规则。 span: numberish(item.span) === null ? '' : numberish(item.span), // 宽度预算要用**算完的** icon:有图标时标签列被多吃掉 19u(图标 14u + 列间距 5u)。 fitVars: kpiFitVars({ label: item.label, caption: item.caption, value: item.value, full: item.full, unit: item.unit, icon, }), } // 差值只在 domain 明确给了数字时才落地(`numberish` 认不出 → 不出现)。 const delta = numberish(item.delta) if (delta !== null) { enriched.delta = delta enriched.deltaText = item.deltaText || deltaText(delta) enriched.deltaTone = item.deltaTone || deltaTone(delta) } return enriched }), } } /** * 卡片块:**一组并列的计数卡**(票 02 的三种卡共用这一个构造器)。 * * 三种卡(规格 §6.2/§6.4/§6.5)画的其实是同一件事 —— 一排"标签 + 计数"的小卡, * 差异只在语义:类型卡带一句小字(`note`,如「压力 / 流量 / 漏失」)、 * 片区卡与等级卡是**固定项**(后端不给也是 0 —— 0 是真实计数,不是缺失)。 * 所以只有这一个构造器(不按用途各写一份),由调用方声明 `fixed` 与 `note`: * · `fixed: true` —— 固定项集合,无数据画 0(等级卡/片区卡/类型卡都是); * · `note` —— 卡内小字(类型卡的监测内容;其它卡留空)。 * * `count` 一律是**数字**:卡片位展示的就是计数,认不出的按 0 落(与「固定项无数据 0」 * 同一口径);需要"缺值画占位符"的读数请走 `kpiBlock`(那条纪律一字不改)。 * 卡片**不可点**(规格 §6 没有点卡动作),所以不带 `actionKey` —— 可点排行走 `barsBlock`。 * * 版式(2026-10-23 视觉对齐):`layout` 是**列数意图**——`two-by-two`(类型卡 2×2)、 * `three`(片区卡一排三张)、`four`(等级卡一排四张);缺省按容器宽度自适应。 * `tone` 是行语义色(等级卡 lv1..lv4、类型卡行业色),`icon` 是卡内徽标(行业图标); * `action`/行 `actionKey` 让卡片可点(类型卡 → 写全局行业筛选,与条形图同一条接缝)。 * * @param {string} label 块标题 * @param {Array<{key?: string, label: string, count?: number, note?: string, tone?: string, icon?: string, actionKey?: string}>} rows * @param {{fixed?: boolean, unit?: string, layout?: string, aside?: string, action?: string}} [options] */ export function cardsBlock(label, rows, options = {}) { const list = Array.isArray(rows) ? rows : [] const action = options.action || '' return { kind: 'cards', label, aside: options.aside || '', // 版式(通用呈现意图,渲染层不认任何指标键)。 layout: options.layout || '', // 固定项声明:界面据此画满整排(无数据 0),不因某个类别缺席就少一张卡。 fixed: options.fixed === true, unit: options.unit || '', action, rows: list.map((row) => ({ key: row.key ?? row.code ?? row.label, label: row.label, count: numberish(row.count) ?? 0, // 卡内小字(类型卡的 monitoringContent);没有就不画那一行。 note: row.note || '', // 小字可能被压缩过(如「压力 / 瞬时流量 等」),完整原文放这里给 title 悬停用。 noteTitle: row.noteTitle || '', // 行语义色:染卡边框与徽标(类型卡的行业色)。 tone: row.tone || '', // 读数色:只染数字(等级卡的 lv1..lv4)。与 tone 分开——类型卡是「边框有色、数字白」, // 等级卡是「边框与数字同色」,一个字段表达不了两种。 valueTone: row.valueTone || '', icon: row.icon || '', // 可点行才给 actionKey(与 barsBlock 同一条纪律:不拿展示名兜底当筛选码)。 actionKey: action ? row.actionKey || '' : '', // 当前生效的筛选行(画选中态,用户据此知道"再点一下就取消"):只有 domain 明说选中才亮。 selected: row.selected === true, })), } } /** * 行 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, aside: options.aside || '', // 版式:缺省横条一行一条;`rank` =名次徽章 + 名称 + 计数一行、条在下一行通栏 // (设计稿的「预警类型排行」形态)。渲染层只按 variant 选画法。 variant: options.variant || '', unit: options.unit || '', 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, })), } } /** * 排行块(票 02 · 「近30日 · 共XX起 · 点击筛选」的类型榜)。 * * 它**就是 `barsBlock` 的一个限额视图**,不重写画法:排序由后端给(规格 §4 已按计数降序), * 本层只截前 N 项 —— 不重排、不补足(不足 3 项就画不足,不拿别的类型凑满)。 * 可点沿用同一条接缝(`action` + 行上的 `actionKey`),渲染层只回答"哪一行被点了"。 * * @param {string} label 块标题(构造标题文案由视图层拼,块只拿最终标题) * @param {Array<{code?: string, label: string, count: number}>} rows 后端已排序的行 * @param {{limit?: number, action?: string}} [options] 取前几项(缺省 3;0 = 全部)与整块动作 */ export function rankingBarsBlock(label, rows, options = {}) { // limit 语义:正数 = 只要前 N 名;**0 = 全部**(维护者 2026-10-23 第 4 条: // 「预警类型排行」要列出所有类型,不再只给前三)。缺省仍为 3,其他调用点行为不变。 const all = Array.isArray(rows) ? rows : [] const limit = options.limit === 0 ? all.length : Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 3 const list = all.slice(0, limit) return { ...barsBlock(label, list, { action: options.action || '', aside: options.aside || '', variant: options.variant || '', unit: options.unit || '', }), limit, } } /** * 环形图块(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, aside: options.aside || '', // 版式:缺省=环形在左、图例在右;`stack-below` =环形居中、图例在下横排 // (设计稿的「管道分类占比」形态,且分区标题带「· 共 N 根」)。 layout: options.layout || '', // 图例带横置柱状条(维护者第 16 轮):左右式饼图靠它给出直观的占比对比。 legendBar: options.legendBar === true, total, action: options.action || '', slices, rows: list.map((row, index) => ({ key: row.code ?? row.label, label: row.label, count: Number(row.count) || 0, // 占比:图例里的**横置柱状条**按它定量(维护者第 16 轮:左右式饼图的图例 // 只有数字、没有长度对比,读者要比大小只能心算)。 ratio: total > 0 ? (Number(row.count) || 0) / total : 0, tone: barTone(row.label, index), actionKey: row.actionKey || row.code || row.key, })), } } /** * 多环形网格块(票 66 · 管理信息「片区 × 行业」):一个块里并排画 N 个小环 + 一份共用图例。 * * 与 `donutBlock` 的分工:`donutBlock` 是**一个**类别的构成(左环右图例); * 本块服务「同一种构成、按另一个类别各画一遍」——每个行业一个环,环内扇区是该行业 * 在各片区的设施占比,环心是该行业合计。四个环若各带一份图例,就是一屏四份图例 * (那是 `donutBlock` 的形态,放不下也读不过来),所以图例**只有一份**放在块底。 * * 图例只有一份,扇区色就必须**跨环稳定**:颜色由 `options.legend` 的次序决定, * 不是由每个环自己的行序决定 —— 否则同一个片区在两个环里两种颜色,图例就在说假话。 * * 明细(每格的设施/设备数、片区的设施与设备合计)进 `hint`,由渲染层挂成悬停说明, * 不占屏上位置(票 66 第 3 条:明细不进清单形态)。 * * @param {string} label 块标题 * @param {Array<{key: string, label: string, unit?: string, hint?: string, slices: Array<{key: string, label: string, count: number}>}>} groups 每个环(`slices` 是该环的构成) * @param {{legend?: Array<{key: string, label: string, count?: number, tone?: string, hint?: string}>, unit?: string, aside?: string, emptyText?: string}} [options] * `legend`=共用图例行(`key` 要与 `groups[].slices[].key` 同一套,色才稳定); * `tone` 可由调用方显式给(如「未分区」要中性色),缺省按行序走 `barTone`。 */ export function donutGridBlock(label, groups, options = {}) { const legend = (Array.isArray(options.legend) ? options.legend : []).map((row, index) => ({ key: row.key, label: row.label, count: Number(row.count) || 0, tone: row.tone || barTone(row.label, index), hint: row.hint || '', })) const toneByKey = new Map(legend.map((row) => [row.key, row.tone])) const cells = (Array.isArray(groups) ? groups : []).map((group) => { const source = Array.isArray(group.slices) ? group.slices : [] const total = source.reduce((sum, slice) => sum + (Number(slice.count) || 0), 0) // 几何与 `donutBlock` 一字不差:SVG 视框 100×100、外半径 42 / 内半径 26, // 只给 count>0 的扇区出片;命中区是整楔(`hitPath`,内半径 0),与票 43 同一个办法。 let angle = 0 const slices = [] for (const slice of source) { const count = Number(slice.count) || 0 if (count <= 0 || total <= 0) continue const end = angle + (count / total) * 360 slices.push({ key: slice.key, label: slice.label, count, ratio: count / total, tone: toneByKey.get(slice.key) || barTone(slice.label, source.indexOf(slice)), path: ringSlicePath(angle, end), hitPath: ringSlicePath(angle, end, 42, 0), }) angle = end } return { key: group.key, label: group.label, unit: group.unit || options.unit || '', hint: group.hint || '', total, slices, } }) return { kind: 'donuts', label, aside: options.aside || '', unit: options.unit || '', emptyText: options.emptyText || '当前范围没有片区与行业计数', groups: cells, legend, // 环的固定轴(`[{key,label}]`):只在**没有任何数据**时用来保设计骨架(票 72)。 // 有数据时该画几个环由 `groups` 决定(全 0 的行业不出环)。 axis: (Array.isArray(options.axis) ? options.axis : []).map((item) => ({ key: item.key, label: item.label })), // 可选底部堆叠条(维护者第 16 轮):与各环**共用同一份图例与色序**, // 用来表达「同一批类别的总量构成」——不必再画第三张环,也不用第二份图例。 stack: buildLegendStack(legend, options.stack), } } /** * 共用图例的**底部堆叠条**(维护者第 16 轮):环形网格下面那条「总量构成」。 * * 关键约束:条段色与图例**同一份**(`legend` 的 tone),几何用与 `stackedBlock` 完全相同的 * 整数接力算法 —— 否则同一批类别在环里一个色、在条上另一个色,读者会当成两组数据。 * `spec` 是调用方给的计数来源(缺省用图例行自带的 count)。 */ function buildLegendStack(legend, spec) { if (!spec) return null const counts = new Map((Array.isArray(spec.rows) ? spec.rows : []).map((row) => [row.key, Number(row.count) || 0])) const rows = legend.map((row) => ({ ...row, count: counts.has(row.key) ? counts.get(row.key) : Number(row.count) || 0 })) const total = rows.reduce((sum, row) => sum + row.count, 0) let acc = 0 let prevPct = 0 return { label: spec.label || '', total, rows: rows.map((row) => { acc += row.count const endPct = total > 0 ? Math.round((acc / total) * 100) : 0 const startPct = prevPct prevPct = endPct return { ...row, ratio: total > 0 ? row.count / total : 0, startPct, widthPct: endPct - startPct } }), } } /** * 可切换维度的**分类柱状图**块(票 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` 等)。 * * 票 61 新增两种「分页形态」声明(与 `paginate` 互斥,按块各自选择): * · `serverPage: {page, pageCount, total}` —— 行**已经是后端返回的当前页**, * 渲染层只画控件、翻页发请求(服务端页码分页,如历史事件单窗); * · `pageNote: string` —— 翻页区的如实口径说明(多窗合并范围「已合并 N 个时间窗 · * 已加载 M 条」;即使一页不到、控件不画,这句说明也要出现——它是口径不是控件)。 * 块**不携带页码状态**:分页是「这一屏怎么看」的视图私有状态(票 46 成例), * 渲染层按 `blockKey` 记页码、用 `domain/pagination.js` 的 `pagedRows` 单源切窗口; * 服务端形态的页码状态归 store(请求参数在那儿),块只带当前回显。 * @param {string} label 块标题 * @param {Array} rows 条目行 * @param {string} [emptyText] 空态文案 * @param {{paginate?: boolean, pageScope?: string, serverPage?: object, pageNote?: string}} [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 : '' } // 票 61:服务端页码分页的块(行已是后端当前页)与翻页区口径说明。 if (options.serverPage && typeof options.serverPage === 'object') { block.serverPage = options.serverPage } if (typeof options.pageNote === 'string' && options.pageNote) { block.pageNote = options.pageNote } 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 kept = rows.map((row, i) => ({ row, i })) const linePoints = chart === 'line' && n >= 2 ? kept.map(({ row, i }) => ({ key: row.key, label: row.label, count: row.count || 0, displayText: row.displayText || '', // 悬停读完整原文:X 轴要短(如 MM-DD)、tooltip 要全(如完整日期)。 hint: row.hint || '', annotate: n <= 8 || i === peakIndex, x: xPct(i), y: 100 - yPct(row.count), xPct: xPct(i), yPct: yPct(row.count), })) : [] return { kind: 'trend', label, aside: options.aside || '', // 维护者 2026-10-29:折线「紧凑」版(更矮、上方留呼吸空间),只由调用方显式声明。 compact: options.compact === true, // 维护者第 15 轮:「宽屏太挤,应该适当拉高」—— 高度档由调用方声明(不按 panelId 特判)。 // `tall` 用于双线对照图(线多、需要更大的竖向空间才读得出走势)。 tall: options.tall === true, // 票 71:块级空态文案(缺数据时渲染层那一行说什么)。缺省由渲染层沿用既有措辞, // 既有调用点一个都不用改;只有「没有事件」与「没有时长样本」说得不同的块才传。 emptyText: options.emptyText || '', // 折线语义色键(设计稿:报警折线为橙、现有多处折线为主蓝)。渲染层查同一张色表。 tone: options.tone || '', 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-23):同一张折线图上并列多条线(如"当天新增"与"总未处理"), // 各自按**共同最大值**归一,否则两条线各按自己的尺度画、看不出谁高谁低。 // 每条序列自带 tone(色键)→ 渲染层查同一张色表并画图例。 ...multiSeries(rows, options, { chart, n, xPct, yPct }), } } /** * 多序列折线(维护者 2026-10-23 反馈第 2 条:不要单条线 + 口径小字,要多条线对比 + 图例)。 * * 入参约定:调用方给 `points`(与单序列同形:key/label/count/hint),并给 * `options.series = [{ key, label, tone, counts: number[] }]` —— 每条序列只给"取值列表", * x 轴标签仍共用 `points`。这样调用点不用把同一份日期抄三遍。 * * 归一化用**所有序列的联合最大值**:各画各的尺度会让两条线看起来一样高, * 那正是"对比"要避免的假象。 * * 只画线 + 图例,不画面积与点顶数值 —— 多条线叠面积会糊成一片、点顶标注会互相压。 * 单序列(没给 series)时仍走原来的单线版式(面积 + 点顶数值),行为不变。 */ function multiSeries(rows, options, { chart, n, xPct, yPct }) { // 空数组也算"没有这条线":后端没给该序列时调用方回 `[]`,若放行就会画出一条全 0 的假线 —— // 那是在用 0 冒充"积压为零",与本仓"不把缺失画成 0"的纪律相反。 const declared = Array.isArray(options.series) ? options.series.filter((s) => s && s.key && Array.isArray(s.counts) && s.counts.length > 0) : [] if (!declared.length || chart !== 'line' || n < 2) { return { series: [], multiSeries: false, seriesMax: 0 } } // 维护者 2026-10-29:`null` = 该序列在这个桶里**没有数据**(不是 0)。 // 这类点整点不画 —— 画成 0 会在图上伪造一个「降到底」的读数(票 62 的同一纪律)。 const seriesMax = declared.reduce( (top, series) => series.counts.reduce((m, value) => (value === null || value === undefined ? m : Math.max(m, Number(value) || 0)), top), 0, ) const yOf = (count) => (seriesMax > 0 ? ((Number(count) || 0) / seriesMax) * 100 : 0) return { multiSeries: true, seriesMax, series: declared.map((series) => ({ key: series.key, label: series.label, tone: series.tone || '', // 维护者 2026-10-30 第 2 条:图例悬停的读数按序列语义给。 // 流量(新增/处置)合计有意义;存量(未处置)只有「最新一天的余额」有意义 —— // 原先一律求和,于是图例显示 60 而线上只有 1,正是「数据和图例提示对不上」。 summary: series.summary === 'last' ? 'last' : 'sum', // 每条线自己的点列(x 与标签共用主轴,y 按联合最大值归一);缺数据的桶直接跳过。 points: series.counts .map((count, index) => ({ count, index })) .filter(({ count }) => count !== null && count !== undefined) .map(({ count, index }) => ({ key: rows[index] ? rows[index].key : String(index), label: rows[index] ? rows[index].label : '', hint: rows[index] ? rows[index].hint || '' : '', count: Number(count) || 0, x: xPct(index), y: 100 - yOf(count), xPct: xPct(index), yPct: yOf(count), })), // 图例悬停读数:流量算合计、存量算末日(维护者 2026-10-30 第 2 条)。 ...seriesSummary(series), })), // 多序列时 Y 轴刻度按联合最大值给(单序列的 yTicks 仍按自己的 max)。 yTicks: (seriesMax > 0 ? [0, 0.5, 1] : [0]).map((f) => ({ value: seriesMax * f, pct: f * 100 })), } } /** * 百分比堆叠条块(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, options = {}) { 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 // 维护者第 16 轮:堆叠条要「对不同颜色做出更明显的区分」——调用方声明 `distinct` 时 // 不再走语义色(排水绿/供水蓝/燃气红之外的其余行会落进同一族蓝),改用 // **八个互不相近的色相**按行序分配(渲染层 `seg:N` 查同一张色表)。 const tone = options.distinct === true ? `seg:${(i % 8) + 1}` : 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: options.distinct === true ? false : (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) } /** * 从**领域对象的 `missing` 账本**收字段缺失(票 100:不做全树深扫)。 * * `collectMissing()` 会递归遍历整棵载荷 —— 那是给"单个指标/单条记录"用的。 * 但运行信息这类端点一次回 6771 条指标,每条指标都是适配期已经记过账的领域对象 * (`toOperatingMetric()` 里 `reader.missing`),再深扫一遍实测要 ≈ 100 ms, * 而且扫的仍然是同一份 `missing` 数组。**领域对象已经把缺失收在 `missing` 字段上了**, * 所以这里只读那一层账本,不钻进业务数组。 * * 语义不变:去重、保序;`missing` 只在有断言价值时才进清单(空数组=没有缺失)。 * * @param {Array} carriers 带 `missing: string[]` 的领域对象(如分区数据 + 各行业块) * @returns {string[]} 去重保序的字段路径 */ export function collectDeclaredMissing(carriers) { const found = [] for (const carrier of Array.isArray(carriers) ? carriers : []) { if (!carrier || typeof carrier !== 'object') continue const list = Array.isArray(carrier.missing) ? carrier.missing : [] for (const item of list) { if (typeof item === 'string' && item && !found.includes(item)) found.push(item) } } return found } /** 声明式缺失 → 界面那一行(与 `panelMissingNote()` 同形状:只报条数,路径进 tooltip)。 */ export function panelMissingFromSections(carriers) { const fields = collectDeclaredMissing(carriers) return { count: fields.length, fields, text: fields.length ? `后端未提供 ${fields.length} 项字段` : '', tooltip: fields.join('、'), } } /** 字段缺失 → 界面那一行(只报条数,路径进 tooltip;票 11 的立场)。 */ export function panelMissingNote(data) { return missingNote(data) } /** 行业码 → 展示名(认不出的回空串;本文件不另造行业词表)。 */ export function industryText(code) { return industryLabel(code) || '' } /** * 一条多序列线的**图例读数**(维护者 2026-10-30 第 2 条)。 * * 流量序列(新增/处置)报合计;存量序列(未处置)报**最后一天**的余额。 * 之前一律求和:未处置线上只有 1-2,图例却显示 60 —— 那正是「数据和图例提示对不上」。 * 这里只算数与词,措辞交给渲染层拼(domain 不碰 DOM,渲染层不重算语义)。 */ function seriesSummary(series) { const values = series.counts.filter((value) => value !== null && value !== undefined).map((value) => Number(value) || 0) if (!values.length) return { summaryValue: 0, summaryLabel: '' } if (series.summary === 'last') { return { summaryValue: values[values.length - 1], summaryLabel: '末日' } } return { summaryValue: values.reduce((sum, value) => sum + value, 0), summaryLabel: '合计' } }