mapSummary.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. /**
  2. * 地图摘要领域对象(票 05;消费方是票 08 分页与票 07 坐标校验)。
  3. *
  4. * 后端原始形状(`CockpitMapSnapshotResponse` 的一页,见 `docs/cockpit/map-snapshot-demo.html`):
  5. * `{ page, pageSize, snapshotAt, batchUpdatedAt, total, complete,
  6. * monitoringPoints: [{ pointCode, pointName, pointStatus, statusReasons[], longitude, latitude, coordinateValidation }],
  7. * unassociatedEquipment: [{ deviceCode, deviceName, longitude, latitude, coordinateValidation }] }`
  8. *
  9. * 本文件**只做改名与取值**:坐标是否上图由后端 `coordinateValidation` 决定(票 07 不重算),
  10. * 点位状态是后端解析后的结果(CONTEXT.md「点位状态」:前端只渲染,不重算)。
  11. * 两种地图对象合成同一个 `kind` 词表,地图与列表因此不必各写一套分支。
  12. */
  13. import { fieldReader, toLocation } from './fields.js'
  14. import { splitByCoordinate, toCoordinateSummary } from './coordinate.js'
  15. /** 地图对象的两种来源(CONTEXT.md:监测点优先作为可点击对象,未关联设备是设备)。 */
  16. export const MAP_OBJECT_KIND = {
  17. MONITORING_POINT: 'monitoring-point',
  18. UNASSOCIATED_EQUIPMENT: 'unassociated-equipment',
  19. }
  20. /**
  21. * 监测点摘要 → 地图对象。
  22. * @param {object} raw 后端 `CockpitMonitoringPointSummary`
  23. */
  24. export function toMonitoringPoint(raw) {
  25. const reader = fieldReader(raw, 'monitoringPoint')
  26. // 坐标按契约是必给的:缺失要记一笔(票 07 据此区分「缺失」与「非法」),值仍保持 null。
  27. const longitude = reader.num('longitude')
  28. const latitude = reader.num('latitude')
  29. return {
  30. kind: MAP_OBJECT_KIND.MONITORING_POINT,
  31. code: reader.str('pointCode'),
  32. name: reader.str('pointName'),
  33. // 后端解析并发布的点位状态:前端不重算(CONTEXT.md)。
  34. status: reader.optionalStr('pointStatus'),
  35. statusReasons: reader.optionalList('statusReasons').map((item) => String(item)),
  36. longitude,
  37. latitude,
  38. coordinate: toLocation(raw && raw.coordinateValidation),
  39. missing: reader.missing,
  40. }
  41. }
  42. /**
  43. * 未关联设备摘要 → 地图对象。
  44. * @param {object} raw 后端 `CockpitUnassociatedEquipmentSummary`
  45. */
  46. export function toUnassociatedEquipment(raw) {
  47. const reader = fieldReader(raw, 'unassociatedEquipment')
  48. const longitude = reader.num('longitude')
  49. const latitude = reader.num('latitude')
  50. return {
  51. kind: MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT,
  52. code: reader.str('deviceCode'),
  53. name: reader.str('deviceName'),
  54. // 未关联设备没有点位状态:留空而不是借用设备的在线态(0/NORMAL 都算伪造)。
  55. status: '',
  56. statusReasons: [],
  57. longitude,
  58. latitude,
  59. coordinate: toLocation(raw && raw.coordinateValidation),
  60. missing: reader.missing,
  61. }
  62. }
  63. /**
  64. * 一页地图摘要 → 领域批次。快照标记原样带出,供票 08 保证「同一次加载用同一快照」。
  65. * @param {object} raw 后端一页
  66. */
  67. export function toMapBatch(raw) {
  68. const reader = fieldReader(raw)
  69. const points = reader.list('monitoringPoints').map(toMonitoringPoint)
  70. const equipment = reader.list('unassociatedEquipment').map(toUnassociatedEquipment)
  71. return {
  72. snapshotAt: reader.optionalStr('snapshotAt'),
  73. batchUpdatedAt: reader.optionalStr('batchUpdatedAt'),
  74. page: reader.num('page'),
  75. pageSize: reader.num('pageSize'),
  76. total: reader.num('total'),
  77. complete: reader.bool('complete', false),
  78. points,
  79. equipment,
  80. // 票 07:后端每页都带**全量**坐标校验汇总(`CockpitCoordinateValidationSummary`,
  81. // `CockpitMapSnapshotService` 用整份快照算完再切片),如实带出供界面报「全城多少点位没上图」。
  82. coordinateSummary: toCoordinateSummary(raw && raw.coordinateValidation),
  83. // 分页窗口横跨两类对象(后端是一条总序列),所以批次对外也给一条合起来的序列:
  84. // 装载器与地图不必各自记得「先监测点后未关联设备」。
  85. objects: [...points, ...equipment],
  86. missing: reader.missing,
  87. }
  88. }
  89. /** 批次里的全部地图对象,监测点在前(与后端数组顺序一致)。 */
  90. export function batchObjects(batch) {
  91. const current = batch || {}
  92. return [...(current.points || []), ...(current.equipment || [])]
  93. }
  94. /**
  95. * 坐标校验汇总(票 07 的计数依据):只数,不判——判定权在后端。
  96. * @param {Array} objects 地图对象数组
  97. */
  98. export function summarizeCoordinateValidation(objects) {
  99. const summary = { total: 0, renderable: 0, blocked: 0, states: {} }
  100. for (const item of objects || []) {
  101. summary.total += 1
  102. const validation = (item && item.coordinate) || {}
  103. const state = validation.state || 'UNVERIFIED'
  104. summary.states[state] = (summary.states[state] || 0) + 1
  105. if (validation.renderable) summary.renderable += 1
  106. else summary.blocked += 1
  107. }
  108. return summary
  109. }
  110. // ================================================================ 分页与快照(票 08)
  111. /**
  112. * 地图摘要渐进加载的六个演示态(票 08 验收第 5 条:加载中/完成/失败/快照过期都要能演示)。
  113. *
  114. * 注意 `EMPTY` 与 `COMPLETE` 的区别:后端说「总数 0」时是**确实没有对象**(显示「没有可上图对象」),
  115. * 而 `COMPLETE` 是**确实加载完了**(哪怕只加载到 1 个对象)。二者都不能伪装成对方。
  116. */
  117. export const MAP_SUMMARY_PHASE = {
  118. IDLE: 'idle',
  119. LOADING: 'loading',
  120. COMPLETE: 'complete',
  121. EMPTY: 'empty',
  122. FAILED: 'failed',
  123. EXPIRED: 'expired',
  124. }
  125. /** 「不用再翻了」的两态:界面据此禁用「加载下一页」。 */
  126. export const MAP_SUMMARY_SETTLED_PHASES = [MAP_SUMMARY_PHASE.COMPLETE, MAP_SUMMARY_PHASE.EMPTY]
  127. /**
  128. * 长跑护栏:地图摘要是全量点位的分页序列,理论上可以无限翻。
  129. * 合并态只保留最近这么多个批次与对象——它服务于「一张图当前要画什么」,
  130. * 不是台账(全量列表归票 10/18 的列表端点)。
  131. */
  132. export const MAP_SUMMARY_LIMITS = {
  133. maxBatches: 200,
  134. maxObjects: 2000,
  135. }
  136. /** 合并态的初值:还没取过任何一页。 */
  137. export function emptyMapSummary(extra = {}) {
  138. return {
  139. phase: MAP_SUMMARY_PHASE.IDLE,
  140. snapshot: '',
  141. batchUpdatedAt: '',
  142. // `page` = 已成功合并的最后一页;`nextPage` = 下一次要请求的页。
  143. // 分开是为了让「失败后重试同一页」既真实(page 不前进)又能显示正在取第几页。
  144. page: 0,
  145. nextPage: 1,
  146. pageSize: 0,
  147. total: 0,
  148. complete: false,
  149. loaded: 0,
  150. batches: [],
  151. objects: [],
  152. missing: [],
  153. reason: '',
  154. // 票 07:后端**全量**坐标校验汇总(每页都回同一份口径,取最新那次响应即可,不做累加)。
  155. coordinateSummary: toCoordinateSummary(null),
  156. ...extra,
  157. }
  158. }
  159. /** 地图对象的去重键:`kind` + 编码(两张表各自的编码空间是独立的)。 */
  160. function objectKey(item) {
  161. return `${(item && item.kind) || ''}|${(item && item.code) || ''}`
  162. }
  163. /**
  164. * 把一页摘要并进当前合并态(纯函数,票 08 的核心逻辑)。
  165. *
  166. * 三条语义来自后端实现(`CockpitMapSnapshotService`)而不是猜测:
  167. * 1. **一页是一个快照内的切片**:分页窗口横跨「监测点 + 未关联设备」这一条总序列,
  168. * 所以同一页里两类对象可能同时出现,跨页拼接不需要按类分别翻页;
  169. * 2. **快照标记取自第一页的响应**,之后的每一页都带着它请求(一致性由装载器保证);
  170. * 3. 合并态**从空态重来**即为「换快照」:`prev = null` 时旧快照的对象不会残留
  171. * (票 08 验收第 3 条「同一次加载保持快照一致」的另一半:刷新就是换一份数据)。
  172. *
  173. * @param {object|null} prev 上一次的合并态(`null` = 新一次加载)
  174. * @param {object} page 一页领域批次:`{ snapshotAt, batchUpdatedAt, total, complete, objects, missing }`
  175. */
  176. export function mergeMapPage(prev, page = {}) {
  177. const base = prev && prev.objects ? prev : emptyMapSummary()
  178. const incoming = Array.isArray(page.objects) ? page.objects : []
  179. // 去重按 kind+code:后端重放同一页、或第 2 页与第 1 页有重叠切片时,
  180. // 同一个对象不得在地图上画两遍。没有编码的脏对象不参与去重(宁可多画一个,也不吞掉)。
  181. const seen = new Set(base.objects.map(objectKey))
  182. const fresh = incoming.filter((item) => {
  183. const key = objectKey(item)
  184. if (key.endsWith('|')) return true
  185. if (seen.has(key)) return false
  186. seen.add(key)
  187. return true
  188. })
  189. const objects = [...base.objects, ...fresh]
  190. const batches = [...base.batches, { page: page.page, loaded: fresh.length, total: page.total }]
  191. let reason = base.reason
  192. if (!page.complete) reason = ''
  193. const loadedPage = Number.isFinite(page.page) ? page.page : base.page + 1
  194. return {
  195. ...base,
  196. // 快照标记以「本次加载的第一次响应」为准;后续页带着同一个标记请求,正常不会变。
  197. snapshot: base.snapshot || page.snapshotAt || '',
  198. batchUpdatedAt: page.batchUpdatedAt || base.batchUpdatedAt,
  199. page: loadedPage,
  200. // 下一页总是「刚合并的那一页 + 1」:后端的分页是切片序列,没有别的算法。
  201. nextPage: loadedPage + 1,
  202. pageSize: Number.isFinite(page.pageSize) ? page.pageSize : base.pageSize,
  203. total: Number.isFinite(page.total) ? page.total : base.total,
  204. complete: page.complete === true,
  205. loaded: objects.length,
  206. batches: batches.slice(-MAP_SUMMARY_LIMITS.maxBatches),
  207. objects: objects.slice(-MAP_SUMMARY_LIMITS.maxObjects),
  208. missing: [...base.missing, ...((page && page.missing) || [])],
  209. // 票 07:后端这份汇总是**全量**口径(不是本页切片),每页都回同一个数,
  210. // 所以取最新那次响应即可 —— 累加会把它翻成「页数 × 全量」的荒唐值。
  211. coordinateSummary: page.coordinateSummary && page.coordinateSummary.available
  212. ? page.coordinateSummary
  213. : base.coordinateSummary,
  214. reason,
  215. }
  216. }
  217. /**
  218. * 合并态 → 给人读的演示文案(票 08 验收第 5 条)。
  219. *
  220. * 和 `domain/sectionState.js` 的 `sectionView` 同一立场:**内部原因码不上界面**,
  221. * 认不出的原因宁可只说「加载失败」,也不把 `snapshot-expired` 这类接口词汇甩给值班人员。
  222. *
  223. * 票 07 修正了一处**会骗人的口径**:`loaded` 是「已加载对象数」(含不上图的对象),
  224. * 而这里的措辞原本是「已上图 N 个」——票 07 起有对象因坐标被拦下,这两个数就不再相等,
  225. * 照旧写法会把 5 个说成上图的 5 个(实际只有 3 个画得出来)。
  226. * 所以:调用方给了 `objects` 时按坐标准入分开报,没给(如纯文案单测)时保持原措辞。
  227. * @param {object} state 装载器的 `getState()`
  228. */
  229. export function summaryView(state) {
  230. const current = state || emptyMapSummary()
  231. const phase = current.phase || MAP_SUMMARY_PHASE.IDLE
  232. const pages = current.batches ? current.batches.length : 0
  233. const onMap = onMapCount(current)
  234. if (phase === MAP_SUMMARY_PHASE.LOADING) {
  235. const attempt = current.attemptPage || current.nextPage || 1
  236. return { phase, tone: 'lv2', label: `地图摘要加载中 · 第 ${attempt} 页 · ${onMap.label}`, detail: '' }
  237. }
  238. if (phase === MAP_SUMMARY_PHASE.COMPLETE) {
  239. return { phase, tone: 'lv4', label: `地图摘要加载完成 · ${onMap.label}`, detail: '' }
  240. }
  241. if (phase === MAP_SUMMARY_PHASE.EMPTY) {
  242. return { phase, tone: 'neutral', label: '地图摘要无数据 · 当前范围没有可上图对象', detail: '' }
  243. }
  244. if (phase === MAP_SUMMARY_PHASE.FAILED) {
  245. return { phase, tone: 'lv1', label: `地图摘要加载失败 · ${onMap.label}`, detail: '刷新或稍后重试' }
  246. }
  247. if (phase === MAP_SUMMARY_PHASE.EXPIRED) {
  248. // 「同一次加载保持快照一致」的反面:后端换了快照,已加载的点属于旧快照。
  249. return { phase, tone: 'lv1', label: '地图摘要快照已失效 · 请刷新', detail: '后端数据已更新,重新加载会切换到新快照' }
  250. }
  251. return {
  252. phase,
  253. tone: 'accent',
  254. label: pages ? `地图摘要就绪 · 已加载 ${pages} 页` : '地图框架已就绪 · 等待分页数据',
  255. detail: '',
  256. }
  257. }
  258. /**
  259. * 「已上图」的计数口径(票 07):只认坐标准入通过的对象。
  260. *
  261. * 调用方没给 `objects` 时退回票 08 的原口径(`loaded`)——那是纯文案单测的用法,
  262. * 没有对象可判;给了就必须分开报,否则「已上图」这个词就是假的。
  263. * @param {object} state 装载器状态
  264. */
  265. function onMapCount(state) {
  266. if (!Array.isArray(state.objects) || !state.objects.length) {
  267. return { label: `已上图 ${state.loaded} 个`, renderable: state.loaded, blocked: 0 }
  268. }
  269. const { renderable, blocked } = splitByCoordinate(state.objects)
  270. const label = blocked.length
  271. ? `已上图 ${renderable.length} 个 · 无地图位置 ${blocked.length} 个`
  272. : `已上图 ${renderable.length} 个`
  273. return { label, renderable: renderable.length, blocked: blocked.length }
  274. }
  275. /**
  276. * 快照失效判据(唯一入口):本次加载锁定的快照与后端这一页回的快照不一致时必须停下。
  277. * 拿它去判「后端换了数据」,也就不会再出现「两个快照的点位拼在同一张地图上」。
  278. * @param {string} locked 本次加载锁定的快照标记(第 1 页响应给的)
  279. * @param {string} incoming 本页响应回的快照标记
  280. */
  281. export function isSnapshotChanged(locked, incoming) {
  282. if (!locked || !incoming) return false
  283. return locked !== incoming
  284. }
  285. /**
  286. * 快照失效时的状态收敛(纯函数):不再发请求,也不再合并,已加载的对象保持原样
  287. * (界面措辞是「请刷新」,而不是「已加载的点没了」)。
  288. * @param {object} state 当前合并态
  289. */
  290. export function expireMapSummary(state) {
  291. const current = state || emptyMapSummary()
  292. return { ...current, phase: MAP_SUMMARY_PHASE.EXPIRED, reason: 'snapshot-expired' }
  293. }
  294. /**
  295. * 地图摘要分页装载器(票 08):渐进加载的状态机,一次只往前翻一页。
  296. *
  297. * 为什么是「装载器 + 注入取数函数」而不是直接在服务里翻页:
  298. * · 快照一致性是**状态**(第 1 页锁定、后续页带着它请求),必须有地方持有;
  299. * · 纯函数 + 注入让脱浏览器的逻辑脚本能完整演示六态(见 verify/t08-map-summary.mjs),
  300. * 浏览器侧只是把同一个装载器的状态画出来(`stores/mapStore.js` 持有实例)。
  301. *
  302. * 硬约束(票 08 验收第 2、3 条):
  303. * · 分页与快照参数**由本层拼**(调用方只说「下一页」),组件与页面不出现 `page`/`snapshotAt`;
  304. * · 第 2 页起必须带第 1 页锁定的 `snapshotAt`(后端 `validatePagination` 的硬要求);
  305. * · 后端回的快照与锁定值不一致时立刻进 `expired`,不再往下翻。
  306. *
  307. * @param {object} options
  308. * @param {(query: {page: number, pageSize: number, snapshotAt?: string}) => Promise<object>} options.fetchPage
  309. * 取一页;返回 `{ ok, data: { snapshotAt, total, complete, objects, missing }, reason }`
  310. * @param {number} [options.pageSize] 每页条数(后端默认 500、上限 1000)
  311. */
  312. export function createMapSummaryLoader(options = {}) {
  313. const fetchPage = options.fetchPage
  314. const pageSize = Number.isFinite(options.pageSize) ? options.pageSize : 500
  315. let state = emptyMapSummary({ pageSize })
  316. let token = 0
  317. function readState() {
  318. return {
  319. ...state,
  320. batches: state.batches.map((item) => ({ ...item })),
  321. objects: [...state.objects],
  322. missing: [...state.missing],
  323. }
  324. }
  325. /** 翻下一页。完成态下是空操作;失败/失效后可以再次调用(失败会重试同一页)。 */
  326. async function loadNext() {
  327. if (!fetchPage) return readState()
  328. if (state.phase === MAP_SUMMARY_PHASE.COMPLETE || state.phase === MAP_SUMMARY_PHASE.EMPTY) return readState()
  329. const wanted = state.nextPage
  330. // 同一批加载里的后续页必须带第 1 页锁定的快照;第一页不带(后端据此开新快照)。
  331. const query = { page: wanted, pageSize }
  332. if (state.snapshot) query.snapshotAt = state.snapshot
  333. // 并发点两次「加载下一页」不得重复翻页:只认最后一次(token 大的那次)。
  334. token += 1
  335. const mine = token
  336. state = { ...state, phase: MAP_SUMMARY_PHASE.LOADING, attemptPage: wanted, reason: '' }
  337. // 取数函数自己抛错(网络层意外)也要收敛成失败态:装载器绝不能卡在 loading。
  338. let result = null
  339. try {
  340. result = await fetchPage(query)
  341. } catch (error) {
  342. result = { ok: false, reason: 'unreachable', error: String((error && error.message) || error) }
  343. }
  344. if (mine !== token) return readState()
  345. if (!result || result.ok !== true) {
  346. const reason = (result && result.reason) || 'unavailable'
  347. if (reason === 'snapshot-expired') {
  348. state = expireMapSummary(state)
  349. return readState()
  350. }
  351. // 失败保持 page/nextPage 不动 = 下次点重试同一页,不静默丢一页数据。
  352. state = { ...state, phase: MAP_SUMMARY_PHASE.FAILED, reason }
  353. return readState()
  354. }
  355. const data = result.data || {}
  356. if (isSnapshotChanged(state.snapshot, data.snapshotAt)) {
  357. state = expireMapSummary(state)
  358. return readState()
  359. }
  360. state = mergeMapPage(state, { ...data, page: wanted, pageSize: data.pageSize || pageSize })
  361. if (state.complete) {
  362. state = { ...state, phase: state.loaded === 0 ? MAP_SUMMARY_PHASE.EMPTY : MAP_SUMMARY_PHASE.COMPLETE }
  363. } else {
  364. state = { ...state, phase: MAP_SUMMARY_PHASE.IDLE }
  365. }
  366. // 合并成功后 attemptPage 与 page 一致;留着它只为读状态时少一层判断。
  367. return readState()
  368. }
  369. /**
  370. * 刷新:丢掉当前快照,从第 1 页重新加载(下一次请求不带 `snapshotAt`,后端据此给新快照)。
  371. * 旧快照的对象不再保留——留着就是两个快照的数据混在一张图上。
  372. */
  373. async function refresh() {
  374. state = emptyMapSummary({ pageSize })
  375. return loadNext()
  376. }
  377. return { loadNext, refresh, getState: readState }
  378. }