/** * 地图摘要领域对象(票 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} 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 } }