pipeNetwork.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. /**
  2. * 管线与设施点位图层(票 24)。
  3. *
  4. * 与监测点**不是同一类对象**,因此不共用 `domain/mapSummary.js` 的口径:
  5. * · 监测点是「承载设备的空间业务对象」,管线是**设施**(管网本身),二者不可混算;
  6. * · 契约形状也不同:这里是 GeoJSON 风格的 `features[]`,不是分页快照。
  7. *
  8. * 两条不可让步的纪律(沿用票 07 / 决策票 07):
  9. * 1. **准��只认后端 `coordinateValidation.renderable`**,本文件不重算经纬度范围,
  10. * 更不纠偏、不补默认坐标 —— 与 `isRenderable()` 同一立场;
  11. * 2. **不得臆造安装拓扑**。管段只有坐标、无正式端节点ID,
  12. * 所以角/支管构件只能来自后端给的真实构件属性(燃气 pointType)。
  13. * 经用户确认,完全重合的同业端点可仅用于无分叉链的管径示意插值,
  14. * 不据此生成角/支管构件、不把候选连接关系当工程拓扑。
  15. *
  16. * 行业覆盖如实呈现:哪个行业有几何、哪个没有,由后端 `coverage` 表达,
  17. * 前端只转述,不替后端猜(缺数据就说缺数据,不画空图层冒充"这里没有管线")。
  18. */
  19. import { COORDINATE_REASON_TEXT, NO_MAP_LOCATION_TEXT } from './coordinate.js'
  20. import { DISTRICT_CODES, INDUSTRY_CODES, districtQuery, industryQuery } from './filterOptions.js'
  21. /** 四个正式行业码(燃气/供水/排水/窨井)——`INDUSTRY_CODES` 的**值**才是后端码。 */
  22. const INDUSTRY_LIST = Object.freeze(Object.values(INDUSTRY_CODES))
  23. const INDUSTRY_SET = new Set(INDUSTRY_LIST)
  24. /** 四类程序化构件(维护者 2026-12 裁定)。样条管道是管段本身,不单列构件。 */
  25. export const PIPE_COMPONENT = {
  26. ELBOW: 'elbow',
  27. VALVE: 'valve',
  28. BRANCH: 'branch',
  29. OTHER: 'other',
  30. }
  31. /**
  32. * 后端构件类型 → 前端构件类别。
  33. *
  34. * 后端 `pointType` **原样透传**(票 30 实测为英文 `valve`;数据底座注释另有中文词表),
  35. * 两侧都认。白名单外一律落 `other` 并**保留原文**:界面要能说清"这是个什么构件",
  36. * 不能因为我们没建模就把调压/计量/阴极保护一律说成"其它"以外的东西,更不能猜。
  37. */
  38. const POINT_TYPE_COMPONENT = {
  39. 阀门: PIPE_COMPONENT.VALVE,
  40. 弯头: PIPE_COMPONENT.ELBOW,
  41. 三通: PIPE_COMPONENT.BRANCH,
  42. valve: PIPE_COMPONENT.VALVE,
  43. elbow: PIPE_COMPONENT.ELBOW,
  44. tee: PIPE_COMPONENT.BRANCH,
  45. cross: PIPE_COMPONENT.BRANCH,
  46. branch: PIPE_COMPONENT.BRANCH,
  47. }
  48. export const COMPONENT_TEXT = {
  49. [PIPE_COMPONENT.VALVE]: '阀门',
  50. [PIPE_COMPONENT.ELBOW]: '角管道',
  51. [PIPE_COMPONENT.BRANCH]: '支管管道',
  52. [PIPE_COMPONENT.OTHER]: '其它构件',
  53. }
  54. /**
  55. * 管径(mm) → 管线线宽(px)。
  56. *
  57. * 放 domain 而不是引擎层:它是**判定**,引擎只消费结果(与本仓「判定收进 domain」一致),
  58. * 这样它能脱 Cesium、脱离浏览器断言。
  59. * 分档而非线性——真实数据的管径跨度从几十到上千毫米,线性映射会把细管压成看不见、
  60. * 把粗管撑成整屏宽。缺管径回最细档(画出来总比不画诚实),但**不猜**一个"典型值"。
  61. */
  62. export function pipeWidth(diameter) {
  63. const value = Number(diameter)
  64. if (!Number.isFinite(value) || value <= 0) return 2
  65. if (value < 300) return 2
  66. if (value < 600) return 3
  67. if (value < 1000) return 4
  68. return 6
  69. }
  70. /** 分类:只认白名单自有键(继承键会漏出函数,与票 22 同一条纪律)。 */
  71. function componentOf(pointType) {
  72. const raw = typeof pointType === 'string' ? pointType.trim() : ''
  73. if (!raw) return { kind: PIPE_COMPONENT.OTHER, label: '' }
  74. if (Object.prototype.hasOwnProperty.call(POINT_TYPE_COMPONENT, raw)) {
  75. return { kind: POINT_TYPE_COMPONENT[raw], label: raw }
  76. }
  77. // 未建模的构件类型(调压/计量/阴极保护/其它):如实显示后端原文,不伪装成已知类别。
  78. return { kind: PIPE_COMPONENT.OTHER, label: raw }
  79. }
  80. /**
  81. * **管线/构件编码的片区前缀**(维护者 2026-10-23 更正)。
  82. *
  83. * 设施编码的组成规则是「**片区 + 行业 + 时间编码**」,例:TCRQ202609080001
  84. * = 太常(TC) + 燃气(RQ) + 202609080001;CNWS... = 城南 + 供水。所以片区的第一手来源
  85. * 就在编码前两位,而不是几何 properties 里的独立字段(那里**没有**片区字段)。
  86. *
  87. * 取值与 filterOptions.DISTRICT_CODES 的**后端片区编码**同值(south=0 / north=1 / taichang=2)。
  88. * 现网 6766 条几何里只出现过 TC 与 CN(实测:前缀与 name 里的片区词 100% 一致,
  89. * 4116/4116 与 2646/2646,零反例);**CB(城北)已由维护者 2026-10-23 确认**,**现网无该前缀数据**,
  90. * 现网还没有 CB 前缀的数据,所以选「城北」时管线为空 —— 那是编码里没有,不是没有管线。
  91. * 编码规则若变,改这一张表即可。
  92. */
  93. export const PIPE_DISTRICT_PREFIX = Object.freeze({
  94. CN: DISTRICT_CODES.south,
  95. CB: DISTRICT_CODES.north,
  96. TC: DISTRICT_CODES.taichang,
  97. })
  98. /**
  99. * 设施编码 → 片区编码(0/1/2);认不出回 null =**推不出**,不猜。
  100. *
  101. * 只认「两位片区字母 + 两位行业字母」这个正式形状(前四位必须都是字母):
  102. * 现网另有 GD-001(燃气阀门)、YL-YJ-001(窨井)这类**另一套编码**的构件,
  103. * 前两位不是片区码 —— 它们回 null,由筛选层如实计数,不硬塞进某个片区。
  104. */
  105. export function pipeDistrictOf(code) {
  106. const raw = typeof code === 'string' ? code.trim().toUpperCase() : ''
  107. if (!/^[A-Z]{2}[A-Z]{2}/.test(raw)) return null
  108. const prefix = raw.slice(0, 2)
  109. return Object.hasOwn(PIPE_DISTRICT_PREFIX, prefix) ? PIPE_DISTRICT_PREFIX[prefix] : null
  110. }
  111. /** 行业码 → 规范化后端码;认不出的回空串(不拿脏值去筛、不进统计)。 */
  112. function industryOf(code) {
  113. const raw = typeof code === 'string' ? code.trim() : ''
  114. return INDUSTRY_SET.has(raw) ? raw : ''
  115. }
  116. /** 后端 `coordinateValidation` → 准入结论 + 不上图原因(原因文案走票 07 词表)。 */
  117. function admission(properties) {
  118. const validation = (properties && properties.coordinateValidation) || null
  119. if (!validation) return { renderable: false, reasonCode: '', reasonText: '坐标未校验' }
  120. if (validation.renderable === true) return { renderable: true, reasonCode: '', reasonText: '' }
  121. const code = typeof validation.reason === 'string' ? validation.reason : ''
  122. return { renderable: false, reasonCode: code, reasonText: COORDINATE_REASON_TEXT[code] || NO_MAP_LOCATION_TEXT }
  123. }
  124. function isFiniteNumber(value) {
  125. return typeof value === 'number' && Number.isFinite(value)
  126. }
  127. function diameterOf(value) {
  128. if (typeof value !== 'number' && typeof value !== 'string') return null
  129. if (typeof value === 'string' && !value.trim()) return null
  130. const number = Number(value)
  131. return Number.isFinite(number) ? number : null
  132. }
  133. /** 管段几何:保留完整路径;任一顶点结构非法就整条拦下,不跳点重连。 */
  134. function pipePositions(geometry) {
  135. const raw = geometry && geometry.type === 'LineString' && Array.isArray(geometry.coordinates) ? geometry.coordinates : []
  136. if (raw.length < 2) return []
  137. const positions = []
  138. for (const pair of raw) {
  139. if (!Array.isArray(pair) || !isFiniteNumber(pair[0]) || !isFiniteNumber(pair[1])) return []
  140. positions.push([pair[0], pair[1]])
  141. }
  142. return positions
  143. }
  144. /** 构件几何:点 `[lon, lat]`。 */
  145. function componentPosition(geometry) {
  146. const raw = geometry && geometry.type === 'Point' && Array.isArray(geometry.coordinates) ? geometry.coordinates : null
  147. return raw && isFiniteNumber(raw[0]) && isFiniteNumber(raw[1]) ? [raw[0], raw[1]] : []
  148. }
  149. /**
  150. * 后端几何载荷 → 领域图层。
  151. *
  152. * @param {object} payload 真实契约见 [票 30](30-untitled.md)(端点 `GET /api/cockpit/v1/infrastructure/geometry`):
  153. * `{ type:'FeatureCollection', generatedAt, features:[Feature], coverage:[...] }`。
  154. * Feature 是标准 GeoJSON:`{ type:'Feature', id, geometry, properties }`;
  155. * **没有 `kind` 字段**——管段/构件按 `geometry.type`(LineString/Point)判别;
  156. * 非法坐标的 feature `geometry` 为 **null**(原始坐标保留在 `properties.sourceCoordinates`)。
  157. * @returns {{pipes:Array, components:Array, blocked:Array, coverage:object, counts:object}}
  158. * `pipes`/`components` 只含**后端已判定可上图**的对象;被拦下的进 `blocked`,
  159. * 带中文原因,供界面「无地图位置」清单如实显示。
  160. */
  161. export function toPipeNetwork(payload) {
  162. const features = Array.isArray(payload && payload.features) ? payload.features : []
  163. const pipes = []
  164. const components = []
  165. const blocked = []
  166. for (const feature of features) {
  167. const properties = (feature && feature.properties) || {}
  168. const industry = industryOf(properties.industry)
  169. // 管段/构件判别:契约没有 kind 字段,按 geometry 形状判。geometry 为 null(非法坐标)
  170. // 时按 properties.typeCode 归类——两端的 source(pipe_network_base / gas_pipe_point / manhole_base)
  171. // 各自只有一种形状,typeCode 是后端给的权威标识,不猜。
  172. const geometryType = feature && feature.geometry && feature.geometry.type
  173. const isComponent = geometryType === 'Point' || (!geometryType && String(properties.typeCode || '').includes('POINT'))
  174. const kind = isComponent ? 'component' : 'pipe'
  175. const id = String(properties.code || properties.sourceId || feature.id || '')
  176. const verdict = admission(properties)
  177. // 票 67 更正(维护者 2026-10-23):片区从**设施编码前缀**推(片区+行业+时间编码),
  178. // 几何 properties 里没有片区字段。推不出的回 null,交给筛选层如实计数。
  179. const district = pipeDistrictOf(id)
  180. if (!verdict.renderable) {
  181. blocked.push({
  182. kind,
  183. code: id,
  184. industry,
  185. district,
  186. name: String(properties.name || properties.pointType || id || ''),
  187. reasonCode: verdict.reasonCode,
  188. reasonText: verdict.reasonText,
  189. })
  190. continue
  191. }
  192. if (kind === 'pipe') {
  193. const positions = pipePositions(feature.geometry)
  194. if (positions.length < 2) {
  195. blocked.push({ kind, code: id, industry, district, name: String(properties.name || id || ''), reasonCode: '', reasonText: NO_MAP_LOCATION_TEXT })
  196. continue
  197. }
  198. pipes.push({
  199. code: id,
  200. district,
  201. sourceId: String(properties.sourceId ?? ''),
  202. networkId: String(properties.networkId ?? ''),
  203. status: typeof properties.status === 'string' ? properties.status : '',
  204. statusLabel: typeof properties.statusLabel === 'string' ? properties.statusLabel : '',
  205. name: String(properties.name || ''),
  206. remark: typeof properties.remark === 'string' ? properties.remark : '',
  207. networkLevel: properties.networkLevel ?? properties.network_level ?? 0,
  208. industry,
  209. material: String(properties.material ?? ''),
  210. diameter: diameterOf(properties.diameter),
  211. length: properties.length == null || String(properties.length).trim() === '' ? null
  212. : isFiniteNumber(Number(properties.length)) ? Number(properties.length) : null,
  213. positions,
  214. })
  215. continue
  216. }
  217. const position = componentPosition(feature.geometry)
  218. if (!position.length) {
  219. blocked.push({ kind, code: id, industry, district, name: String(properties.pointType || id || ''), reasonCode: '', reasonText: NO_MAP_LOCATION_TEXT })
  220. continue
  221. }
  222. const component = componentOf(properties.pointType)
  223. components.push({
  224. code: id,
  225. district,
  226. sourceId: String(properties.sourceId ?? ''),
  227. networkId: String(properties.networkId ?? ''),
  228. status: typeof properties.status === 'string' ? properties.status : '',
  229. statusLabel: typeof properties.statusLabel === 'string' ? properties.statusLabel : '',
  230. industry,
  231. kind: component.kind,
  232. label: component.label,
  233. name: String(properties.name || component.label || ''),
  234. position,
  235. })
  236. }
  237. return { pipes, components, blocked, coverage: coverageOf(payload, { pipes, components }), counts: countByIndustry({ pipes, components }) }
  238. }
  239. /** 行业覆盖:后端给了就照抄,没给就按实际画出来的数说 —— 两种情况都不替后端圆场。
  240. *
  241. * 真实契约(票 30)的 coverage 是**三态行**:`{industry, source, geometryType, state, reason, recordCount, geometryCount, renderableCount}`,
  242. * `state` ∈ `AVAILABLE`/`EMPTY`/`UNAVAILABLE`。同一行业可有**两行**(管线一行、构件一行),
  243. * 按行归并:任一行 AVAILABLE 即视为有几何;管段/构件计数各取各自行。 */
  244. function coverageOf(payload, drawn) {
  245. const declared = Array.isArray(payload && payload.coverage) ? payload.coverage : []
  246. const result = {}
  247. for (const row of declared) {
  248. const industry = industryOf(row && row.industry)
  249. if (!industry) continue
  250. const state = row && row.state
  251. const isPointRow = (row && row.geometryType) === 'Point'
  252. const slot = result[industry] || (result[industry] = { geometryAvailable: false, pipeCount: 0, componentCount: 0, gaps: [] })
  253. if (state === 'AVAILABLE') {
  254. slot.geometryAvailable = true
  255. if (isPointRow) slot.componentCount = finiteOr(row.renderableCount, row.geometryCount)
  256. else slot.pipeCount = finiteOr(row.renderableCount, row.geometryCount)
  257. } else if (state === 'UNAVAILABLE' || state === 'EMPTY') {
  258. // 缺口如实记录(如供水 NO_COMPONENT_SOURCE、窨井 NO_PIPELINE_SOURCE),
  259. // 供界面说明"这个行业为什么没有这一类几何"——不静默、不冒充。
  260. slot.gaps.push({ geometryType: row.geometryType || '', state: state || '', reason: row.reason || '' })
  261. }
  262. }
  263. if (!Object.keys(result).length) {
  264. for (const industry of INDUSTRY_LIST) result[industry] = { geometryAvailable: false, pipeCount: 0, componentCount: 0, gaps: [] }
  265. return result
  266. }
  267. // 后端没点名的行业:如实记"无几何",不让它从覆盖表里消失(消失=看起来像没这个行业)。
  268. for (const industry of INDUSTRY_LIST) {
  269. if (!result[industry]) result[industry] = { geometryAvailable: false, pipeCount: 0, componentCount: 0, gaps: [] }
  270. }
  271. return result
  272. }
  273. function finiteOr(...values) {
  274. for (const value of values) {
  275. const num = Number(value)
  276. if (Number.isFinite(num)) return num
  277. }
  278. return 0
  279. }
  280. function countByIndustry({ pipes, components }) {
  281. const counts = {}
  282. for (const industry of INDUSTRY_LIST) counts[industry] = { pipes: 0, components: 0 }
  283. for (const pipe of pipes) if (counts[pipe.industry]) counts[pipe.industry].pipes += 1
  284. for (const component of components) if (counts[component.industry]) counts[component.industry].components += 1
  285. return counts
  286. }
  287. /**
  288. * 按当前筛选裁剪(图层过滤只此一处,引擎与组件都不做第二次过滤)。
  289. *
  290. * 行业:前端键 → 后端码(`all`/空 = 不限)。
  291. *
  292. * **片区(票 67 更正,维护者 2026-10-23)**:几何 properties 里没有片区字段,但设施编码
  293. * 的组成规则是「片区 + 行业 + 时间编码」,所以片区可以从编码前缀**推出来**
  294. * (`pipeDistrictOf`)—— 这不是伪造筛选,是把编码里已有的信息用起来。三条纪律:
  295. * 1. 推不出片区的对象(现网 4 个:燃气阀门 + 窨井构件,用的是另一套编码)在**选具体片区时排除**,
  296. * 并如实计数(`districtUnresolvedCount`)—— 既不硬塞进某个片区,也不静默留在结果里;
  297. * 2. 选「未分区」时的语义就是**推不出片区的那批对象**(与后端 district=null 同一读法);
  298. * 3. `districtApplied` 只在真的按片区筛了时为 true(全域时 false),界面据此措辞。
  299. *
  300. * @param {object} network 图层(`toPipeNetwork()` 的结果)
  301. * @param {{industry?: string, scope?: string}} [filters] 全局条件(前端键)
  302. */
  303. export function filterPipeNetwork(network, filters = {}) {
  304. const current = network || { pipes: [], components: [], blocked: [] }
  305. const industry = industryQuery(filters.industry)
  306. const district = districtQuery(filters.scope)
  307. const keepIndustry = (row) => !industry || row.industry === industry
  308. // district 取值:'' = 全域|0/1/2 = 具体片区|'UNASSIGNED' = 推不出片区的那批。
  309. const unresolvedUnderDistrict = district !== '' && district !== 'UNASSIGNED'
  310. const keepDistrict = (row) => {
  311. if (district === '') return true
  312. if (district === 'UNASSIGNED') return row.district === null
  313. return row.district === district
  314. }
  315. const keep = (row) => keepIndustry(row) && keepDistrict(row)
  316. const unresolvedCount = (rows) => (unresolvedUnderDistrict
  317. ? rows.filter((row) => keepIndustry(row) && row.district === null).length
  318. : 0)
  319. return {
  320. pipes: current.pipes.filter(keep),
  321. components: current.components.filter(keep),
  322. blocked: current.blocked.filter(keep),
  323. coverage: current.coverage,
  324. counts: current.counts,
  325. districtApplied: district !== '',
  326. districtUnresolvedCount: unresolvedCount(current.pipes) + unresolvedCount(current.components),
  327. }
  328. }