pipeNetwork.js 21 KB

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