| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397 |
- /**
- * 面板块的**共享积木**(票 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<object>} 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: '合计' }
- }
|