| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- /**
- * 关联监测设备图层的领域判定(票 42;规格 14 / 决策票 05)。
- *
- * 本模块只做**判定**,不取数、不碰 Cesium、不碰 Vue —— 这样「哪台设备该上图、哪台为什么不上图、
- * 总数怎么算」都能脱浏览器断言(规格的 Testing Decisions 明确要求纯逻辑覆盖这几件事)。
- *
- * 三条最容易写错、也最容易骗人的地方,都在这里收口:
- * 1. **总数只认当前快照去重后的 `deviceCodes`**,不拿详情统计或卡片数冒充
- * (详情可能复用缓存、也不与地图快照同版;决策票 05 明确要求以快照为准);
- * 2. **快照独有 ≠ 详情独有**:快照独有 = 设备详情待确认;详情独有 = 与当前快照不一致、
- * **禁止上图**。两边都不静默丢弃,如实并列;
- * 3. **坐标准入只认设备自身 `coordinateValidation.renderable`**(票 07 的唯一判据),
- * 绝不复制监测点坐标、不人工偏移 —— 那是 CONTEXT.md 明令禁止的。
- *
- * 与下游票的边界(不在本票实现):
- * · 设备状态的**颜色/形状/文字**词表归票 44(`deviceStatus.js`),本模块只透传后端状态字符串;
- * · 完整四模式图层矩阵与 `legend/locateTargets` 计划归票 46;
- * · 状态特效归票 47。本模块只回答「本模式该不该显示它」这一层可见性。
- */
- import { isRenderable } from './coordinate.js'
- // 票 46:模式可见性判据唯一住在 `modeLayerPlan.js`(规格 18:唯一裁决点)。
- import { isAssociatedDeviceVisible } from './modeLayerPlan.js'
- /** 一级模式(与 `domain/panelCatalog.js` 的 MODES 同名;这里只用得上这四个)。 */
- export const LAYER_MODE = {
- OVERVIEW: 'overview',
- INFRASTRUCTURE: 'infrastructure',
- MONITORING: 'monitoring',
- WARNING_RESPONSE: 'warningResponse',
- }
- /**
- * 关联设备这条身份的**类型键**(拾取与实体 id 都用它)。
- *
- * 与摘要的 `monitoring-point` / `unassociated-equipment` **并列而不相同**:
- * 设备不冒充监测点(票 05 的结构性保证),所以拾到设备时必须能被认出来是设备。
- */
- export const DEVICE_LAYER_KIND = 'monitoring-device'
- /**
- * 设备自身状态里**需要处置**的两态(预警处置模式只看这两态)。
- * 注意:这是**设备**状态,不是监测点聚合状态(`pointStatus.js` 那张表不能用在这里)。
- */
- export const DEVICE_ATTENTION_STATUSES = ['ALARM', 'WARNING']
- /**
- * 一台关联设备的**互斥归类**(规格:各类原因互斥且能解释已确认的关联总数)。
- *
- * 优先级有意为之 —— 一台设备只能落一类,顺序就是判定的先后:
- * 1. `pending`:设备详情尚未返回(快照清单里有、详情里没有)→ 待确认,不算上图也不算无位置;
- * 2. `inconsistent`:详情独有(不在当前快照清单里)→ 与当前快照不一致,**禁止上图**;
- * 3. `hidden`:本模式不显示它(基础设施整体隐藏;预警处置只显示报警/预警);
- * 4. `no-location`:后端明确判不可上图(`renderable=false`)→ 保留清单并给原因;
- * 5. `unavailable`:坐标准入通过但经纬度缺失/非有限 → 数据不可用,不上图;
- * 6. `drawn`:可上图(**唯一**进入图层的一类)。
- */
- export const DEVICE_PLACEMENT = {
- DRAWN: 'drawn',
- NO_LOCATION: 'no-location',
- HIDDEN: 'hidden',
- PENDING: 'pending',
- INCONSISTENT: 'inconsistent',
- UNAVAILABLE: 'unavailable',
- /**
- * 票 44:**坐标有效但地形未就绪**(或地形采样失败)。
- *
- * 规格 16 明写这一条必须与"坐标无效"**分开**:它是"等地图资源",
- * 而不是"数据有问题";也不能被算作已上图。所以单独立一类。
- */
- TERRAIN_PENDING: 'terrain-pending',
- /**
- * 票 14:**未命中当前筛选**(决策 02 的 Q8/Q14/Q15)。
- *
- * 只在**设备级筛选生效**(行业/范围/类型)且当前快照的 `deviceCodes` 已是命中子集时出现:
- * 详情独有(不在命中清单里)的设备不再是「与当前快照不一致」(那是无筛选时的语义),
- * 而是"被筛选挡住"的未命中设备——保留清单、展开后可交互(进详情)、仅视觉弱化、不上图
- * (规格原句「已上图 Y=命中且上图的子集」:未命中者不在上图集合里)。
- */
- UNMATCHED: 'unmatched',
- }
- /** 归类 → 人话(界面与图例都读它,不另写一套措辞)。 */
- export const DEVICE_PLACEMENT_TEXT = {
- [DEVICE_PLACEMENT.DRAWN]: '已上图',
- [DEVICE_PLACEMENT.NO_LOCATION]: '无地图位置',
- [DEVICE_PLACEMENT.HIDDEN]: '当前模式隐藏',
- [DEVICE_PLACEMENT.PENDING]: '设备详情待确认',
- [DEVICE_PLACEMENT.INCONSISTENT]: '与当前快照不一致',
- [DEVICE_PLACEMENT.UNAVAILABLE]: '位置数据不可用',
- [DEVICE_PLACEMENT.TERRAIN_PENDING]: '地形未就绪 · 位置待确认',
- [DEVICE_PLACEMENT.UNMATCHED]: '未命中当前筛选',
- }
- /** 去重且保序的编码清单(后端 `deviceCodes` 源顺序去重未排序,前端不重排)。 */
- export function uniqueCodes(list) {
- const seen = new Set()
- const out = []
- for (const item of Array.isArray(list) ? list : []) {
- const code = typeof item === 'string' ? item.trim() : ''
- if (!code || seen.has(code)) continue
- seen.add(code)
- out.push(code)
- }
- return out
- }
- /**
- * 该设备在当前模式下是否属于「本模式要显示的范围」。
- *
- * 票 46 起,这个判据**只住在** `domain/modeLayerPlan.js`(规格 18:唯一裁决点,
- * 不做第二次过滤);本函数保留为**别名转发**,让 t42 既有断言入口不变。
- * 基础设施整体隐藏;预警处置只显示设备自身报警/预警;
- * 总体概览与运行监测显示全部(**状态未知也显示** —— 规格明确不许凭状态隐藏)。
- * @param {string} mode 一级模式
- * @param {string} status 设备自身状态(后端原字符串)
- */
- export function isVisibleInMode(mode, status) {
- return isAssociatedDeviceVisible(mode, status)
- }
- /**
- * 一台设备 → 归类(纯函数,规格要求的「集合分类」)。
- *
- * ── 顺序即优先级,且**数据可用性先于模式可见性** ──
- * 这条不是随手排的:若先判模式,一台「坐标被后端否决」的报警设备在预警处置里会显示成
- * 「当前模式隐藏」,值班人员会以为"切到总览就能看到它"—— 而它其实**任何模式都上不了图**。
- * 说成模式隐藏是把数据问题推给了模式,差额就解释错了。所以:
- * 1. `inconsistent`:详情独有 ⇒ 不属于当前快照,谈不上模式;
- * 2. `pending`:详情未到 ⇒ 还不知道状态与坐标,不能先判模式;
- * 3. `no-location` / `unavailable` / `terrain-pending`:**当前上不了图**(与模式无关);
- * 4. `hidden`:数据可用、但本模式不显示它;
- * 5. `drawn`:可上图(唯一进入图层的一类)。
- *
- * @param {object} params
- * @param {object} params.card 详情卡片(可为 null = 详情还没返回)
- * @param {boolean} params.inSnapshot 该编码是否在**当前快照**的 `deviceCodes` 里
- * @param {boolean} params.detailOwned 该编码是否只在详情里(快照清单没有它)
- * @param {string} params.mode 一级模式
- * @param {boolean} [params.terrainReady] 地形是否已就绪(票 44;缺省 true = 不因地形降级)
- * @param {boolean} [params.filterActive] 设备级筛选是否生效(票 14;见 `UNMATCHED` 的说明)
- */
- export function classifyDevice({ card, inSnapshot, detailOwned, mode, terrainReady = true, filterActive = false }) {
- // 票 14:设备级筛选生效时,详情独有 = 被筛选挡住(未命中),不是快照不一致——
- // 命中子集契约下 `deviceCodes` 本来就是裁剪过的,两份数据并不冲突。
- if (detailOwned) return filterActive ? DEVICE_PLACEMENT.UNMATCHED : DEVICE_PLACEMENT.INCONSISTENT
- if (!card) return DEVICE_PLACEMENT.PENDING
- // 坐标准入只认后端结论(票 07 的 `isRenderable`)——本模块不重算经纬度范围。
- if (!isRenderable(card)) return DEVICE_PLACEMENT.NO_LOCATION
- if (!Number.isFinite(card.longitude) || !Number.isFinite(card.latitude)) return DEVICE_PLACEMENT.UNAVAILABLE
- // 票 44:**坐标有效但地形未就绪** → 位置待确认。
- // 位置在「数据可用性」之后、「模式可见性」之前:它同样与模式无关(任何模式都上不了图),
- // 但必须与"坐标无效"分开 —— 那是"等地图资源",不是"数据有问题"。
- if (terrainReady !== true) return DEVICE_PLACEMENT.TERRAIN_PENDING
- // 到这里数据是好的;模式只管"要不要显示"。
- if (!isVisibleInMode(mode, card.status)) return DEVICE_PLACEMENT.HIDDEN
- return DEVICE_PLACEMENT.DRAWN
- }
- /**
- * 当前快照该点位的关联设备清单 → 领域行(**保留全部**,含不上图的)。
- *
- * 快照清单是总数的唯一依据;详情只提供**状态与坐标**这类富信息。两边的差异如实并列:
- * · 快照独有(详情还没回来)→ `pending`,编码占位;
- * · 详情独有(快照清单没有)→ `inconsistent`,保留卡片但禁止上图。
- *
- * @param {object} params
- * @param {Array<string>} params.snapshotCodes 当前快照该点位的 `deviceCodes`(未去重也可)
- * @param {Array} params.cards 详情 `devices[]`(领域卡片,可为空数组)
- * @param {string} params.mode 一级模式
- * @param {string} params.pointCode 所属监测点编码
- * @param {boolean} [params.terrainReady] 地形是否就绪(票 44;缺省 true)
- * @param {boolean} [params.filterActive] 设备级筛选生效且快照清单是命中子集(票 14;
- * 由调用方在「快照对象确实已加载」时才置 true——快照还没翻到该点位时无从判断命中,
- * 详情独有仍按「与当前快照不一致」如实呈现)
- * @returns {{rows: Array, counts: object, total: number, hasSnapshot: boolean}}
- */
- export function planAssociatedDevices({ snapshotCodes, cards, mode, pointCode, terrainReady = true, filterActive = false, pointIndustry = '' }) {
- const codes = uniqueCodes(snapshotCodes)
- const cardList = Array.isArray(cards) ? cards : []
- const byCode = new Map()
- for (const card of cardList) {
- const code = card && card.code ? card.code : ''
- if (code && !byCode.has(code)) byCode.set(code, card)
- }
- const rows = []
- const seen = new Set()
- for (const code of codes) {
- seen.add(code)
- const card = byCode.get(code) || null
- const placement = classifyDevice({ card, inSnapshot: true, detailOwned: false, mode, terrainReady })
- rows.push({
- code,
- pointCode,
- name: card ? card.name : '',
- typeName: card ? card.typeName : '',
- status: card ? card.status : '',
- statusLabel: card ? card.statusLabel : '',
- statusReasons: card ? card.statusReasons : [],
- // 坐标只来自**设备自身**(card 的字段);详情没回来时如实为空,绝不借用点位坐标。
- longitude: card ? card.longitude : null,
- latitude: card ? card.latitude : null,
- coordinate: card ? card.coordinate : null,
- // 批次 3:内层行业图标选型依据——设备卡契约(实测)无 industry,**继承所属点位**的。
- industry: card && card.industry ? card.industry : pointIndustry,
- placement,
- placementText: DEVICE_PLACEMENT_TEXT[placement],
- // 不可上图的设备要给出**具体**原因,界面照实说(不写"未知原因"糊过去)。
- reason: card ? (card.coordinate && card.coordinate.reason) || '' : 'DEVICE_DETAIL_PENDING',
- inSnapshot: true,
- })
- }
- // 详情独有:不在当前快照清单里。两义分取(票 14):
- // · 设备级筛选生效(快照清单=命中子集)→ **未命中当前筛选**:保留、可交互、弱化、不上图;
- // · 未筛选 → **与当前快照不一致**:保留但禁止上图(规格 14 的既有语义,两份数据真冲突)。
- let unmatchedCount = 0
- for (const [code, card] of byCode) {
- if (seen.has(code)) continue
- const placement = filterActive ? DEVICE_PLACEMENT.UNMATCHED : DEVICE_PLACEMENT.INCONSISTENT
- if (filterActive) unmatchedCount += 1
- rows.push({
- code,
- pointCode,
- name: card.name,
- typeName: card.typeName,
- status: card.status,
- statusLabel: card.statusLabel,
- statusReasons: card.statusReasons,
- longitude: card.longitude,
- latitude: card.latitude,
- coordinate: card.coordinate,
- // 批次 3:同上,详情卡无 industry 时继承点位。
- industry: card.industry || pointIndustry,
- placement,
- placementText: DEVICE_PLACEMENT_TEXT[placement],
- reason: filterActive ? 'NOT_HIT_BY_CURRENT_FILTER' : 'NOT_IN_CURRENT_SNAPSHOT',
- inSnapshot: false,
- })
- }
- return {
- rows,
- counts: countPlacements(rows),
- // 总数(规格 07 的口径):**该点位全部关联设备**。
- // · 未筛选:当前快照去重 `deviceCodes` 条数(规格 14 的既有口径);
- // · 筛选生效:命中子集 + 未命中(详情独有)——裁剪过的清单不等于全部关联设备。
- total: codes.length + (filterActive ? unmatchedCount : 0),
- hasSnapshot: Array.isArray(snapshotCodes),
- }
- }
- /** 互斥计数:每台设备恰好落一类,各类之和 == 行数(规格要求可解释总数)。 */
- export function countPlacements(rows) {
- const counts = {
- [DEVICE_PLACEMENT.DRAWN]: 0,
- [DEVICE_PLACEMENT.NO_LOCATION]: 0,
- [DEVICE_PLACEMENT.HIDDEN]: 0,
- [DEVICE_PLACEMENT.PENDING]: 0,
- [DEVICE_PLACEMENT.INCONSISTENT]: 0,
- [DEVICE_PLACEMENT.UNAVAILABLE]: 0,
- [DEVICE_PLACEMENT.TERRAIN_PENDING]: 0,
- [DEVICE_PLACEMENT.UNMATCHED]: 0,
- }
- for (const row of Array.isArray(rows) ? rows : []) {
- if (counts[row.placement] === undefined) continue
- counts[row.placement] += 1
- }
- return counts
- }
- /**
- * 关联设备图例(**独立于监测点图例**,规格与票 09 的边界都要求分开)。
- *
- * 行按互斥归类给,每行带计数;`drawn` 行就是「当前模式实际已上图」的数。
- * 与摘要图例的关系:摘要图例只统计摘要标记,本图例只统计关联设备,两者不互相计入。
- */
- export function associatedLegend(counts) {
- const current = counts || {}
- return [
- { key: DEVICE_PLACEMENT.DRAWN, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.DRAWN], count: current[DEVICE_PLACEMENT.DRAWN] || 0 },
- { key: DEVICE_PLACEMENT.NO_LOCATION, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.NO_LOCATION], count: current[DEVICE_PLACEMENT.NO_LOCATION] || 0 },
- { key: DEVICE_PLACEMENT.HIDDEN, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.HIDDEN], count: current[DEVICE_PLACEMENT.HIDDEN] || 0 },
- { key: DEVICE_PLACEMENT.PENDING, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.PENDING], count: current[DEVICE_PLACEMENT.PENDING] || 0 },
- { key: DEVICE_PLACEMENT.INCONSISTENT, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.INCONSISTENT], count: current[DEVICE_PLACEMENT.INCONSISTENT] || 0 },
- { key: DEVICE_PLACEMENT.UNAVAILABLE, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.UNAVAILABLE], count: current[DEVICE_PLACEMENT.UNAVAILABLE] || 0 },
- // 票 44:地形未就绪单列一行 —— 它既不是"坐标无效"也不是"已上图"(规格明写要分开)。
- { key: DEVICE_PLACEMENT.TERRAIN_PENDING, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.TERRAIN_PENDING], count: current[DEVICE_PLACEMENT.TERRAIN_PENDING] || 0 },
- // 票 14:未命中当前筛选单列一行(「另有 N 台」的 N 就读它)。
- { key: DEVICE_PLACEMENT.UNMATCHED, label: DEVICE_PLACEMENT_TEXT[DEVICE_PLACEMENT.UNMATCHED], count: current[DEVICE_PLACEMENT.UNMATCHED] || 0 },
- ]
- }
- /**
- * 跨端点同步风险提示(规格强制行为,不得静默删除)。
- *
- * 详情请求不携地图快照的 `snapshotAt`,也可能复用缓存 —— 所以两边的设备集合**可能不同版**。
- * 后端将来若给出可核对的同版契约,应在规格评审时重新核对本条。
- * @param {Array} rows `planAssociatedDevices()` 的行
- * @returns {{risky: boolean, text: string}}
- */
- export function crossEndpointNotice(rows) {
- const list = Array.isArray(rows) ? rows : []
- const inconsistent = list.filter((row) => row.placement === DEVICE_PLACEMENT.INCONSISTENT).length
- const pending = list.filter((row) => row.placement === DEVICE_PLACEMENT.PENDING).length
- if (!inconsistent && !pending) return { risky: false, text: '' }
- const parts = []
- if (inconsistent) parts.push(`${inconsistent} 台与当前地图快照不一致(不上图)`)
- if (pending) parts.push(`${pending} 台设备详情待确认`)
- return { risky: true, text: `设备位置可能与当前地图快照不同版:${parts.join(';')}` }
- }
|