deviceLayer.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. /**
  2. * 关联监测设备图层的领域判定(票 42;规格 14 / 决策票 05)。
  3. *
  4. * 本模块只做**判定**,不取数、不碰 Cesium、不碰 Vue —— 这样「哪台设备该上图、哪台为什么不上图、
  5. * 总数怎么算」都能脱浏览器断言(规格的 Testing Decisions 明确要求纯逻辑覆盖这几件事)。
  6. *
  7. * 三条最容易写错、也最容易骗人的地方,都在这里收口:
  8. * 1. **总数只认当前快照去重后的 `deviceCodes`**,不拿详情统计或卡片数冒充
  9. * (详情可能复用缓存、也不与地图快照同版;决策票 05 明确要求以快照为准);
  10. * 2. **快照独有 ≠ 详情独有**:快照独有 = 设备详情待确认;详情独有 = 与当前快照不一致、
  11. * **禁止上图**。两边都不静默丢弃,如实并列;
  12. * 3. **坐标准入只认设备自身 `coordinateValidation.renderable`**(票 07 的唯一判据),
  13. * 绝不复制监测点坐标、不人工偏移 —— 那是 CONTEXT.md 明令禁止的。
  14. *
  15. * 与下游票的边界(不在本票实现):
  16. * · 设备状态的**颜色/形状/文字**词表归票 44(`deviceStatus.js`),本模块只透传后端状态字符串;
  17. * · 完整四模式图层矩阵与 `legend/locateTargets` 计划归票 46;
  18. * · 状态特效归票 47。本模块只回答「本模式该不该显示它」这一层可见性。
  19. */
  20. import { isRenderable } from './coordinate.js'
  21. // 票 46:模式可见性判据唯一住在 `modeLayerPlan.js`(规格 18:唯一裁决点)。
  22. import { isAssociatedDeviceVisible } from './modeLayerPlan.js'
  23. /** 一级模式(与 `domain/panelCatalog.js` 的 MODES 同名;这里只用得上这四个)。 */
  24. export const LAYER_MODE = {
  25. OVERVIEW: 'overview',
  26. INFRASTRUCTURE: 'infrastructure',
  27. MONITORING: 'monitoring',
  28. WARNING_RESPONSE: 'warningResponse',
  29. }
  30. /**
  31. * 关联设备这条身份的**类型键**(拾取与实体 id 都用它)。
  32. *
  33. * 与摘要的 `monitoring-point` / `unassociated-equipment` **并列而不相同**:
  34. * 设备不冒充监测点(票 05 的结构性保证),所以拾到设备时必须能被认出来是设备。
  35. */
  36. export const DEVICE_LAYER_KIND = 'monitoring-device'
  37. /**
  38. * 设备自身状态里**需要处置**的两态(预警处置模式只看这两态)。
  39. * 注意:这是**设备**状态,不是监测点聚合状态(`pointStatus.js` 那张表不能用在这里)。
  40. */
  41. export const DEVICE_ATTENTION_STATUSES = ['ALARM', 'WARNING']
  42. /**
  43. * 一台关联设备的**互斥归类**(规格:各类原因互斥且能解释已确认的关联总数)。
  44. *
  45. * 优先级有意为之 —— 一台设备只能落一类,顺序就是判定的先后:
  46. * 1. `pending`:设备详情尚未返回(快照清单里有、详情里没有)→ 待确认,不算上图也不算无位置;
  47. * 2. `inconsistent`:详情独有(不在当前快照清单里)→ 与当前快照不一致,**禁止上图**;
  48. * 3. `hidden`:本模式不显示它(基础设施整体隐藏;预警处置只显示报警/预警);
  49. * 4. `no-location`:后端明确判不可上图(`renderable=false`)→ 保留清单并给原因;
  50. * 5. `unavailable`:坐标准入通过但经纬度缺失/非有限 → 数据不可用,不上图;
  51. * 6. `drawn`:可上图(**唯一**进入图层的一类)。
  52. */
  53. export const DEVICE_PLACEMENT = {
  54. DRAWN: 'drawn',
  55. NO_LOCATION: 'no-location',
  56. HIDDEN: 'hidden',
  57. PENDING: 'pending',
  58. INCONSISTENT: 'inconsistent',
  59. UNAVAILABLE: 'unavailable',
  60. /**
  61. * 票 44:**坐标有效但地形未就绪**(或地形采样失败)。
  62. *
  63. * 规格 16 明写这一条必须与"坐标无效"**分开**:它是"等地图资源",
  64. * 而不是"数据有问题";也不能被算作已上图。所以单独立一类。
  65. */
  66. TERRAIN_PENDING: 'terrain-pending',
  67. /**
  68. * 票 14:**未命中当前筛选**(决策 02 的 Q8/Q14/Q15)。
  69. *
  70. * 只在**设备级筛选生效**(行业/范围/类型)且当前快照的 `deviceCodes` 已是命中子集时出现:
  71. * 详情独有(不在命中清单里)的设备不再是「与当前快照不一致」(那是无筛选时的语义),
  72. * 而是"被筛选挡住"的未命中设备——保留清单、展开后可交互(进详情)、仅视觉弱化、不上图
  73. * (规格原句「已上图 Y=命中且上图的子集」:未命中者不在上图集合里)。
  74. */
  75. UNMATCHED: 'unmatched',
  76. }
  77. /** 归类 → 人话(界面与图例都读它,不另写一套措辞)。 */
  78. export const DEVICE_PLACEMENT_TEXT = {
  79. [DEVICE_PLACEMENT.DRAWN]: '已上图',
  80. [DEVICE_PLACEMENT.NO_LOCATION]: '无地图位置',
  81. [DEVICE_PLACEMENT.HIDDEN]: '当前模式隐藏',
  82. [DEVICE_PLACEMENT.PENDING]: '设备详情待确认',
  83. [DEVICE_PLACEMENT.INCONSISTENT]: '与当前快照不一致',
  84. [DEVICE_PLACEMENT.UNAVAILABLE]: '位置数据不可用',
  85. [DEVICE_PLACEMENT.TERRAIN_PENDING]: '地形未就绪 · 位置待确认',
  86. [DEVICE_PLACEMENT.UNMATCHED]: '未命中当前筛选',
  87. }
  88. /** 去重且保序的编码清单(后端 `deviceCodes` 源顺序去重未排序,前端不重排)。 */
  89. export function uniqueCodes(list) {
  90. const seen = new Set()
  91. const out = []
  92. for (const item of Array.isArray(list) ? list : []) {
  93. const code = typeof item === 'string' ? item.trim() : ''
  94. if (!code || seen.has(code)) continue
  95. seen.add(code)
  96. out.push(code)
  97. }
  98. return out
  99. }
  100. /**
  101. * 该设备在当前模式下是否属于「本模式要显示的范围」。
  102. *
  103. * 票 46 起,这个判据**只住在** `domain/modeLayerPlan.js`(规格 18:唯一裁决点,
  104. * 不做第二次过滤);本函数保留为**别名转发**,让 t42 既有断言入口不变。
  105. * 基础设施整体隐藏;预警处置只显示设备自身报警/预警;
  106. * 总体概览与运行监测显示全部(**状态未知也显示** —— 规格明确不许凭状态隐藏)。
  107. * @param {string} mode 一级模式
  108. * @param {string} status 设备自身状态(后端原字符串)
  109. */
  110. export function isVisibleInMode(mode, status) {
  111. return isAssociatedDeviceVisible(mode, status)
  112. }
  113. /**
  114. * 一台设备 → 归类(纯函数,规格要求的「集合分类」)。
  115. *
  116. * ── 顺序即优先级,且**数据可用性先于模式可见性** ──
  117. * 这条不是随手排的:若先判模式,一台「坐标被后端否决」的报警设备在预警处置里会显示成
  118. * 「当前模式隐藏」,值班人员会以为"切到总览就能看到它"—— 而它其实**任何模式都上不了图**。
  119. * 说成模式隐藏是把数据问题推给了模式,差额就解释错了。所以:
  120. * 1. `inconsistent`:详情独有 ⇒ 不属于当前快照,谈不上模式;
  121. * 2. `pending`:详情未到 ⇒ 还不知道状态与坐标,不能先判模式;
  122. * 3. `no-location` / `unavailable` / `terrain-pending`:**当前上不了图**(与模式无关);
  123. * 4. `hidden`:数据可用、但本模式不显示它;
  124. * 5. `drawn`:可上图(唯一进入图层的一类)。
  125. *
  126. * @param {object} params
  127. * @param {object} params.card 详情卡片(可为 null = 详情还没返回)
  128. * @param {boolean} params.inSnapshot 该编码是否在**当前快照**的 `deviceCodes` 里
  129. * @param {boolean} params.detailOwned 该编码是否只在详情里(快照清单没有它)
  130. * @param {string} params.mode 一级模式
  131. * @param {boolean} [params.terrainReady] 地形是否已就绪(票 44;缺省 true = 不因地形降级)
  132. * @param {boolean} [params.filterActive] 设备级筛选是否生效(票 14;见 `UNMATCHED` 的说明)
  133. */
  134. export function classifyDevice({ card, inSnapshot, detailOwned, mode, terrainReady = true, filterActive = false }) {
  135. // 票 14:设备级筛选生效时,详情独有 = 被筛选挡住(未命中),不是快照不一致——
  136. // 命中子集契约下 `deviceCodes` 本来就是裁剪过的,两份数据并不冲突。
  137. if (detailOwned) return filterActive ? DEVICE_PLACEMENT.UNMATCHED : DEVICE_PLACEMENT.INCONSISTENT
  138. if (!card) return DEVICE_PLACEMENT.PENDING
  139. // 坐标准入只认后端结论(票 07 的 `isRenderable`)——本模块不重算经纬度范围。
  140. if (!isRenderable(card)) return DEVICE_PLACEMENT.NO_LOCATION
  141. if (!Number.isFinite(card.longitude) || !Number.isFinite(card.latitude)) return DEVICE_PLACEMENT.UNAVAILABLE
  142. // 票 44:**坐标有效但地形未就绪** → 位置待确认。
  143. // 位置在「数据可用性」之后、「模式可见性」之前:它同样与模式无关(任何模式都上不了图),
  144. // 但必须与"坐标无效"分开 —— 那是"等地图资源",不是"数据有问题"。
  145. if (terrainReady !== true) return DEVICE_PLACEMENT.TERRAIN_PENDING
  146. // 到这里数据是好的;模式只管"要不要显示"。
  147. if (!isVisibleInMode(mode, card.status)) return DEVICE_PLACEMENT.HIDDEN
  148. return DEVICE_PLACEMENT.DRAWN
  149. }
  150. /**
  151. * 当前快照该点位的关联设备清单 → 领域行(**保留全部**,含不上图的)。
  152. *
  153. * 快照清单是总数的唯一依据;详情只提供**状态与坐标**这类富信息。两边的差异如实并列:
  154. * · 快照独有(详情还没回来)→ `pending`,编码占位;
  155. * · 详情独有(快照清单没有)→ `inconsistent`,保留卡片但禁止上图。
  156. *
  157. * @param {object} params
  158. * @param {Array<string>} params.snapshotCodes 当前快照该点位的 `deviceCodes`(未去重也可)
  159. * @param {Array} params.cards 详情 `devices[]`(领域卡片,可为空数组)
  160. * @param {string} params.mode 一级模式
  161. * @param {string} params.pointCode 所属监测点编码
  162. * @param {boolean} [params.terrainReady] 地形是否就绪(票 44;缺省 true)
  163. * @param {boolean} [params.filterActive] 设备级筛选生效且快照清单是命中子集(票 14;
  164. * 由调用方在「快照对象确实已加载」时才置 true——快照还没翻到该点位时无从判断命中,
  165. * 详情独有仍按「与当前快照不一致」如实呈现)
  166. * @returns {{rows: Array, counts: object, total: number, hasSnapshot: boolean}}
  167. */
  168. export function planAssociatedDevices({ snapshotCodes, cards, mode, pointCode, terrainReady = true, filterActive = false, pointIndustry = '' }) {
  169. const codes = uniqueCodes(snapshotCodes)
  170. const cardList = Array.isArray(cards) ? cards : []
  171. const byCode = new Map()
  172. for (const card of cardList) {
  173. const code = card && card.code ? card.code : ''
  174. if (code && !byCode.has(code)) byCode.set(code, card)
  175. }
  176. const rows = []
  177. const seen = new Set()
  178. for (const code of codes) {
  179. seen.add(code)
  180. const card = byCode.get(code) || null
  181. const placement = classifyDevice({ card, inSnapshot: true, detailOwned: false, mode, terrainReady })
  182. rows.push({
  183. code,
  184. pointCode,
  185. name: card ? card.name : '',
  186. typeName: card ? card.typeName : '',
  187. status: card ? card.status : '',
  188. statusLabel: card ? card.statusLabel : '',
  189. statusReasons: card ? card.statusReasons : [],
  190. // 坐标只来自**设备自身**(card 的字段);详情没回来时如实为空,绝不借用点位坐标。
  191. longitude: card ? card.longitude : null,
  192. latitude: card ? card.latitude : null,
  193. coordinate: card ? card.coordinate : null,
  194. // 批次 3:内层行业图标选型依据——设备卡契约(实测)无 industry,**继承所属点位**的。
  195. industry: card && card.industry ? card.industry : pointIndustry,
  196. placement,
  197. placementText: DEVICE_PLACEMENT_TEXT[placement],
  198. // 不可上图的设备要给出**具体**原因,界面照实说(不写"未知原因"糊过去)。
  199. reason: card ? (card.coordinate && card.coordinate.reason) || '' : 'DEVICE_DETAIL_PENDING',
  200. inSnapshot: true,
  201. })
  202. }
  203. // 详情独有:不在当前快照清单里。两义分取(票 14):
  204. // · 设备级筛选生效(快照清单=命中子集)→ **未命中当前筛选**:保留、可交互、弱化、不上图;
  205. // · 未筛选 → **与当前快照不一致**:保留但禁止上图(规格 14 的既有语义,两份数据真冲突)。
  206. let unmatchedCount = 0
  207. for (const [code, card] of byCode) {
  208. if (seen.has(code)) continue
  209. const placement = filterActive ? DEVICE_PLACEMENT.UNMATCHED : DEVICE_PLACEMENT.INCONSISTENT
  210. if (filterActive) unmatchedCount += 1
  211. rows.push({
  212. code,
  213. pointCode,
  214. name: card.name,
  215. typeName: card.typeName,
  216. status: card.status,
  217. statusLabel: card.statusLabel,
  218. statusReasons: card.statusReasons,
  219. longitude: card.longitude,
  220. latitude: card.latitude,
  221. coordinate: card.coordinate,
  222. // 批次 3:同上,详情卡无 industry 时继承点位。
  223. industry: card.industry || pointIndustry,
  224. placement,
  225. placementText: DEVICE_PLACEMENT_TEXT[placement],
  226. reason: filterActive ? 'NOT_HIT_BY_CURRENT_FILTER' : 'NOT_IN_CURRENT_SNAPSHOT',
  227. inSnapshot: false,
  228. })
  229. }
  230. return {
  231. rows,
  232. counts: countPlacements(rows),
  233. // 总数(规格 07 的口径):**该点位全部关联设备**。
  234. // · 未筛选:当前快照去重 `deviceCodes` 条数(规格 14 的既有口径);
  235. // · 筛选生效:命中子集 + 未命中(详情独有)——裁剪过的清单不等于全部关联设备。
  236. total: codes.length + (filterActive ? unmatchedCount : 0),
  237. hasSnapshot: Array.isArray(snapshotCodes),
  238. }
  239. }
  240. /** 互斥计数:每台设备恰好落一类,各类之和 == 行数(规格要求可解释总数)。 */
  241. export function countPlacements(rows) {
  242. const counts = {
  243. [DEVICE_PLACEMENT.DRAWN]: 0,
  244. [DEVICE_PLACEMENT.NO_LOCATION]: 0,
  245. [DEVICE_PLACEMENT.HIDDEN]: 0,
  246. [DEVICE_PLACEMENT.PENDING]: 0,
  247. [DEVICE_PLACEMENT.INCONSISTENT]: 0,
  248. [DEVICE_PLACEMENT.UNAVAILABLE]: 0,
  249. [DEVICE_PLACEMENT.TERRAIN_PENDING]: 0,
  250. [DEVICE_PLACEMENT.UNMATCHED]: 0,
  251. }
  252. for (const row of Array.isArray(rows) ? rows : []) {
  253. if (counts[row.placement] === undefined) continue
  254. counts[row.placement] += 1
  255. }
  256. return counts
  257. }
  258. /**
  259. * 关联设备图例(**独立于监测点图例**,规格与票 09 的边界都要求分开)。
  260. *
  261. * 行按互斥归类给,每行带计数;`drawn` 行就是「当前模式实际已上图」的数。
  262. * 与摘要图例的关系:摘要图例只统计摘要标记,本图例只统计关联设备,两者不互相计入。
  263. */
  264. export function associatedLegend(counts) {
  265. const current = counts || {}
  266. return [
  267. { key: DEVICE_PLACEMENT.DRAWN, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.DRAWN], count: current[DEVICE_PLACEMENT.DRAWN] || 0 },
  268. { key: DEVICE_PLACEMENT.NO_LOCATION, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.NO_LOCATION], count: current[DEVICE_PLACEMENT.NO_LOCATION] || 0 },
  269. { key: DEVICE_PLACEMENT.HIDDEN, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.HIDDEN], count: current[DEVICE_PLACEMENT.HIDDEN] || 0 },
  270. { key: DEVICE_PLACEMENT.PENDING, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.PENDING], count: current[DEVICE_PLACEMENT.PENDING] || 0 },
  271. { key: DEVICE_PLACEMENT.INCONSISTENT, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.INCONSISTENT], count: current[DEVICE_PLACEMENT.INCONSISTENT] || 0 },
  272. { key: DEVICE_PLACEMENT.UNAVAILABLE, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.UNAVAILABLE], count: current[DEVICE_PLACEMENT.UNAVAILABLE] || 0 },
  273. // 票 44:地形未就绪单列一行 —— 它既不是"坐标无效"也不是"已上图"(规格明写要分开)。
  274. { key: DEVICE_PLACEMENT.TERRAIN_PENDING, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.TERRAIN_PENDING], count: current[DEVICE_PLACEMENT.TERRAIN_PENDING] || 0 },
  275. // 票 14:未命中当前筛选单列一行(「另有 N 台」的 N 就读它)。
  276. { key: DEVICE_PLACEMENT.UNMATCHED, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.UNMATCHED], count: current[DEVICE_PLACEMENT.UNMATCHED] || 0 },
  277. ]
  278. }
  279. /**
  280. * 跨端点同步风险提示(规格强制行为,不得静默删除)。
  281. *
  282. * 详情请求不携地图快照的 `snapshotAt`,也可能复用缓存 —— 所以两边的设备集合**可能不同版**。
  283. * 后端将来若给出可核对的同版契约,应在规格评审时重新核对本条。
  284. * @param {Array} rows `planAssociatedDevices()` 的行
  285. * @returns {{risky: boolean, text: string}}
  286. */
  287. export function crossEndpointNotice(rows) {
  288. const list = Array.isArray(rows) ? rows : []
  289. const inconsistent = list.filter((row) => row.placement === DEVICE_PLACEMENT.INCONSISTENT).length
  290. const pending = list.filter((row) => row.placement === DEVICE_PLACEMENT.PENDING).length
  291. if (!inconsistent && !pending) return { risky: false, text: '' }
  292. const parts = []
  293. if (inconsistent) parts.push(`${inconsistent} 台与当前地图快照不一致(不上图)`)
  294. if (pending) parts.push(`${pending} 台设备详情待确认`)
  295. return { risky: true, text: `设备位置可能与当前地图快照不同版:${parts.join(';')}` }
  296. }