// 票 37 走查扩展(维护者 2026-10-14 要求):数据统计卡片(KPI)逐项配图标。 // 1. 从 E:\&项目模型文件等\UI图片\Texture\Icon 取 I/Pipe/Q 系列既有切图(不引入新素材); // 2. 去色(转灰度,保 alpha); // 3. 染**白色**(#ffffff)—— KPI 图标在渲染层走 CockpitIcon 的 mask 模式, // mask 用灰度当透明度、颜色跟随 CSS currentColor,所以 PNG 本体染白即可; // 4. 输出 public/cockpit/assets/icons/kpi/*.png(256×256)。 // // 为什么染白而不是主蓝:面板标题图标只一种语义(标题)→ 染主蓝合适; // KPI 图标语义随指标变(报警=lv1、在线=lv4…),色相交给 CSS(currentColor), // PNG 里留白色剪影,mask 才能把 currentColor 涂进形状 —— 这是共识 §4.2 在 // 「颜色语义在承载层」原则下的自然延伸。 import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs' import { inflateSync, deflateSync } from 'node:zlib' const SRC = 'E:/&项目模型文件等/UI图片/Texture/Icon' const OUT = 'public/cockpit/assets/icons/kpi' mkdirSync(OUT, { recursive: true }) // KPI key(domain/panelBlocks.js 的 KPI_ICONS 全表)→ 源图(含简短理由,供走查对照)。 // 缺源图的 key 会在结尾如实列出(渲染层跳过,不抛错)。 const MAP = { // 规模/底数 facilityCount: { src: '图标数量@2x.png', meaning: '设施底数/设施总数(计数堆叠)' }, smartDeviceCount: { src: 'Q图标设备logo@2x.png', meaning: '监测规模(设备齿轮)' }, deviceCount: { src: 'I图标设备.png', meaning: '智能设备(设备机柜)' }, equipmentCount: { src: 'I图标设备.png', meaning: '监测设备(设备机柜)' }, monitoringPointCount: { src: 'I图标经纬度.png', meaning: '监测点(定位)' }, monitoringFacilityCount: { src: 'Pipe管网设施.png', meaning: '监测设施(管网)' }, infrastructureFacilityCount: { src: 'Pipe管网设施.png', meaning: '基础设施(管网)' }, industryCount: { src: 'I图标资源整合.png', meaning: '行业数(资源整合)' }, // 运行状态 onlineRate: { src: 'I图标在线.png', meaning: '在线率(信号)' }, online: { src: 'I图标在线.png', meaning: '在线(信号)' }, offline: { src: 'I图标离线.png', meaning: '离线(断信号)' }, stale: { src: 'A图标离线.png', meaning: '数据滞后(信号叉)' }, unknownOrMissing: { src: 'I图标报警误报.png', meaning: '未知/缺失(警示三角)' }, // 预警/报警/处置 warningTotal: { src: 'I图标报警产生.png', meaning: '当前预警(警铃)' }, alarmTotal: { src: 'I图标报警等级.png', meaning: '当前报警(警灯)' }, warningCount: { src: 'I图标报警产生.png', meaning: '预警中(警铃)' }, alarmCount: { src: 'I图标报警等级.png', meaning: '报警中(警灯)' }, abnormalCount: { src: 'Q图标异常设备.png', meaning: '异常设备(异常折线)' }, offlineCount: { src: 'I图标离线.png', meaning: '离线设备(断信号)' }, '预警-total': { src: 'I图标报警产生.png', meaning: '预警总数(警铃)' }, '预警-handled': { src: 'I图标报警解除.png', meaning: '预警已处理(解除铃)' }, '预警-pending': { src: 'I图标报警产生.png', meaning: '预警待处理(警铃)' }, '预警-cancelled': { src: 'I图标报警误报.png', meaning: '预警已取消(警示三角)' }, '预警-unlinked': { src: 'I图标资源整合.png', meaning: '预警未关联(资源整合)' }, '预警-rate': { src: 'I图标报警解除.png', meaning: '预警已解除率(解除铃)' }, '报警-total': { src: 'I图标报警等级.png', meaning: '报警总数(警灯)' }, '报警-handled': { src: 'I图标报警解除.png', meaning: '报警已处理(解除铃)' }, '报警-pending': { src: 'I图标报警等级.png', meaning: '报警待处理(警灯)' }, '报警-cancelled': { src: 'I图标报警误报.png', meaning: '报警已取消(警示三角)' }, '报警-unlinked': { src: 'I图标资源整合.png', meaning: '报警未关联(资源整合)' }, '报警-rate': { src: 'I图标协议实施.png', meaning: '报警处置率(协议实施)' }, handlingRate: { src: 'I图标协议实施.png', meaning: '处置率(协议实施)' }, handlingRateView: { src: 'I图标协议实施.png', meaning: '处置率(协议实施)' }, responseDuration: { src: 'Q图标时间.png', meaning: '响应时长(时钟)' }, closeDuration: { src: 'Q图标时间.png', meaning: '办结时长(时钟)' }, // 基础档案 lengthKm: { src: 'Pipe管网设施.png', meaning: '管线里程(管网)' }, scale: { src: 'Q图标规模种类.png', meaning: '规模(规模种类)' }, // 应急保障 hazardSourceCount: { src: 'I图标报警误报.png', meaning: '危险源(警示三角)' }, protectionTargetCount: { src: '图标安全.png', meaning: '防护目标(盾)' }, emergencyResourceCount: { src: 'Q图标规模种类.png', meaning: '应急资源(规模种类)' }, // 监测运行 createdAt: { src: 'Q图标时间.png', meaning: '数据时间(时钟)' }, } // 白色 RGB:PNG 本体染白,mask 模式下 currentColor 上色。 const TINT = [0xff, 0xff, 0xff] function tintPng(buf) { if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('非 PNG') let pos = 8 const idat = [] let width = 0 let height = 0 let bitDepth = 0 let colorType = 0 let palette = null let trns = null while (pos < buf.length) { const len = buf.readUInt32BE(pos) const type = buf.toString('ascii', pos + 4, pos + 8) const data = buf.subarray(pos + 8, pos + 8 + len) if (type === 'IHDR') { width = data.readUInt32BE(0) height = data.readUInt32BE(4) bitDepth = data[8] colorType = data[9] if (bitDepth !== 8 || ![2, 3, 6].includes(colorType)) throw new Error(`不支持的 PNG 格式: bit=${bitDepth} color=${colorType}`) } else if (type === 'PLTE') { palette = Buffer.from(data) } else if (type === 'tRNS') { trns = Buffer.from(data) } else if (type === 'IDAT') { idat.push(data) } else if (type === 'IEND') { break } pos += 12 + len } if (!width) throw new Error('缺 IHDR') const raw = inflateSync(Buffer.concat(idat)) const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 1 const stride = width * channels const bpp = channels const img = Buffer.alloc(height * stride) for (let y = 0; y < height; y++) { const filter = raw[y * (stride + 1)] const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)) const prev = y > 0 ? img.subarray((y - 1) * stride, y * stride) : null const out = img.subarray(y * stride, (y + 1) * stride) for (let x = 0; x < stride; x++) { const a = x >= bpp ? out[x - bpp] : 0 const b = prev ? prev[x] : 0 const c = prev && x >= bpp ? prev[x - bpp] : 0 let v = row[x] if (filter === 1) v += a else if (filter === 2) v += b else if (filter === 3) v += (a + b) >> 1 else if (filter === 4) { const p = a + b - c const pa = Math.abs(p - a) const pb = Math.abs(p - b) const pc = Math.abs(p - c) v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c } out[x] = v & 0xff } } // 灰度当 mask 强度:亮部→不透明(白)、暗部→更透明。这样 mask 出的形状保留原图层次。 const outRows = [] for (let y = 0; y < height; y++) { const rowOut = Buffer.alloc(1 + width * 4) rowOut[0] = 0 for (let x = 0; x < width; x++) { let r let g let b let alpha = 255 if (colorType === 6) { const o = x * 4 r = img[y * stride + o] g = img[y * stride + o + 1] b = img[y * stride + o + 2] alpha = img[y * stride + o + 3] } else if (colorType === 2) { const o = x * 3 r = img[y * stride + o] g = img[y * stride + o + 1] b = img[y * stride + o + 2] } else { const idx = img[y * stride + x] const p = palette ? idx * 3 : 0 r = palette ? palette[p] : 0 g = palette ? palette[p + 1] : 0 b = palette ? palette[p + 2] : 0 if (trns && idx < trns.length) alpha = trns[idx] } // 灰度(BT.601)→ mask 强度(白 + 灰度透明度)。纯透明像素保持透明。 const lum = (r * 0.299 + g * 0.587 + b * 0.114) / 255 // 提亮下限:源图暗部细节(渐变图标的中段)也要可辨,否则 mask 出来只剩高光。 const strength = Math.min(1, Math.max(0.35, lum)) const o = x * 4 rowOut[1 + o] = TINT[0] rowOut[1 + o + 1] = TINT[1] rowOut[1 + o + 2] = TINT[2] rowOut[1 + o + 3] = Math.round(alpha * strength) } outRows.push(rowOut) } const crc32Table = (() => { const t = new Int32Array(256) for (let n = 0; n < 256; n++) { let c = n for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 t[n] = c } return t })() const crc32 = (b) => { let c = -1 for (const byte of b) c = crc32Table[(c ^ byte) & 0xff] ^ (c >>> 8) return (c ^ -1) >>> 0 } const chunk = (type, data) => { const head = Buffer.alloc(4) head.writeUInt32BE(data.length) const body = Buffer.concat([Buffer.from(type, 'ascii'), data]) const crc = Buffer.alloc(4) crc.writeUInt32BE(crc32(body)) return Buffer.concat([head, body, crc]) } const ihdr = Buffer.alloc(13) ihdr.writeUInt32BE(width, 0) ihdr.writeUInt32BE(height, 4) ihdr[8] = 8 ihdr[9] = 6 const compressed = deflateSync(Buffer.concat(outRows), { level: 9 }) return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', compressed), chunk('IEND', Buffer.alloc(0)), ]) } const nameOf = { facilityCount: 'kpi-facility-count.png', smartDeviceCount: 'kpi-device-gear.png', deviceCount: 'kpi-device.png', equipmentCount: 'kpi-device.png', monitoringPointCount: 'kpi-point.png', monitoringFacilityCount: 'kpi-pipe.png', infrastructureFacilityCount: 'kpi-pipe.png', industryCount: 'kpi-industry.png', onlineRate: 'kpi-online.png', online: 'kpi-online.png', offline: 'kpi-offline.png', stale: 'kpi-stale.png', unknownOrMissing: 'kpi-unknown.png', warningTotal: 'kpi-warning.png', alarmTotal: 'kpi-alarm.png', warningCount: 'kpi-warning.png', alarmCount: 'kpi-alarm.png', abnormalCount: 'kpi-abnormal.png', offlineCount: 'kpi-offline.png', '预警-total': 'kpi-warning.png', '预警-handled': 'kpi-resolved.png', '预警-pending': 'kpi-warning.png', '预警-cancelled': 'kpi-unknown.png', '预警-unlinked': 'kpi-industry.png', '预警-rate': 'kpi-resolved.png', '报警-total': 'kpi-alarm.png', '报警-handled': 'kpi-resolved.png', '报警-pending': 'kpi-alarm.png', '报警-cancelled': 'kpi-unknown.png', '报警-unlinked': 'kpi-industry.png', '报警-rate': 'kpi-efficiency.png', handlingRate: 'kpi-efficiency.png', handlingRateView: 'kpi-efficiency.png', responseDuration: 'kpi-time.png', closeDuration: 'kpi-time.png', lengthKm: 'kpi-pipe.png', scale: 'kpi-scale.png', hazardSourceCount: 'kpi-unknown.png', protectionTargetCount: 'kpi-shield.png', emergencyResourceCount: 'kpi-scale.png', createdAt: 'kpi-time.png', } const missing = [] for (const [key, cfg] of Object.entries(MAP)) { const srcPath = `${SRC}/${cfg.src}` if (!existsSync(srcPath)) { missing.push(`${key} <- ${cfg.src}`) continue } const out = tintPng(readFileSync(srcPath)) writeFileSync(`${OUT}/${nameOf[key]}`, out) console.log(` ✓ ${key} <- ${cfg.src} -> kpi/${nameOf[key]} (${out.length}B)`) } if (missing.length) { console.log('\n缺源图:') for (const line of missing) console.log(` ✗ ${line}`) }