/** * 管线与设施点位图层(票 24)。 * * 与监测点**不是同一类对象**,因此不共用 `domain/mapSummary.js` 的口径: * · 监测点是「承载设备的空间业务对象」,管线是**设施**(管网本身),二者不可混算; * · 契约形状也不同:这里是 GeoJSON 风格的 `features[]`,不是分页快照。 * * 两条不可让步的纪律(沿用票 07 / 决策票 07): * 1. **准��只认后端 `coordinateValidation.renderable`**,本文件不重算经纬度范围, * 更不纠偏、不补默认坐标 —— 与 `isRenderable()` 同一立场; * 2. **不得臆造安装拓扑**。管段只有坐标、无正式端节点ID, * 所以角/支管构件只能来自后端给的真实构件属性(燃气 pointType)。 * 经用户确认,完全重合的同业端点可仅用于无分叉链的管径示意插值, * 不据此生成角/支管构件、不把候选连接关系当工程拓扑。 * * 行业覆盖如实呈现:哪个行业有几何、哪个没有,由后端 `coverage` 表达, * 前端只转述,不替后端猜(缺数据就说缺数据,不画空图层冒充"这里没有管线")。 */ import { COORDINATE_REASON_TEXT, NO_MAP_LOCATION_TEXT } from './coordinate.js' import { DISTRICT_CODES, INDUSTRY_CODES, districtQuery, industryQuery } from './filterOptions.js' /** 四个正式行业码(燃气/供水/排水/窨井)——`INDUSTRY_CODES` 的**值**才是后端码。 */ const INDUSTRY_LIST = Object.freeze(Object.values(INDUSTRY_CODES)) const INDUSTRY_SET = new Set(INDUSTRY_LIST) /** 四类程序化构件(维护者 2026-12 裁定)。样条管道是管段本身,不单列构件。 */ export const PIPE_COMPONENT = { ELBOW: 'elbow', VALVE: 'valve', BRANCH: 'branch', OTHER: 'other', } /** * 后端构件类型 → 前端构件类别。 * * 后端 `pointType` **原样透传**(票 30 实测为英文 `valve`;数据底座注释另有中文词表), * 两侧都认。白名单外一律落 `other` 并**保留原文**:界面要能说清"这是个什么构件", * 不能因为我们没建模就把调压/计量/阴极保护一律说成"其它"以外的东西,更不能猜。 */ const POINT_TYPE_COMPONENT = { 阀门: PIPE_COMPONENT.VALVE, 弯头: PIPE_COMPONENT.ELBOW, 三通: PIPE_COMPONENT.BRANCH, valve: PIPE_COMPONENT.VALVE, elbow: PIPE_COMPONENT.ELBOW, tee: PIPE_COMPONENT.BRANCH, cross: PIPE_COMPONENT.BRANCH, branch: PIPE_COMPONENT.BRANCH, } export const COMPONENT_TEXT = { [PIPE_COMPONENT.VALVE]: '阀门', [PIPE_COMPONENT.ELBOW]: '角管道', [PIPE_COMPONENT.BRANCH]: '支管管道', [PIPE_COMPONENT.OTHER]: '其它构件', } /** * 管径(mm) → 管线线宽(px)。 * * 放 domain 而不是引擎层:它是**判定**,引擎只消费结果(与本仓「判定收进 domain」一致), * 这样它能脱 Cesium、脱离浏览器断言。 * 分档而非线性——真实数据的管径跨度从几十到上千毫米,线性映射会把细管压成看不见、 * 把粗管撑成整屏宽。缺管径回最细档(画出来总比不画诚实),但**不猜**一个"典型值"。 */ export function pipeWidth(diameter) { const value = Number(diameter) if (!Number.isFinite(value) || value <= 0) return 2 if (value < 300) return 2 if (value < 600) return 3 if (value < 1000) return 4 return 6 } /** 分类:只认白名单自有键(继承键会漏出函数,与票 22 同一条纪律)。 */ function componentOf(pointType) { const raw = typeof pointType === 'string' ? pointType.trim() : '' if (!raw) return { kind: PIPE_COMPONENT.OTHER, label: '' } if (Object.prototype.hasOwnProperty.call(POINT_TYPE_COMPONENT, raw)) { return { kind: POINT_TYPE_COMPONENT[raw], label: raw } } // 未建模的构件类型(调压/计量/阴极保护/其它):如实显示后端原文,不伪装成已知类别。 return { kind: PIPE_COMPONENT.OTHER, label: raw } } /** * **管线/构件编码的片区前缀**(维护者 2026-10-23 更正)。 * * 设施编码的组成规则是「**片区 + 行业 + 时间编码**」,例:TCRQ202609080001 * = 太常(TC) + 燃气(RQ) + 202609080001;CNWS... = 城南 + 供水。所以片区的第一手来源 * 就在编码前两位,而不是几何 properties 里的独立字段(那里**没有**片区字段)。 * * 取值与 filterOptions.DISTRICT_CODES 的**后端片区编码**同值(south=0 / north=1 / taichang=2)。 * 现网 6766 条几何里只出现过 TC 与 CN(实测:前缀与 name 里的片区词 100% 一致, * 4116/4116 与 2646/2646,零反例);**CB(城北)已由维护者 2026-10-23 确认**,**现网无该前缀数据**, * 现网还没有 CB 前缀的数据,所以选「城北」时管线为空 —— 那是编码里没有,不是没有管线。 * 编码规则若变,改这一张表即可。 */ export const PIPE_DISTRICT_PREFIX = Object.freeze({ CN: DISTRICT_CODES.south, CB: DISTRICT_CODES.north, TC: DISTRICT_CODES.taichang, }) /** * 设施编码 → 片区编码(0/1/2);认不出回 null =**推不出**,不猜。 * * 只认「两位片区字母 + 两位行业字母」这个正式形状(前四位必须都是字母): * 现网另有 GD-001(燃气阀门)、YL-YJ-001(窨井)这类**另一套编码**的构件, * 前两位不是片区码 —— 它们回 null,由筛选层如实计数,不硬塞进某个片区。 */ export function pipeDistrictOf(code) { const raw = typeof code === 'string' ? code.trim().toUpperCase() : '' if (!/^[A-Z]{2}[A-Z]{2}/.test(raw)) return null const prefix = raw.slice(0, 2) return Object.hasOwn(PIPE_DISTRICT_PREFIX, prefix) ? PIPE_DISTRICT_PREFIX[prefix] : null } /** 行业码 → 规范化后端码;认不出的回空串(不拿脏值去筛、不进统计)。 */ function industryOf(code) { const raw = typeof code === 'string' ? code.trim() : '' return INDUSTRY_SET.has(raw) ? raw : '' } /** 后端 `coordinateValidation` → 准入结论 + 不上图原因(原因文案走票 07 词表)。 */ function admission(properties) { const validation = (properties && properties.coordinateValidation) || null if (!validation) return { renderable: false, reasonCode: '', reasonText: '坐标未校验' } if (validation.renderable === true) return { renderable: true, reasonCode: '', reasonText: '' } const code = typeof validation.reason === 'string' ? validation.reason : '' return { renderable: false, reasonCode: code, reasonText: COORDINATE_REASON_TEXT[code] || NO_MAP_LOCATION_TEXT } } function isFiniteNumber(value) { return typeof value === 'number' && Number.isFinite(value) } function diameterOf(value) { if (typeof value !== 'number' && typeof value !== 'string') return null if (typeof value === 'string' && !value.trim()) return null const number = Number(value) return Number.isFinite(number) ? number : null } /** 管段几何:保留完整路径;任一顶点结构非法就整条拦下,不跳点重连。 */ function pipePositions(geometry) { const raw = geometry && geometry.type === 'LineString' && Array.isArray(geometry.coordinates) ? geometry.coordinates : [] if (raw.length < 2) return [] const positions = [] for (const pair of raw) { if (!Array.isArray(pair) || !isFiniteNumber(pair[0]) || !isFiniteNumber(pair[1])) return [] positions.push([pair[0], pair[1]]) } return positions } /** 构件几何:点 `[lon, lat]`。 */ function componentPosition(geometry) { const raw = geometry && geometry.type === 'Point' && Array.isArray(geometry.coordinates) ? geometry.coordinates : null return raw && isFiniteNumber(raw[0]) && isFiniteNumber(raw[1]) ? [raw[0], raw[1]] : [] } /** * 后端几何载荷 → 领域图层。 * * @param {object} payload 真实契约见 [票 30](30-untitled.md)(端点 `GET /api/cockpit/v1/infrastructure/geometry`): * `{ type:'FeatureCollection', generatedAt, features:[Feature], coverage:[...] }`。 * Feature 是标准 GeoJSON:`{ type:'Feature', id, geometry, properties }`; * **没有 `kind` 字段**——管段/构件按 `geometry.type`(LineString/Point)判别; * 非法坐标的 feature `geometry` 为 **null**(原始坐标保留在 `properties.sourceCoordinates`)。 * @returns {{pipes:Array, components:Array, blocked:Array, coverage:object, counts:object}} * `pipes`/`components` 只含**后端已判定可上图**的对象;被拦下的进 `blocked`, * 带中文原因,供界面「无地图位置」清单如实显示。 */ export function toPipeNetwork(payload) { const features = Array.isArray(payload && payload.features) ? payload.features : [] const pipes = [] const components = [] const blocked = [] for (const feature of features) { const properties = (feature && feature.properties) || {} const industry = industryOf(properties.industry) // 管段/构件判别:契约没有 kind 字段,按 geometry 形状判。geometry 为 null(非法坐标) // 时按 properties.typeCode 归类——两端的 source(pipe_network_base / gas_pipe_point / manhole_base) // 各自只有一种形状,typeCode 是后端给的权威标识,不猜。 const geometryType = feature && feature.geometry && feature.geometry.type const isComponent = geometryType === 'Point' || (!geometryType && String(properties.typeCode || '').includes('POINT')) const kind = isComponent ? 'component' : 'pipe' const id = String(properties.code || properties.sourceId || feature.id || '') const verdict = admission(properties) // 票 67 更正(维护者 2026-10-23):片区从**设施编码前缀**推(片区+行业+时间编码), // 几何 properties 里没有片区字段。推不出的回 null,交给筛选层如实计数。 const district = pipeDistrictOf(id) if (!verdict.renderable) { blocked.push({ kind, code: id, industry, district, name: String(properties.name || properties.pointType || id || ''), reasonCode: verdict.reasonCode, reasonText: verdict.reasonText, }) continue } if (kind === 'pipe') { const positions = pipePositions(feature.geometry) if (positions.length < 2) { blocked.push({ kind, code: id, industry, district, name: String(properties.name || id || ''), reasonCode: '', reasonText: NO_MAP_LOCATION_TEXT }) continue } pipes.push({ code: id, district, sourceId: String(properties.sourceId ?? ''), networkId: String(properties.networkId ?? ''), status: typeof properties.status === 'string' ? properties.status : '', statusLabel: typeof properties.statusLabel === 'string' ? properties.statusLabel : '', name: String(properties.name || ''), remark: typeof properties.remark === 'string' ? properties.remark : '', networkLevel: properties.networkLevel ?? properties.network_level ?? 0, industry, material: String(properties.material ?? ''), diameter: diameterOf(properties.diameter), length: properties.length == null || String(properties.length).trim() === '' ? null : isFiniteNumber(Number(properties.length)) ? Number(properties.length) : null, positions, }) continue } const position = componentPosition(feature.geometry) if (!position.length) { blocked.push({ kind, code: id, industry, district, name: String(properties.pointType || id || ''), reasonCode: '', reasonText: NO_MAP_LOCATION_TEXT }) continue } const component = componentOf(properties.pointType) components.push({ code: id, district, sourceId: String(properties.sourceId ?? ''), networkId: String(properties.networkId ?? ''), status: typeof properties.status === 'string' ? properties.status : '', statusLabel: typeof properties.statusLabel === 'string' ? properties.statusLabel : '', industry, kind: component.kind, label: component.label, name: String(properties.name || component.label || ''), position, }) } return { pipes, components, blocked, coverage: coverageOf(payload, { pipes, components }), counts: countByIndustry({ pipes, components }) } } /** 行业覆盖:后端给了就照抄,没给就按实际画出来的数说 —— 两种情况都不替后端圆场。 * * 真实契约(票 30)的 coverage 是**三态行**:`{industry, source, geometryType, state, reason, recordCount, geometryCount, renderableCount}`, * `state` ∈ `AVAILABLE`/`EMPTY`/`UNAVAILABLE`。同一行业可有**两行**(管线一行、构件一行), * 按行归并:任一行 AVAILABLE 即视为有几何;管段/构件计数各取各自行。 */ function coverageOf(payload, drawn) { const declared = Array.isArray(payload && payload.coverage) ? payload.coverage : [] const result = {} for (const row of declared) { const industry = industryOf(row && row.industry) if (!industry) continue const state = row && row.state const isPointRow = (row && row.geometryType) === 'Point' const slot = result[industry] || (result[industry] = { geometryAvailable: false, pipeCount: 0, componentCount: 0, gaps: [] }) if (state === 'AVAILABLE') { slot.geometryAvailable = true if (isPointRow) slot.componentCount = finiteOr(row.renderableCount, row.geometryCount) else slot.pipeCount = finiteOr(row.renderableCount, row.geometryCount) } else if (state === 'UNAVAILABLE' || state === 'EMPTY') { // 缺口如实记录(如供水 NO_COMPONENT_SOURCE、窨井 NO_PIPELINE_SOURCE), // 供界面说明"这个行业为什么没有这一类几何"——不静默、不冒充。 slot.gaps.push({ geometryType: row.geometryType || '', state: state || '', reason: row.reason || '' }) } } if (!Object.keys(result).length) { for (const industry of INDUSTRY_LIST) result[industry] = { geometryAvailable: false, pipeCount: 0, componentCount: 0, gaps: [] } return result } // 后端没点名的行业:如实记"无几何",不让它从覆盖表里消失(消失=看起来像没这个行业)。 for (const industry of INDUSTRY_LIST) { if (!result[industry]) result[industry] = { geometryAvailable: false, pipeCount: 0, componentCount: 0, gaps: [] } } return result } function finiteOr(...values) { for (const value of values) { const num = Number(value) if (Number.isFinite(num)) return num } return 0 } function countByIndustry({ pipes, components }) { const counts = {} for (const industry of INDUSTRY_LIST) counts[industry] = { pipes: 0, components: 0 } for (const pipe of pipes) if (counts[pipe.industry]) counts[pipe.industry].pipes += 1 for (const component of components) if (counts[component.industry]) counts[component.industry].components += 1 return counts } /** * 按当前筛选裁剪(图层过滤只此一处,引擎与组件都不做第二次过滤)。 * * 行业:前端键 → 后端码(`all`/空 = 不限)。 * * **片区(票 67 更正,维护者 2026-10-23)**:几何 properties 里没有片区字段,但设施编码 * 的组成规则是「片区 + 行业 + 时间编码」,所以片区可以从编码前缀**推出来** * (`pipeDistrictOf`)—— 这不是伪造筛选,是把编码里已有的信息用起来。三条纪律: * 1. 推不出片区的对象(现网 4 个:燃气阀门 + 窨井构件,用的是另一套编码)在**选具体片区时排除**, * 并如实计数(`districtUnresolvedCount`)—— 既不硬塞进某个片区,也不静默留在结果里; * 2. 选「未分区」时的语义就是**推不出片区的那批对象**(与后端 district=null 同一读法); * 3. `districtApplied` 只在真的按片区筛了时为 true(全域时 false),界面据此措辞。 * * @param {object} network 图层(`toPipeNetwork()` 的结果) * @param {{industry?: string, scope?: string}} [filters] 全局条件(前端键) */ export function filterPipeNetwork(network, filters = {}) { const current = network || { pipes: [], components: [], blocked: [] } const industry = industryQuery(filters.industry) const district = districtQuery(filters.scope) const keepIndustry = (row) => !industry || row.industry === industry // district 取值:'' = 全域|0/1/2 = 具体片区|'UNASSIGNED' = 推不出片区的那批。 const unresolvedUnderDistrict = district !== '' && district !== 'UNASSIGNED' const keepDistrict = (row) => { if (district === '') return true if (district === 'UNASSIGNED') return row.district === null return row.district === district } const keep = (row) => keepIndustry(row) && keepDistrict(row) const unresolvedCount = (rows) => (unresolvedUnderDistrict ? rows.filter((row) => keepIndustry(row) && row.district === null).length : 0) return { pipes: current.pipes.filter(keep), components: current.components.filter(keep), blocked: current.blocked.filter(keep), coverage: current.coverage, counts: current.counts, districtApplied: district !== '', districtUnresolvedCount: unresolvedCount(current.pipes) + unresolvedCount(current.components), } }