/** * 关联监测设备图层的领域判定(票 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} 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(';')}` } }