| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419 |
- /**
- * 地图摘要领域对象(票 05;消费方是票 08 分页与票 07 坐标校验)。
- *
- * 后端原始形状(`CockpitMapSnapshotResponse` 的一页,见 `docs/cockpit/map-snapshot-demo.html`):
- * `{ page, pageSize, snapshotAt, batchUpdatedAt, total, complete,
- * monitoringPoints: [{ pointCode, pointName, pointStatus, statusReasons[], longitude, latitude, coordinateValidation }],
- * unassociatedEquipment: [{ deviceCode, deviceName, longitude, latitude, coordinateValidation }] }`
- *
- * 本文件**只做改名与取值**:坐标是否上图由后端 `coordinateValidation` 决定(票 07 不重算),
- * 点位状态是后端解析后的结果(CONTEXT.md「点位状态」:前端只渲染,不重算)。
- * 两种地图对象合成同一个 `kind` 词表,地图与列表因此不必各写一套分支。
- */
- import { fieldReader, toLocation } from './fields.js'
- import { splitByCoordinate, toCoordinateSummary } from './coordinate.js'
- /** 地图对象的两种来源(CONTEXT.md:监测点优先作为可点击对象,未关联设备是设备)。 */
- export const MAP_OBJECT_KIND = {
- MONITORING_POINT: 'monitoring-point',
- UNASSOCIATED_EQUIPMENT: 'unassociated-equipment',
- }
- /**
- * 监测点摘要 → 地图对象。
- * @param {object} raw 后端 `CockpitMonitoringPointSummary`
- */
- export function toMonitoringPoint(raw) {
- const reader = fieldReader(raw, 'monitoringPoint')
- // 坐标按契约是必给的:缺失要记一笔(票 07 据此区分「缺失」与「非法」),值仍保持 null。
- const longitude = reader.num('longitude')
- const latitude = reader.num('latitude')
- return {
- kind: MAP_OBJECT_KIND.MONITORING_POINT,
- code: reader.str('pointCode'),
- name: reader.str('pointName'),
- // 后端解析并发布的点位状态:前端不重算(CONTEXT.md)。
- status: reader.optionalStr('pointStatus'),
- statusReasons: reader.optionalList('statusReasons').map((item) => String(item)),
- longitude,
- latitude,
- coordinate: toLocation(raw && raw.coordinateValidation),
- missing: reader.missing,
- }
- }
- /**
- * 未关联设备摘要 → 地图对象。
- * @param {object} raw 后端 `CockpitUnassociatedEquipmentSummary`
- */
- export function toUnassociatedEquipment(raw) {
- const reader = fieldReader(raw, 'unassociatedEquipment')
- const longitude = reader.num('longitude')
- const latitude = reader.num('latitude')
- return {
- kind: MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT,
- code: reader.str('deviceCode'),
- name: reader.str('deviceName'),
- // 未关联设备没有点位状态:留空而不是借用设备的在线态(0/NORMAL 都算伪造)。
- status: '',
- statusReasons: [],
- longitude,
- latitude,
- coordinate: toLocation(raw && raw.coordinateValidation),
- missing: reader.missing,
- }
- }
- /**
- * 一页地图摘要 → 领域批次。快照标记原样带出,供票 08 保证「同一次加载用同一快照」。
- * @param {object} raw 后端一页
- */
- export function toMapBatch(raw) {
- const reader = fieldReader(raw)
- const points = reader.list('monitoringPoints').map(toMonitoringPoint)
- const equipment = reader.list('unassociatedEquipment').map(toUnassociatedEquipment)
- return {
- snapshotAt: reader.optionalStr('snapshotAt'),
- batchUpdatedAt: reader.optionalStr('batchUpdatedAt'),
- page: reader.num('page'),
- pageSize: reader.num('pageSize'),
- total: reader.num('total'),
- complete: reader.bool('complete', false),
- points,
- equipment,
- // 票 07:后端每页都带**全量**坐标校验汇总(`CockpitCoordinateValidationSummary`,
- // `CockpitMapSnapshotService` 用整份快照算完再切片),如实带出供界面报「全城多少点位没上图」。
- coordinateSummary: toCoordinateSummary(raw && raw.coordinateValidation),
- // 分页窗口横跨两类对象(后端是一条总序列),所以批次对外也给一条合起来的序列:
- // 装载器与地图不必各自记得「先监测点后未关联设备」。
- objects: [...points, ...equipment],
- missing: reader.missing,
- }
- }
- /** 批次里的全部地图对象,监测点在前(与后端数组顺序一致)。 */
- export function batchObjects(batch) {
- const current = batch || {}
- return [...(current.points || []), ...(current.equipment || [])]
- }
- /**
- * 坐标校验汇总(票 07 的计数依据):只数,不判——判定权在后端。
- * @param {Array} objects 地图对象数组
- */
- export function summarizeCoordinateValidation(objects) {
- const summary = { total: 0, renderable: 0, blocked: 0, states: {} }
- for (const item of objects || []) {
- summary.total += 1
- const validation = (item && item.coordinate) || {}
- const state = validation.state || 'UNVERIFIED'
- summary.states[state] = (summary.states[state] || 0) + 1
- if (validation.renderable) summary.renderable += 1
- else summary.blocked += 1
- }
- return summary
- }
- // ================================================================ 分页与快照(票 08)
- /**
- * 地图摘要渐进加载的六个演示态(票 08 验收第 5 条:加载中/完成/失败/快照过期都要能演示)。
- *
- * 注意 `EMPTY` 与 `COMPLETE` 的区别:后端说「总数 0」时是**确实没有对象**(显示「没有可上图对象」),
- * 而 `COMPLETE` 是**确实加载完了**(哪怕只加载到 1 个对象)。二者都不能伪装成对方。
- */
- export const MAP_SUMMARY_PHASE = {
- IDLE: 'idle',
- LOADING: 'loading',
- COMPLETE: 'complete',
- EMPTY: 'empty',
- FAILED: 'failed',
- EXPIRED: 'expired',
- }
- /** 「不用再翻了」的两态:界面据此禁用「加载下一页」。 */
- export const MAP_SUMMARY_SETTLED_PHASES = [MAP_SUMMARY_PHASE.COMPLETE, MAP_SUMMARY_PHASE.EMPTY]
- /**
- * 长跑护栏:地图摘要是全量点位的分页序列,理论上可以无限翻。
- * 合并态只保留最近这么多个批次与对象——它服务于「一张图当前要画什么」,
- * 不是台账(全量列表归票 10/18 的列表端点)。
- */
- export const MAP_SUMMARY_LIMITS = {
- maxBatches: 200,
- maxObjects: 2000,
- }
- /** 合并态的初值:还没取过任何一页。 */
- export function emptyMapSummary(extra = {}) {
- return {
- phase: MAP_SUMMARY_PHASE.IDLE,
- snapshot: '',
- batchUpdatedAt: '',
- // `page` = 已成功合并的最后一页;`nextPage` = 下一次要请求的页。
- // 分开是为了让「失败后重试同一页」既真实(page 不前进)又能显示正在取第几页。
- page: 0,
- nextPage: 1,
- pageSize: 0,
- total: 0,
- complete: false,
- loaded: 0,
- batches: [],
- objects: [],
- missing: [],
- reason: '',
- // 票 07:后端**全量**坐标校验汇总(每页都回同一份口径,取最新那次响应即可,不做累加)。
- coordinateSummary: toCoordinateSummary(null),
- ...extra,
- }
- }
- /** 地图对象的去重键:`kind` + 编码(两张表各自的编码空间是独立的)。 */
- function objectKey(item) {
- return `${(item && item.kind) || ''}|${(item && item.code) || ''}`
- }
- /**
- * 把一页摘要并进当前合并态(纯函数,票 08 的核心逻辑)。
- *
- * 三条语义来自后端实现(`CockpitMapSnapshotService`)而不是猜测:
- * 1. **一页是一个快照内的切片**:分页窗口横跨「监测点 + 未关联设备」这一条总序列,
- * 所以同一页里两类对象可能同时出现,跨页拼接不需要按类分别翻页;
- * 2. **快照标记取自第一页的响应**,之后的每一页都带着它请求(一致性由装载器保证);
- * 3. 合并态**从空态重来**即为「换快照」:`prev = null` 时旧快照的对象不会残留
- * (票 08 验收第 3 条「同一次加载保持快照一致」的另一半:刷新就是换一份数据)。
- *
- * @param {object|null} prev 上一次的合并态(`null` = 新一次加载)
- * @param {object} page 一页领域批次:`{ snapshotAt, batchUpdatedAt, total, complete, objects, missing }`
- */
- export function mergeMapPage(prev, page = {}) {
- const base = prev && prev.objects ? prev : emptyMapSummary()
- const incoming = Array.isArray(page.objects) ? page.objects : []
- // 去重按 kind+code:后端重放同一页、或第 2 页与第 1 页有重叠切片时,
- // 同一个对象不得在地图上画两遍。没有编码的脏对象不参与去重(宁可多画一个,也不吞掉)。
- const seen = new Set(base.objects.map(objectKey))
- const fresh = incoming.filter((item) => {
- const key = objectKey(item)
- if (key.endsWith('|')) return true
- if (seen.has(key)) return false
- seen.add(key)
- return true
- })
- const objects = [...base.objects, ...fresh]
- const batches = [...base.batches, { page: page.page, loaded: fresh.length, total: page.total }]
- let reason = base.reason
- if (!page.complete) reason = ''
- const loadedPage = Number.isFinite(page.page) ? page.page : base.page + 1
- return {
- ...base,
- // 快照标记以「本次加载的第一次响应」为准;后续页带着同一个标记请求,正常不会变。
- snapshot: base.snapshot || page.snapshotAt || '',
- batchUpdatedAt: page.batchUpdatedAt || base.batchUpdatedAt,
- page: loadedPage,
- // 下一页总是「刚合并的那一页 + 1」:后端的分页是切片序列,没有别的算法。
- nextPage: loadedPage + 1,
- pageSize: Number.isFinite(page.pageSize) ? page.pageSize : base.pageSize,
- total: Number.isFinite(page.total) ? page.total : base.total,
- complete: page.complete === true,
- loaded: objects.length,
- batches: batches.slice(-MAP_SUMMARY_LIMITS.maxBatches),
- objects: objects.slice(-MAP_SUMMARY_LIMITS.maxObjects),
- missing: [...base.missing, ...((page && page.missing) || [])],
- // 票 07:后端这份汇总是**全量**口径(不是本页切片),每页都回同一个数,
- // 所以取最新那次响应即可 —— 累加会把它翻成「页数 × 全量」的荒唐值。
- coordinateSummary: page.coordinateSummary && page.coordinateSummary.available
- ? page.coordinateSummary
- : base.coordinateSummary,
- reason,
- }
- }
- /**
- * 合并态 → 给人读的演示文案(票 08 验收第 5 条)。
- *
- * 和 `domain/sectionState.js` 的 `sectionView` 同一立场:**内部原因码不上界面**,
- * 认不出的原因宁可只说「加载失败」,也不把 `snapshot-expired` 这类接口词汇甩给值班人员。
- *
- * 票 07 修正了一处**会骗人的口径**:`loaded` 是「已加载对象数」(含不上图的对象),
- * 而这里的措辞原本是「已上图 N 个」——票 07 起有对象因坐标被拦下,这两个数就不再相等,
- * 照旧写法会把 5 个说成上图的 5 个(实际只有 3 个画得出来)。
- * 所以:调用方给了 `objects` 时按坐标准入分开报,没给(如纯文案单测)时保持原措辞。
- * @param {object} state 装载器的 `getState()`
- */
- export function summaryView(state) {
- const current = state || emptyMapSummary()
- const phase = current.phase || MAP_SUMMARY_PHASE.IDLE
- const pages = current.batches ? current.batches.length : 0
- const onMap = onMapCount(current)
- if (phase === MAP_SUMMARY_PHASE.LOADING) {
- const attempt = current.attemptPage || current.nextPage || 1
- return { phase, tone: 'lv2', label: `地图摘要加载中 · 第 ${attempt} 页 · ${onMap.label}`, detail: '' }
- }
- if (phase === MAP_SUMMARY_PHASE.COMPLETE) {
- return { phase, tone: 'lv4', label: `地图摘要加载完成 · ${onMap.label}`, detail: '' }
- }
- if (phase === MAP_SUMMARY_PHASE.EMPTY) {
- return { phase, tone: 'neutral', label: '地图摘要无数据 · 当前范围没有可上图对象', detail: '' }
- }
- if (phase === MAP_SUMMARY_PHASE.FAILED) {
- return { phase, tone: 'lv1', label: `地图摘要加载失败 · ${onMap.label}`, detail: '刷新或稍后重试' }
- }
- if (phase === MAP_SUMMARY_PHASE.EXPIRED) {
- // 「同一次加载保持快照一致」的反面:后端换了快照,已加载的点属于旧快照。
- return { phase, tone: 'lv1', label: '地图摘要快照已失效 · 请刷新', detail: '后端数据已更新,重新加载会切换到新快照' }
- }
- return {
- phase,
- tone: 'accent',
- label: pages ? `地图摘要就绪 · 已加载 ${pages} 页` : '地图框架已就绪 · 等待分页数据',
- detail: '',
- }
- }
- /**
- * 「已上图」的计数口径(票 07):只认坐标准入通过的对象。
- *
- * 调用方没给 `objects` 时退回票 08 的原口径(`loaded`)——那是纯文案单测的用法,
- * 没有对象可判;给了就必须分开报,否则「已上图」这个词就是假的。
- * @param {object} state 装载器状态
- */
- function onMapCount(state) {
- if (!Array.isArray(state.objects) || !state.objects.length) {
- return { label: `已上图 ${state.loaded} 个`, renderable: state.loaded, blocked: 0 }
- }
- const { renderable, blocked } = splitByCoordinate(state.objects)
- const label = blocked.length
- ? `已上图 ${renderable.length} 个 · 无地图位置 ${blocked.length} 个`
- : `已上图 ${renderable.length} 个`
- return { label, renderable: renderable.length, blocked: blocked.length }
- }
- /**
- * 快照失效判据(唯一入口):本次加载锁定的快照与后端这一页回的快照不一致时必须停下。
- * 拿它去判「后端换了数据」,也就不会再出现「两个快照的点位拼在同一张地图上」。
- * @param {string} locked 本次加载锁定的快照标记(第 1 页响应给的)
- * @param {string} incoming 本页响应回的快照标记
- */
- export function isSnapshotChanged(locked, incoming) {
- if (!locked || !incoming) return false
- return locked !== incoming
- }
- /**
- * 快照失效时的状态收敛(纯函数):不再发请求,也不再合并,已加载的对象保持原样
- * (界面措辞是「请刷新」,而不是「已加载的点没了」)。
- * @param {object} state 当前合并态
- */
- export function expireMapSummary(state) {
- const current = state || emptyMapSummary()
- return { ...current, phase: MAP_SUMMARY_PHASE.EXPIRED, reason: 'snapshot-expired' }
- }
- /**
- * 地图摘要分页装载器(票 08):渐进加载的状态机,一次只往前翻一页。
- *
- * 为什么是「装载器 + 注入取数函数」而不是直接在服务里翻页:
- * · 快照一致性是**状态**(第 1 页锁定、后续页带着它请求),必须有地方持有;
- * · 纯函数 + 注入让脱浏览器的逻辑脚本能完整演示六态(见 verify/t08-map-summary.mjs),
- * 浏览器侧只是把同一个装载器的状态画出来(`stores/mapStore.js` 持有实例)。
- *
- * 硬约束(票 08 验收第 2、3 条):
- * · 分页与快照参数**由本层拼**(调用方只说「下一页」),组件与页面不出现 `page`/`snapshotAt`;
- * · 第 2 页起必须带第 1 页锁定的 `snapshotAt`(后端 `validatePagination` 的硬要求);
- * · 后端回的快照与锁定值不一致时立刻进 `expired`,不再往下翻。
- *
- * @param {object} options
- * @param {(query: {page: number, pageSize: number, snapshotAt?: string}) => Promise<object>} options.fetchPage
- * 取一页;返回 `{ ok, data: { snapshotAt, total, complete, objects, missing }, reason }`
- * @param {number} [options.pageSize] 每页条数(后端默认 500、上限 1000)
- */
- export function createMapSummaryLoader(options = {}) {
- const fetchPage = options.fetchPage
- const pageSize = Number.isFinite(options.pageSize) ? options.pageSize : 500
- let state = emptyMapSummary({ pageSize })
- let token = 0
- function readState() {
- return {
- ...state,
- batches: state.batches.map((item) => ({ ...item })),
- objects: [...state.objects],
- missing: [...state.missing],
- }
- }
- /** 翻下一页。完成态下是空操作;失败/失效后可以再次调用(失败会重试同一页)。 */
- async function loadNext() {
- if (!fetchPage) return readState()
- if (state.phase === MAP_SUMMARY_PHASE.COMPLETE || state.phase === MAP_SUMMARY_PHASE.EMPTY) return readState()
- const wanted = state.nextPage
- // 同一批加载里的后续页必须带第 1 页锁定的快照;第一页不带(后端据此开新快照)。
- const query = { page: wanted, pageSize }
- if (state.snapshot) query.snapshotAt = state.snapshot
- // 并发点两次「加载下一页」不得重复翻页:只认最后一次(token 大的那次)。
- token += 1
- const mine = token
- state = { ...state, phase: MAP_SUMMARY_PHASE.LOADING, attemptPage: wanted, reason: '' }
- // 取数函数自己抛错(网络层意外)也要收敛成失败态:装载器绝不能卡在 loading。
- let result = null
- try {
- result = await fetchPage(query)
- } catch (error) {
- result = { ok: false, reason: 'unreachable', error: String((error && error.message) || error) }
- }
- if (mine !== token) return readState()
- if (!result || result.ok !== true) {
- const reason = (result && result.reason) || 'unavailable'
- if (reason === 'snapshot-expired') {
- state = expireMapSummary(state)
- return readState()
- }
- // 失败保持 page/nextPage 不动 = 下次点重试同一页,不静默丢一页数据。
- state = { ...state, phase: MAP_SUMMARY_PHASE.FAILED, reason }
- return readState()
- }
- const data = result.data || {}
- if (isSnapshotChanged(state.snapshot, data.snapshotAt)) {
- state = expireMapSummary(state)
- return readState()
- }
- state = mergeMapPage(state, { ...data, page: wanted, pageSize: data.pageSize || pageSize })
- if (state.complete) {
- state = { ...state, phase: state.loaded === 0 ? MAP_SUMMARY_PHASE.EMPTY : MAP_SUMMARY_PHASE.COMPLETE }
- } else {
- state = { ...state, phase: MAP_SUMMARY_PHASE.IDLE }
- }
- // 合并成功后 attemptPage 与 page 一致;留着它只为读状态时少一层判断。
- return readState()
- }
- /**
- * 刷新:丢掉当前快照,从第 1 页重新加载(下一次请求不带 `snapshotAt`,后端据此给新快照)。
- * 旧快照的对象不再保留——留着就是两个快照的数据混在一张图上。
- */
- async function refresh() {
- state = emptyMapSummary({ pageSize })
- return loadNext()
- }
- return { loadNext, refresh, getState: readState }
- }
|