| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- /**
- * 位置符号素材合成(批次 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<ImageBitmap|HTMLImageElement>
- /** @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<string>} billboard 用的 data URL(PNG,已裁剪到 bbox)
- */
- /**
- * 内层图标的**去色重染 data URL**(票 47 DOM overlay 用):与 billboard 合成同一
- * `recolorInner` 规则,但**不下采样**——返回 256px 原生分辨率产物,DOM 侧按帽径
- * 向下采样永远锐利(预下采样位图喂给 DOM 会在高 DPR 屏被放大发糊,这是"素材变糊"的根因)。
- * 每图标一次合成、缓存复用。
- */
- const innerIconCache = new Map() // icon → Promise<dataURL>
- 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 =
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14" width="${size}" height="${Math.round(size * 1.257)}" ` +
- `fill="currentColor" color="${c}">` +
- '<path d="M6 0C2.7 0 0 2.7 0 6c0 4.5 6 8 6 8s6-3.5 6-8c0-3.3-2.7-6-6-6Z"/></svg>'
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
- }
|