tint-kpi-icons.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. // 票 37 走查扩展(维护者 2026-10-14 要求):数据统计卡片(KPI)逐项配图标。
  2. // 1. 从 E:\&项目模型文件等\UI图片\Texture\Icon 取 I/Pipe/Q 系列既有切图(不引入新素材);
  3. // 2. 去色(转灰度,保 alpha);
  4. // 3. 染**白色**(#ffffff)—— KPI 图标在渲染层走 CockpitIcon 的 mask 模式,
  5. // mask 用灰度当透明度、颜色跟随 CSS currentColor,所以 PNG 本体染白即可;
  6. // 4. 输出 public/cockpit/assets/icons/kpi/*.png(256×256)。
  7. //
  8. // 为什么染白而不是主蓝:面板标题图标只一种语义(标题)→ 染主蓝合适;
  9. // KPI 图标语义随指标变(报警=lv1、在线=lv4…),色相交给 CSS(currentColor),
  10. // PNG 里留白色剪影,mask 才能把 currentColor 涂进形状 —— 这是共识 §4.2 在
  11. // 「颜色语义在承载层」原则下的自然延伸。
  12. import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
  13. import { inflateSync, deflateSync } from 'node:zlib'
  14. const SRC = 'E:/&项目模型文件等/UI图片/Texture/Icon'
  15. const OUT = 'public/cockpit/assets/icons/kpi'
  16. mkdirSync(OUT, { recursive: true })
  17. // KPI key(domain/panelBlocks.js 的 KPI_ICONS 全表)→ 源图(含简短理由,供走查对照)。
  18. // 缺源图的 key 会在结尾如实列出(渲染层跳过,不抛错)。
  19. const MAP = {
  20. // 规模/底数
  21. facilityCount: { src: '图标数量@2x.png', meaning: '设施底数/设施总数(计数堆叠)' },
  22. smartDeviceCount: { src: 'Q图标设备logo@2x.png', meaning: '监测规模(设备齿轮)' },
  23. deviceCount: { src: 'I图标设备.png', meaning: '智能设备(设备机柜)' },
  24. equipmentCount: { src: 'I图标设备.png', meaning: '监测设备(设备机柜)' },
  25. monitoringPointCount: { src: 'I图标经纬度.png', meaning: '监测点(定位)' },
  26. monitoringFacilityCount: { src: 'Pipe管网设施.png', meaning: '监测设施(管网)' },
  27. infrastructureFacilityCount: { src: 'Pipe管网设施.png', meaning: '基础设施(管网)' },
  28. industryCount: { src: 'I图标资源整合.png', meaning: '行业数(资源整合)' },
  29. // 运行状态
  30. onlineRate: { src: 'I图标在线.png', meaning: '在线率(信号)' },
  31. online: { src: 'I图标在线.png', meaning: '在线(信号)' },
  32. offline: { src: 'I图标离线.png', meaning: '离线(断信号)' },
  33. stale: { src: 'A图标离线.png', meaning: '数据滞后(信号叉)' },
  34. unknownOrMissing: { src: 'I图标报警误报.png', meaning: '未知/缺失(警示三角)' },
  35. // 预警/报警/处置
  36. warningTotal: { src: 'I图标报警产生.png', meaning: '当前预警(警铃)' },
  37. alarmTotal: { src: 'I图标报警等级.png', meaning: '当前报警(警灯)' },
  38. warningCount: { src: 'I图标报警产生.png', meaning: '预警中(警铃)' },
  39. alarmCount: { src: 'I图标报警等级.png', meaning: '报警中(警灯)' },
  40. abnormalCount: { src: 'Q图标异常设备.png', meaning: '异常设备(异常折线)' },
  41. offlineCount: { src: 'I图标离线.png', meaning: '离线设备(断信号)' },
  42. '预警-total': { src: 'I图标报警产生.png', meaning: '预警总数(警铃)' },
  43. '预警-handled': { src: 'I图标报警解除.png', meaning: '预警已处理(解除铃)' },
  44. '预警-pending': { src: 'I图标报警产生.png', meaning: '预警待处理(警铃)' },
  45. '预警-cancelled': { src: 'I图标报警误报.png', meaning: '预警已取消(警示三角)' },
  46. '预警-unlinked': { src: 'I图标资源整合.png', meaning: '预警未关联(资源整合)' },
  47. '预警-rate': { src: 'I图标报警解除.png', meaning: '预警已解除率(解除铃)' },
  48. '报警-total': { src: 'I图标报警等级.png', meaning: '报警总数(警灯)' },
  49. '报警-handled': { src: 'I图标报警解除.png', meaning: '报警已处理(解除铃)' },
  50. '报警-pending': { src: 'I图标报警等级.png', meaning: '报警待处理(警灯)' },
  51. '报警-cancelled': { src: 'I图标报警误报.png', meaning: '报警已取消(警示三角)' },
  52. '报警-unlinked': { src: 'I图标资源整合.png', meaning: '报警未关联(资源整合)' },
  53. '报警-rate': { src: 'I图标协议实施.png', meaning: '报警处置率(协议实施)' },
  54. handlingRate: { src: 'I图标协议实施.png', meaning: '处置率(协议实施)' },
  55. handlingRateView: { src: 'I图标协议实施.png', meaning: '处置率(协议实施)' },
  56. responseDuration: { src: 'Q图标时间.png', meaning: '响应时长(时钟)' },
  57. closeDuration: { src: 'Q图标时间.png', meaning: '办结时长(时钟)' },
  58. // 基础档案
  59. lengthKm: { src: 'Pipe管网设施.png', meaning: '管线里程(管网)' },
  60. scale: { src: 'Q图标规模种类.png', meaning: '规模(规模种类)' },
  61. // 应急保障
  62. hazardSourceCount: { src: 'I图标报警误报.png', meaning: '危险源(警示三角)' },
  63. protectionTargetCount: { src: '图标安全.png', meaning: '防护目标(盾)' },
  64. emergencyResourceCount: { src: 'Q图标规模种类.png', meaning: '应急资源(规模种类)' },
  65. // 监测运行
  66. createdAt: { src: 'Q图标时间.png', meaning: '数据时间(时钟)' },
  67. }
  68. // 白色 RGB:PNG 本体染白,mask 模式下 currentColor 上色。
  69. const TINT = [0xff, 0xff, 0xff]
  70. function tintPng(buf) {
  71. if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('非 PNG')
  72. let pos = 8
  73. const idat = []
  74. let width = 0
  75. let height = 0
  76. let bitDepth = 0
  77. let colorType = 0
  78. let palette = null
  79. let trns = null
  80. while (pos < buf.length) {
  81. const len = buf.readUInt32BE(pos)
  82. const type = buf.toString('ascii', pos + 4, pos + 8)
  83. const data = buf.subarray(pos + 8, pos + 8 + len)
  84. if (type === 'IHDR') {
  85. width = data.readUInt32BE(0)
  86. height = data.readUInt32BE(4)
  87. bitDepth = data[8]
  88. colorType = data[9]
  89. if (bitDepth !== 8 || ![2, 3, 6].includes(colorType)) throw new Error(`不支持的 PNG 格式: bit=${bitDepth} color=${colorType}`)
  90. } else if (type === 'PLTE') {
  91. palette = Buffer.from(data)
  92. } else if (type === 'tRNS') {
  93. trns = Buffer.from(data)
  94. } else if (type === 'IDAT') {
  95. idat.push(data)
  96. } else if (type === 'IEND') {
  97. break
  98. }
  99. pos += 12 + len
  100. }
  101. if (!width) throw new Error('缺 IHDR')
  102. const raw = inflateSync(Buffer.concat(idat))
  103. const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 1
  104. const stride = width * channels
  105. const bpp = channels
  106. const img = Buffer.alloc(height * stride)
  107. for (let y = 0; y < height; y++) {
  108. const filter = raw[y * (stride + 1)]
  109. const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
  110. const prev = y > 0 ? img.subarray((y - 1) * stride, y * stride) : null
  111. const out = img.subarray(y * stride, (y + 1) * stride)
  112. for (let x = 0; x < stride; x++) {
  113. const a = x >= bpp ? out[x - bpp] : 0
  114. const b = prev ? prev[x] : 0
  115. const c = prev && x >= bpp ? prev[x - bpp] : 0
  116. let v = row[x]
  117. if (filter === 1) v += a
  118. else if (filter === 2) v += b
  119. else if (filter === 3) v += (a + b) >> 1
  120. else if (filter === 4) {
  121. const p = a + b - c
  122. const pa = Math.abs(p - a)
  123. const pb = Math.abs(p - b)
  124. const pc = Math.abs(p - c)
  125. v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
  126. }
  127. out[x] = v & 0xff
  128. }
  129. }
  130. // 灰度当 mask 强度:亮部→不透明(白)、暗部→更透明。这样 mask 出的形状保留原图层次。
  131. const outRows = []
  132. for (let y = 0; y < height; y++) {
  133. const rowOut = Buffer.alloc(1 + width * 4)
  134. rowOut[0] = 0
  135. for (let x = 0; x < width; x++) {
  136. let r
  137. let g
  138. let b
  139. let alpha = 255
  140. if (colorType === 6) {
  141. const o = x * 4
  142. r = img[y * stride + o]
  143. g = img[y * stride + o + 1]
  144. b = img[y * stride + o + 2]
  145. alpha = img[y * stride + o + 3]
  146. } else if (colorType === 2) {
  147. const o = x * 3
  148. r = img[y * stride + o]
  149. g = img[y * stride + o + 1]
  150. b = img[y * stride + o + 2]
  151. } else {
  152. const idx = img[y * stride + x]
  153. const p = palette ? idx * 3 : 0
  154. r = palette ? palette[p] : 0
  155. g = palette ? palette[p + 1] : 0
  156. b = palette ? palette[p + 2] : 0
  157. if (trns && idx < trns.length) alpha = trns[idx]
  158. }
  159. // 灰度(BT.601)→ mask 强度(白 + 灰度透明度)。纯透明像素保持透明。
  160. const lum = (r * 0.299 + g * 0.587 + b * 0.114) / 255
  161. // 提亮下限:源图暗部细节(渐变图标的中段)也要可辨,否则 mask 出来只剩高光。
  162. const strength = Math.min(1, Math.max(0.35, lum))
  163. const o = x * 4
  164. rowOut[1 + o] = TINT[0]
  165. rowOut[1 + o + 1] = TINT[1]
  166. rowOut[1 + o + 2] = TINT[2]
  167. rowOut[1 + o + 3] = Math.round(alpha * strength)
  168. }
  169. outRows.push(rowOut)
  170. }
  171. const crc32Table = (() => {
  172. const t = new Int32Array(256)
  173. for (let n = 0; n < 256; n++) {
  174. let c = n
  175. for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
  176. t[n] = c
  177. }
  178. return t
  179. })()
  180. const crc32 = (b) => {
  181. let c = -1
  182. for (const byte of b) c = crc32Table[(c ^ byte) & 0xff] ^ (c >>> 8)
  183. return (c ^ -1) >>> 0
  184. }
  185. const chunk = (type, data) => {
  186. const head = Buffer.alloc(4)
  187. head.writeUInt32BE(data.length)
  188. const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
  189. const crc = Buffer.alloc(4)
  190. crc.writeUInt32BE(crc32(body))
  191. return Buffer.concat([head, body, crc])
  192. }
  193. const ihdr = Buffer.alloc(13)
  194. ihdr.writeUInt32BE(width, 0)
  195. ihdr.writeUInt32BE(height, 4)
  196. ihdr[8] = 8
  197. ihdr[9] = 6
  198. const compressed = deflateSync(Buffer.concat(outRows), { level: 9 })
  199. return Buffer.concat([
  200. Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
  201. chunk('IHDR', ihdr),
  202. chunk('IDAT', compressed),
  203. chunk('IEND', Buffer.alloc(0)),
  204. ])
  205. }
  206. const nameOf = {
  207. facilityCount: 'kpi-facility-count.png',
  208. smartDeviceCount: 'kpi-device-gear.png',
  209. deviceCount: 'kpi-device.png',
  210. equipmentCount: 'kpi-device.png',
  211. monitoringPointCount: 'kpi-point.png',
  212. monitoringFacilityCount: 'kpi-pipe.png',
  213. infrastructureFacilityCount: 'kpi-pipe.png',
  214. industryCount: 'kpi-industry.png',
  215. onlineRate: 'kpi-online.png',
  216. online: 'kpi-online.png',
  217. offline: 'kpi-offline.png',
  218. stale: 'kpi-stale.png',
  219. unknownOrMissing: 'kpi-unknown.png',
  220. warningTotal: 'kpi-warning.png',
  221. alarmTotal: 'kpi-alarm.png',
  222. warningCount: 'kpi-warning.png',
  223. alarmCount: 'kpi-alarm.png',
  224. abnormalCount: 'kpi-abnormal.png',
  225. offlineCount: 'kpi-offline.png',
  226. '预警-total': 'kpi-warning.png',
  227. '预警-handled': 'kpi-resolved.png',
  228. '预警-pending': 'kpi-warning.png',
  229. '预警-cancelled': 'kpi-unknown.png',
  230. '预警-unlinked': 'kpi-industry.png',
  231. '预警-rate': 'kpi-resolved.png',
  232. '报警-total': 'kpi-alarm.png',
  233. '报警-handled': 'kpi-resolved.png',
  234. '报警-pending': 'kpi-alarm.png',
  235. '报警-cancelled': 'kpi-unknown.png',
  236. '报警-unlinked': 'kpi-industry.png',
  237. '报警-rate': 'kpi-efficiency.png',
  238. handlingRate: 'kpi-efficiency.png',
  239. handlingRateView: 'kpi-efficiency.png',
  240. responseDuration: 'kpi-time.png',
  241. closeDuration: 'kpi-time.png',
  242. lengthKm: 'kpi-pipe.png',
  243. scale: 'kpi-scale.png',
  244. hazardSourceCount: 'kpi-unknown.png',
  245. protectionTargetCount: 'kpi-shield.png',
  246. emergencyResourceCount: 'kpi-scale.png',
  247. createdAt: 'kpi-time.png',
  248. }
  249. const missing = []
  250. for (const [key, cfg] of Object.entries(MAP)) {
  251. const srcPath = `${SRC}/${cfg.src}`
  252. if (!existsSync(srcPath)) {
  253. missing.push(`${key} <- ${cfg.src}`)
  254. continue
  255. }
  256. const out = tintPng(readFileSync(srcPath))
  257. writeFileSync(`${OUT}/${nameOf[key]}`, out)
  258. console.log(` ✓ ${key} <- ${cfg.src} -> kpi/${nameOf[key]} (${out.length}B)`)
  259. }
  260. if (missing.length) {
  261. console.log('\n缺源图:')
  262. for (const line of missing) console.log(` ✗ ${line}`)
  263. }