/** * 位置符号素材合成(批次 3)——把 `markerPlan.js` 的合成计划烘焙成 billboard 图片。 * * 双层水滴徽标:外层母版(纯白剪影 PNG,运行时按状态色着色)+ 内层行业图标。 * 合成走**异步 HTML 合成**:`createImageBitmap` 逐层绘制到离屏 canvas,导出 data URL。 * * 着色与风格规则(2026-10-12 维护者修订,并入《图标素材使用共识》): * · 外层母版:状态色整体着色(色相=tokens 语义色,明度结构=母版剪影); * · 内层图标:**一律去色**后按「帽下区域染主蓝渐变(蓝→白)、帽顶区域染白」—— * 点位/设备标记已用颜色语义表达状态,内层再用彩色会与状态色混色融合导致不可辨; * 无颜色语义处用蓝/白渐变(实测观感最优); * · **白色描边**:整枚符号外圈 2px 白描边(替代早先的白色底板方案),深色底图上更立体; * · **输出裁剪到 bbox**:母版 512² 方图两侧全是透明,不裁剪塞进非方 billboard 会被拉伸 * (维护者走查发现的纵向变形根因),裁剪后 billboard 宽高比 = bbox 宽高比,无变形。 * * 全部产物经 manifest 语义 key 解析(resolveAsset),无散落物理路径。 */ import { resolveAsset } from '../domain/runtimeAssets.js' import { MASTER_GEOMETRY } from '../domain/markerPlan.js' // 票 82:描边宽与外扩 pad 来自单源几何模块(此前本地又声明一份 28/40)。 import { MASTER_SOURCE } from '../domain/markerMetrics.js' /** 母版 manifest key(实心/镂空,512×512 白剪影)。 */ const MASTER_KEYS = { solid: 'marker.master.solid', hollow: 'marker.master.hollow' } /** 内层行业图标 manifest key 前缀(key = `marker.inner.<语义名>`)。 */ const INNER_KEY_PREFIX = 'marker.inner.' /** 降饱和参数(旧规则保留给「彩色直用」场景;新规则走 recolorInner 去色染色)。 */ const DESATURATE = { saturate: 0.55, brightness: 0.72 } /** 内层染色:形状处「白 → 浅蓝 #9adcff」渐变(亮度反转提亮,见 recolorInner)。 */ const INNER_TINT = { light: [0x9a, 0xdc, 0xff], white: [255, 255, 255] } // 白描边宽度 / 裁剪窗 pad 一律引用单源 `MASTER_SOURCE`(票 82 消除的三处重复之一): // 屏幕可见 ≈1.5px → 源像素 ≈28;外扩 pad 40 ≥ 28,保证描边不被裁剪窗切掉(前两版的 bug)。 const imgCache = new Map() // url → Promise /** @private 加载一张图(跨环境:优先 createImageBitmap,回退 Image)。 */ function loadImage(url) { if (imgCache.has(url)) return imgCache.get(url) const p = (async () => { if (typeof createImageBitmap === 'function') { const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return createImageBitmap(await res.blob()) } return new Promise((resolve, reject) => { const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => resolve(img) img.onerror = () => reject(new Error(`image load failed: ${url}`)) img.src = url }) })() imgCache.set(url, p) p.catch(() => imgCache.delete(url)) return p } /** * 给白剪影母版着色:把母版 alpha 作为遮罩、状态色作为填充色,逐像素合成。 * 输出 canvas——白剪影的明度结构由「状态色 × 原 alpha」保留(母版是纯白平面剪影, * 规格 10 着色分界:色相 = 状态色,明度结构 = 母版形状)。 */ function tintMaster(bitmap, rgb) { const canvas = document.createElement('canvas') canvas.width = bitmap.width canvas.height = bitmap.height const ctx = canvas.getContext('2d') ctx.drawImage(bitmap, 0, 0) const data = ctx.getImageData(0, 0, bitmap.width, bitmap.height) const px = data.data for (let i = 0; i < px.length; i += 4) { const a = px[i + 3] if (a === 0) continue px[i] = rgb[0] px[i + 1] = rgb[1] px[i + 2] = rgb[2] // alpha 保持原样(剪影边缘抗锯齿不丢失) } ctx.putImageData(data, 0, 0) return canvas } /** * 内层图标去色重染(2026-10-12 维护者复核「完全看不清」后重订): * Pipe 图标是**深色实心**剪影,去色后亮度极低——按旧公式「亮部白暗部蓝」会把整个图标 * 染成深蓝,叠在深色母版上完全不可辨。 * 新规则:**形状处染亮色(白 → 浅蓝 #9adcff 渐变),亮度反转提亮**——形状是亮的、 * 周围透出状态色母版,对比度由「形状亮 vs 母版状态色」提供,不依赖内层自身深色。 * 输出与源图同尺寸的 canvas;有颜色语义的场合(未来扩展)由调用方换 tint 函数。 */ function recolorInner(bitmap) { const w = bitmap.width const h = bitmap.height const canvas = document.createElement('canvas') canvas.width = w canvas.height = h const ctx = canvas.getContext('2d') ctx.drawImage(bitmap, 0, 0) const data = ctx.getImageData(0, 0, w, h) const px = data.data for (let i = 0; i < px.length; i += 4) { const a = px[i + 3] if (a === 0) continue const lum = (px[i] * 0.299 + px[i + 1] * 0.587 + px[i + 2] * 0.114) / 255 // 形状处按原亮度映射到「白(最亮)→ 浅蓝 #9adcff(最暗)」:t 提亮 1.4 倍, // 深色剪影也能落进浅蓝段;整个形状保持「亮」,与状态色母版拉开对比。 const t = Math.min(1, Math.max(0, lum * 1.4)) px[i] = Math.round(0x9a + (0xff - 0x9a) * t) px[i + 1] = Math.round(0xdc + (0xff - 0xdc) * t) px[i + 2] = 0xff } ctx.putImageData(data, 0, 0) return canvas } /** * 给整枚符号加白色外描边(**形态学膨胀版**,走查第三轮重写)。 * * 前两版的两个 bug: * ① 描边画在与符号同尺寸的画布上、裁剪窗又从 bbox 起 → 向外扩的部分全被裁掉 * (「一半有一半没有、部分被切割」); * ② 缩放式膨胀在 512→27px 下描边不足 1 物理像素(「根本看不出来」)。 * * 本版:**画布四周带 pad**,对符号 alpha 逐像素求「到符号的距离」,距离 ≤ outlinePx * 的像素填白(不透明度随距离平滑衰减),符号再叠上层。描边是真正的外圈轮廓, * 平滑无锯齿,且**不会被裁剪窗切掉**(调用方裁剪窗含 pad)。 */ function withWhiteOutline(symbolCanvas, outlinePx) { const w = symbolCanvas.width const h = symbolCanvas.height const ctx = symbolCanvas.getContext('2d') const src = ctx.getImageData(0, 0, w, h) const sp = src.data // ① 二值 alpha 场(>16 视为符号内) const inside = new Uint8Array(w * h) for (let i = 0, p = 0; p < sp.length; i++, p += 4) inside[i] = sp[p + 3] > 16 ? 1 : 0 // ② 两趟扫描近似欧氏距离场(chamfer 3-4):dist[i] = 到符号的像素距离,符号内为 0 const INF = 1e9 const dist = new Float32Array(w * h) for (let i = 0; i < dist.length; i++) dist[i] = inside[i] ? 0 : INF // 前向扫描 for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x if (dist[i] === 0) continue let d = dist[i] if (x > 0) d = Math.min(d, dist[i - 1] + 3) if (y > 0) { d = Math.min(d, dist[i - w] + 3) if (x > 0) d = Math.min(d, dist[i - w - 1] + 4) if (x < w - 1) d = Math.min(d, dist[i - w + 1] + 4) } dist[i] = d } } // 反向扫描 for (let y = h - 1; y >= 0; y--) { for (let x = w - 1; x >= 0; x--) { const i = y * w + x if (dist[i] === 0) continue let d = dist[i] if (x < w - 1) d = Math.min(d, dist[i + 1] + 3) if (y < h - 1) { d = Math.min(d, dist[i + w] + 3) if (x < w - 1) d = Math.min(d, dist[i + w + 1] + 4) if (x > 0) d = Math.min(d, dist[i + w - 1] + 4) } dist[i] = d } } // ③ 描边层:dist ∈ (0, outlinePx] 填白。alpha 用 **S 曲线**(smoothstep): // 贴符号处最实、外沿平滑归零——比线性衰减更接近设计描边的观感(走查反馈「糊」)。 // 第三轮补:贴边 **1.5px 全实带**(alpha=255),再向外 S 曲线渐隐—— // 纯 S 曲线从贴边就开始半透明,缩到屏显后整体发虚(走查「糊糊的」的第二个来源)。 const outline = ctx.createImageData(w, h) const op = outline.data const solidBand = Math.min(outlinePx, 10) // 全实带(源像素),缩到屏显 ≈0.7px for (let i = 0, p = 0; p < op.length; i++, p += 4) { const d = dist[i] / 3 // chamfer 权重还原成像素距离 if (d > 0 && d <= outlinePx) { const t = 1 - d / outlinePx // 1=贴符号 0=外沿 const alpha = d <= solidBand ? 1 : (() => { const s = (1 - d / outlinePx) / (1 - solidBand / outlinePx) return s * s * (3 - 2 * s) // smoothstep })() op[p] = 255; op[p + 1] = 255; op[p + 2] = 255 op[p + 3] = Math.round(255 * (0.35 + 0.65 * alpha)) } } // 画在符号下层(先描边后符号) const out = document.createElement('canvas') out.width = w out.height = h const octx = out.getContext('2d') octx.putImageData(outline, 0, 0) octx.drawImage(symbolCanvas, 0, 0) return out } /** hex → [r,g,b];非法输入回白色(调用方保证 token 色值合法)。 */ function hexToRgb(hex) { const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim()) if (!m) return [255, 255, 255] const n = parseInt(m[1], 16) return [(n >> 16) & 255, (n >> 8) & 255, n & 255] } /** * 合成一枚位置符号(批次 3 主入口)。 * * @param {object} plan `markerPlan.planMarker()` 的输出 * @param {object} opts * @param {string} opts.tintHex 状态色(调用方从 tokens 读出的 CSS 颜色) * @returns {Promise} billboard 用的 data URL(PNG,已裁剪到 bbox) */ /** * 内层图标的**去色重染 data URL**(票 47 DOM overlay 用):与 billboard 合成同一 * `recolorInner` 规则,但**不下采样**——返回 256px 原生分辨率产物,DOM 侧按帽径 * 向下采样永远锐利(预下采样位图喂给 DOM 会在高 DPR 屏被放大发糊,这是"素材变糊"的根因)。 * 每图标一次合成、缓存复用。 */ const innerIconCache = new Map() // icon → Promise export function renderInnerIconUrl(icon) { if (innerIconCache.has(icon)) return innerIconCache.get(icon) const p = (async () => { const asset = resolveAsset(`${INNER_KEY_PREFIX}${icon}`) if (!asset.url) throw new Error(`内层未登记: ${icon}`) const img = await loadImage(asset.url) return recolorInner(img).toDataURL('image/png') })() innerIconCache.set(icon, p) p.catch(() => innerIconCache.delete(icon)) return p } /** 合成结果缓存:key = 母版/内层/染色/描边/尺寸 → dataURL。 * 同一批几百个标记里,状态+行业+色值组合只有几十种,逐个重跑 canvas 合成(含 createImageBitmap * 与两次全幅 canvas 绘制)是加载卡顿的明确热点。缓存后每个组合只合成一次。 */ const compositeCache = new Map() const COMPOSITE_CACHE_MAX = 256 /** 合成缓存键:把"会影响出图"的字段全拼进来(尺寸/母版/内层/着色/降饱和/描边)。 */ function compositeKey(plan, tintHex) { return [ plan.master, plan.inner.enabled ? plan.inner.icon : '', plan.inner.diameterRatio, plan.draw.width, plan.draw.height, plan.desaturate ? 1 : 0, tintHex || '', ].join('|') } export async function renderMarker(plan, { tintHex } = {}) { if (typeof document === 'undefined') throw new Error('renderMarker 仅在浏览器环境可用') // 同组合直接复用上一张图(同一 URL 字符串,Cesium billboard 不会重新上传)。 const cacheKey = compositeKey(plan, tintHex) const cached = compositeCache.get(cacheKey) if (cached) return cached const pending = renderMarkerUncached(plan, tintHex).then((dataUrl) => { if (compositeCache.size >= COMPOSITE_CACHE_MAX) compositeCache.clear() compositeCache.set(cacheKey, dataUrl) return dataUrl }) // 飞行/连点期间同键会有并发调用:先登记 promise,避免重复合成。 compositeCache.set(cacheKey, pending) pending.catch(() => compositeCache.delete(cacheKey)) return pending } async function renderMarkerUncached(plan, tintHex) { const masterKey = MASTER_KEYS[plan.master] const masterAsset = resolveAsset(masterKey) if (!masterAsset.url) throw new Error(`母版未登记: ${masterKey}`) const masterBitmap = await loadImage(masterAsset.url) const rgb = hexToRgb(tintHex) const bbox = MASTER_GEOMETRY.bbox // 全尺寸工作画布:先画状态色母版,再叠内层。 const full = document.createElement('canvas') full.width = masterBitmap.width full.height = masterBitmap.height const fctx = full.getContext('2d') fctx.drawImage(tintMaster(masterBitmap, rgb), 0, 0) // 内层行业图标:直径按 `plan.inner.diameterRatio` × 母版宽,圆心对齐帽心;**去色重染**。 if (plan.inner.enabled) { try { const recolored = await loadImage(await renderInnerIconUrl(plan.inner.icon)) const diameter = Math.round(bbox.width * plan.inner.diameterRatio) const cx = bbox.x + bbox.width * plan.inner.center.x const cy = bbox.y + bbox.height * plan.inner.center.y fctx.drawImage(recolored, cx - diameter / 2, cy - diameter / 2, diameter, diameter) } catch { // 内层加载失败:母版照常上图(降级可见),不阻塞整个标记。 } } // 白描边(整枚符号外圈,形态学膨胀——见 withWhiteOutline 注释) const outlined = withWhiteOutline(full, MASTER_SOURCE.outlineWidth) // 裁剪窗 = bbox **四周外扩单源 pad**:把描边一起裁进来(前两版裁剪窗=bbox 本身, // 描边向外扩的部分全被切掉——「一半有一半没有、部分被切割」的根因)。 // 输出**直接缩到屏显尺寸**(`plan.draw`,本体 + 2×pad 屏显值):GPU 1:1 贴图不参与 // mipmap 缩小——Cesium 对 billboard 纹理默认 LINEAR_MIPMAP_LINEAR,源图远大于显示尺寸时 // 缩小走 mip 高层会发虚(走查「看着糊」的渲染根因);1:1 贴图最锐利。 const pad = MASTER_SOURCE.outlinePad const cropX = bbox.x - pad const cropY = bbox.y - pad const cropW = bbox.width + pad * 2 const cropH = bbox.height + pad * 2 const out = document.createElement('canvas') const drawW = plan.draw.width const drawH = plan.draw.height out.width = Math.round(drawW) out.height = Math.round(drawH) // 高分源 → 屏显尺寸的高质量下采样(imageSmoothingQuality high) const octx = out.getContext('2d') octx.imageSmoothingEnabled = true octx.imageSmoothingQuality = 'high' octx.drawImage(outlined, cropX, cropY, cropW, cropH, 0, 0, out.width, out.height) return out.toDataURL('image/png') } export function markerFallbackSvg(shape, color, size = 18) { const c = color || '#ffffff' const svg = `` + '' return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` }