markerAssets.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. /**
  2. * 位置符号素材合成(批次 3)——把 `markerPlan.js` 的合成计划烘焙成 billboard 图片。
  3. *
  4. * 双层水滴徽标:外层母版(纯白剪影 PNG,运行时按状态色着色)+ 内层行业图标。
  5. * 合成走**异步 HTML 合成**:`createImageBitmap` 逐层绘制到离屏 canvas,导出 data URL。
  6. *
  7. * 着色与风格规则(2026-10-12 维护者修订,并入《图标素材使用共识》):
  8. * · 外层母版:状态色整体着色(色相=tokens 语义色,明度结构=母版剪影);
  9. * · 内层图标:**一律去色**后按「帽下区域染主蓝渐变(蓝→白)、帽顶区域染白」——
  10. * 点位/设备标记已用颜色语义表达状态,内层再用彩色会与状态色混色融合导致不可辨;
  11. * 无颜色语义处用蓝/白渐变(实测观感最优);
  12. * · **白色描边**:整枚符号外圈 2px 白描边(替代早先的白色底板方案),深色底图上更立体;
  13. * · **输出裁剪到 bbox**:母版 512² 方图两侧全是透明,不裁剪塞进非方 billboard 会被拉伸
  14. * (维护者走查发现的纵向变形根因),裁剪后 billboard 宽高比 = bbox 宽高比,无变形。
  15. *
  16. * 全部产物经 manifest 语义 key 解析(resolveAsset),无散落物理路径。
  17. */
  18. import { resolveAsset } from '../domain/runtimeAssets.js'
  19. import { MASTER_GEOMETRY } from '../domain/markerPlan.js'
  20. // 票 82:描边宽与外扩 pad 来自单源几何模块(此前本地又声明一份 28/40)。
  21. import { MASTER_SOURCE } from '../domain/markerMetrics.js'
  22. /** 母版 manifest key(实心/镂空,512×512 白剪影)。 */
  23. const MASTER_KEYS = { solid: 'marker.master.solid', hollow: 'marker.master.hollow' }
  24. /** 内层行业图标 manifest key 前缀(key = `marker.inner.<语义名>`)。 */
  25. const INNER_KEY_PREFIX = 'marker.inner.'
  26. /** 降饱和参数(旧规则保留给「彩色直用」场景;新规则走 recolorInner 去色染色)。 */
  27. const DESATURATE = { saturate: 0.55, brightness: 0.72 }
  28. /** 内层染色:形状处「白 → 浅蓝 #9adcff」渐变(亮度反转提亮,见 recolorInner)。 */
  29. const INNER_TINT = { light: [0x9a, 0xdc, 0xff], white: [255, 255, 255] }
  30. // 白描边宽度 / 裁剪窗 pad 一律引用单源 `MASTER_SOURCE`(票 82 消除的三处重复之一):
  31. // 屏幕可见 ≈1.5px → 源像素 ≈28;外扩 pad 40 ≥ 28,保证描边不被裁剪窗切掉(前两版的 bug)。
  32. const imgCache = new Map() // url → Promise<ImageBitmap|HTMLImageElement>
  33. /** @private 加载一张图(跨环境:优先 createImageBitmap,回退 Image)。 */
  34. function loadImage(url) {
  35. if (imgCache.has(url)) return imgCache.get(url)
  36. const p = (async () => {
  37. if (typeof createImageBitmap === 'function') {
  38. const res = await fetch(url)
  39. if (!res.ok) throw new Error(`HTTP ${res.status}`)
  40. return createImageBitmap(await res.blob())
  41. }
  42. return new Promise((resolve, reject) => {
  43. const img = new Image()
  44. img.crossOrigin = 'anonymous'
  45. img.onload = () => resolve(img)
  46. img.onerror = () => reject(new Error(`image load failed: ${url}`))
  47. img.src = url
  48. })
  49. })()
  50. imgCache.set(url, p)
  51. p.catch(() => imgCache.delete(url))
  52. return p
  53. }
  54. /**
  55. * 给白剪影母版着色:把母版 alpha 作为遮罩、状态色作为填充色,逐像素合成。
  56. * 输出 canvas——白剪影的明度结构由「状态色 × 原 alpha」保留(母版是纯白平面剪影,
  57. * 规格 10 着色分界:色相 = 状态色,明度结构 = 母版形状)。
  58. */
  59. function tintMaster(bitmap, rgb) {
  60. const canvas = document.createElement('canvas')
  61. canvas.width = bitmap.width
  62. canvas.height = bitmap.height
  63. const ctx = canvas.getContext('2d')
  64. ctx.drawImage(bitmap, 0, 0)
  65. const data = ctx.getImageData(0, 0, bitmap.width, bitmap.height)
  66. const px = data.data
  67. for (let i = 0; i < px.length; i += 4) {
  68. const a = px[i + 3]
  69. if (a === 0) continue
  70. px[i] = rgb[0]
  71. px[i + 1] = rgb[1]
  72. px[i + 2] = rgb[2]
  73. // alpha 保持原样(剪影边缘抗锯齿不丢失)
  74. }
  75. ctx.putImageData(data, 0, 0)
  76. return canvas
  77. }
  78. /**
  79. * 内层图标去色重染(2026-10-12 维护者复核「完全看不清」后重订):
  80. * Pipe 图标是**深色实心**剪影,去色后亮度极低——按旧公式「亮部白暗部蓝」会把整个图标
  81. * 染成深蓝,叠在深色母版上完全不可辨。
  82. * 新规则:**形状处染亮色(白 → 浅蓝 #9adcff 渐变),亮度反转提亮**——形状是亮的、
  83. * 周围透出状态色母版,对比度由「形状亮 vs 母版状态色」提供,不依赖内层自身深色。
  84. * 输出与源图同尺寸的 canvas;有颜色语义的场合(未来扩展)由调用方换 tint 函数。
  85. */
  86. function recolorInner(bitmap) {
  87. const w = bitmap.width
  88. const h = bitmap.height
  89. const canvas = document.createElement('canvas')
  90. canvas.width = w
  91. canvas.height = h
  92. const ctx = canvas.getContext('2d')
  93. ctx.drawImage(bitmap, 0, 0)
  94. const data = ctx.getImageData(0, 0, w, h)
  95. const px = data.data
  96. for (let i = 0; i < px.length; i += 4) {
  97. const a = px[i + 3]
  98. if (a === 0) continue
  99. const lum = (px[i] * 0.299 + px[i + 1] * 0.587 + px[i + 2] * 0.114) / 255
  100. // 形状处按原亮度映射到「白(最亮)→ 浅蓝 #9adcff(最暗)」:t 提亮 1.4 倍,
  101. // 深色剪影也能落进浅蓝段;整个形状保持「亮」,与状态色母版拉开对比。
  102. const t = Math.min(1, Math.max(0, lum * 1.4))
  103. px[i] = Math.round(0x9a + (0xff - 0x9a) * t)
  104. px[i + 1] = Math.round(0xdc + (0xff - 0xdc) * t)
  105. px[i + 2] = 0xff
  106. }
  107. ctx.putImageData(data, 0, 0)
  108. return canvas
  109. }
  110. /**
  111. * 给整枚符号加白色外描边(**形态学膨胀版**,走查第三轮重写)。
  112. *
  113. * 前两版的两个 bug:
  114. * ① 描边画在与符号同尺寸的画布上、裁剪窗又从 bbox 起 → 向外扩的部分全被裁掉
  115. * (「一半有一半没有、部分被切割」);
  116. * ② 缩放式膨胀在 512→27px 下描边不足 1 物理像素(「根本看不出来」)。
  117. *
  118. * 本版:**画布四周带 pad**,对符号 alpha 逐像素求「到符号的距离」,距离 ≤ outlinePx
  119. * 的像素填白(不透明度随距离平滑衰减),符号再叠上层。描边是真正的外圈轮廓,
  120. * 平滑无锯齿,且**不会被裁剪窗切掉**(调用方裁剪窗含 pad)。
  121. */
  122. function withWhiteOutline(symbolCanvas, outlinePx) {
  123. const w = symbolCanvas.width
  124. const h = symbolCanvas.height
  125. const ctx = symbolCanvas.getContext('2d')
  126. const src = ctx.getImageData(0, 0, w, h)
  127. const sp = src.data
  128. // ① 二值 alpha 场(>16 视为符号内)
  129. const inside = new Uint8Array(w * h)
  130. for (let i = 0, p = 0; p < sp.length; i++, p += 4) inside[i] = sp[p + 3] > 16 ? 1 : 0
  131. // ② 两趟扫描近似欧氏距离场(chamfer 3-4):dist[i] = 到符号的像素距离,符号内为 0
  132. const INF = 1e9
  133. const dist = new Float32Array(w * h)
  134. for (let i = 0; i < dist.length; i++) dist[i] = inside[i] ? 0 : INF
  135. // 前向扫描
  136. for (let y = 0; y < h; y++) {
  137. for (let x = 0; x < w; x++) {
  138. const i = y * w + x
  139. if (dist[i] === 0) continue
  140. let d = dist[i]
  141. if (x > 0) d = Math.min(d, dist[i - 1] + 3)
  142. if (y > 0) {
  143. d = Math.min(d, dist[i - w] + 3)
  144. if (x > 0) d = Math.min(d, dist[i - w - 1] + 4)
  145. if (x < w - 1) d = Math.min(d, dist[i - w + 1] + 4)
  146. }
  147. dist[i] = d
  148. }
  149. }
  150. // 反向扫描
  151. for (let y = h - 1; y >= 0; y--) {
  152. for (let x = w - 1; x >= 0; x--) {
  153. const i = y * w + x
  154. if (dist[i] === 0) continue
  155. let d = dist[i]
  156. if (x < w - 1) d = Math.min(d, dist[i + 1] + 3)
  157. if (y < h - 1) {
  158. d = Math.min(d, dist[i + w] + 3)
  159. if (x < w - 1) d = Math.min(d, dist[i + w + 1] + 4)
  160. if (x > 0) d = Math.min(d, dist[i + w - 1] + 4)
  161. }
  162. dist[i] = d
  163. }
  164. }
  165. // ③ 描边层:dist ∈ (0, outlinePx] 填白。alpha 用 **S 曲线**(smoothstep):
  166. // 贴符号处最实、外沿平滑归零——比线性衰减更接近设计描边的观感(走查反馈「糊」)。
  167. // 第三轮补:贴边 **1.5px 全实带**(alpha=255),再向外 S 曲线渐隐——
  168. // 纯 S 曲线从贴边就开始半透明,缩到屏显后整体发虚(走查「糊糊的」的第二个来源)。
  169. const outline = ctx.createImageData(w, h)
  170. const op = outline.data
  171. const solidBand = Math.min(outlinePx, 10) // 全实带(源像素),缩到屏显 ≈0.7px
  172. for (let i = 0, p = 0; p < op.length; i++, p += 4) {
  173. const d = dist[i] / 3 // chamfer 权重还原成像素距离
  174. if (d > 0 && d <= outlinePx) {
  175. const t = 1 - d / outlinePx // 1=贴符号 0=外沿
  176. const alpha = d <= solidBand ? 1 : (() => {
  177. const s = (1 - d / outlinePx) / (1 - solidBand / outlinePx)
  178. return s * s * (3 - 2 * s) // smoothstep
  179. })()
  180. op[p] = 255; op[p + 1] = 255; op[p + 2] = 255
  181. op[p + 3] = Math.round(255 * (0.35 + 0.65 * alpha))
  182. }
  183. }
  184. // 画在符号下层(先描边后符号)
  185. const out = document.createElement('canvas')
  186. out.width = w
  187. out.height = h
  188. const octx = out.getContext('2d')
  189. octx.putImageData(outline, 0, 0)
  190. octx.drawImage(symbolCanvas, 0, 0)
  191. return out
  192. }
  193. /** hex → [r,g,b];非法输入回白色(调用方保证 token 色值合法)。 */
  194. function hexToRgb(hex) {
  195. const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim())
  196. if (!m) return [255, 255, 255]
  197. const n = parseInt(m[1], 16)
  198. return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
  199. }
  200. /**
  201. * 合成一枚位置符号(批次 3 主入口)。
  202. *
  203. * @param {object} plan `markerPlan.planMarker()` 的输出
  204. * @param {object} opts
  205. * @param {string} opts.tintHex 状态色(调用方从 tokens 读出的 CSS 颜色)
  206. * @returns {Promise<string>} billboard 用的 data URL(PNG,已裁剪到 bbox)
  207. */
  208. /**
  209. * 内层图标的**去色重染 data URL**(票 47 DOM overlay 用):与 billboard 合成同一
  210. * `recolorInner` 规则,但**不下采样**——返回 256px 原生分辨率产物,DOM 侧按帽径
  211. * 向下采样永远锐利(预下采样位图喂给 DOM 会在高 DPR 屏被放大发糊,这是"素材变糊"的根因)。
  212. * 每图标一次合成、缓存复用。
  213. */
  214. const innerIconCache = new Map() // icon → Promise<dataURL>
  215. export function renderInnerIconUrl(icon) {
  216. if (innerIconCache.has(icon)) return innerIconCache.get(icon)
  217. const p = (async () => {
  218. const asset = resolveAsset(`${INNER_KEY_PREFIX}${icon}`)
  219. if (!asset.url) throw new Error(`内层未登记: ${icon}`)
  220. const img = await loadImage(asset.url)
  221. return recolorInner(img).toDataURL('image/png')
  222. })()
  223. innerIconCache.set(icon, p)
  224. p.catch(() => innerIconCache.delete(icon))
  225. return p
  226. }
  227. /** 合成结果缓存:key = 母版/内层/染色/描边/尺寸 → dataURL。
  228. * 同一批几百个标记里,状态+行业+色值组合只有几十种,逐个重跑 canvas 合成(含 createImageBitmap
  229. * 与两次全幅 canvas 绘制)是加载卡顿的明确热点。缓存后每个组合只合成一次。 */
  230. const compositeCache = new Map()
  231. const COMPOSITE_CACHE_MAX = 256
  232. /** 合成缓存键:把"会影响出图"的字段全拼进来(尺寸/母版/内层/着色/降饱和/描边)。 */
  233. function compositeKey(plan, tintHex) {
  234. return [
  235. plan.master, plan.inner.enabled ? plan.inner.icon : '', plan.inner.diameterRatio,
  236. plan.draw.width, plan.draw.height, plan.desaturate ? 1 : 0, tintHex || '',
  237. ].join('|')
  238. }
  239. export async function renderMarker(plan, { tintHex } = {}) {
  240. if (typeof document === 'undefined') throw new Error('renderMarker 仅在浏览器环境可用')
  241. // 同组合直接复用上一张图(同一 URL 字符串,Cesium billboard 不会重新上传)。
  242. const cacheKey = compositeKey(plan, tintHex)
  243. const cached = compositeCache.get(cacheKey)
  244. if (cached) return cached
  245. const pending = renderMarkerUncached(plan, tintHex).then((dataUrl) => {
  246. if (compositeCache.size >= COMPOSITE_CACHE_MAX) compositeCache.clear()
  247. compositeCache.set(cacheKey, dataUrl)
  248. return dataUrl
  249. })
  250. // 飞行/连点期间同键会有并发调用:先登记 promise,避免重复合成。
  251. compositeCache.set(cacheKey, pending)
  252. pending.catch(() => compositeCache.delete(cacheKey))
  253. return pending
  254. }
  255. async function renderMarkerUncached(plan, tintHex) {
  256. const masterKey = MASTER_KEYS[plan.master]
  257. const masterAsset = resolveAsset(masterKey)
  258. if (!masterAsset.url) throw new Error(`母版未登记: ${masterKey}`)
  259. const masterBitmap = await loadImage(masterAsset.url)
  260. const rgb = hexToRgb(tintHex)
  261. const bbox = MASTER_GEOMETRY.bbox
  262. // 全尺寸工作画布:先画状态色母版,再叠内层。
  263. const full = document.createElement('canvas')
  264. full.width = masterBitmap.width
  265. full.height = masterBitmap.height
  266. const fctx = full.getContext('2d')
  267. fctx.drawImage(tintMaster(masterBitmap, rgb), 0, 0)
  268. // 内层行业图标:直径按 `plan.inner.diameterRatio` × 母版宽,圆心对齐帽心;**去色重染**。
  269. if (plan.inner.enabled) {
  270. try {
  271. const recolored = await loadImage(await renderInnerIconUrl(plan.inner.icon))
  272. const diameter = Math.round(bbox.width * plan.inner.diameterRatio)
  273. const cx = bbox.x + bbox.width * plan.inner.center.x
  274. const cy = bbox.y + bbox.height * plan.inner.center.y
  275. fctx.drawImage(recolored, cx - diameter / 2, cy - diameter / 2, diameter, diameter)
  276. } catch {
  277. // 内层加载失败:母版照常上图(降级可见),不阻塞整个标记。
  278. }
  279. }
  280. // 白描边(整枚符号外圈,形态学膨胀——见 withWhiteOutline 注释)
  281. const outlined = withWhiteOutline(full, MASTER_SOURCE.outlineWidth)
  282. // 裁剪窗 = bbox **四周外扩单源 pad**:把描边一起裁进来(前两版裁剪窗=bbox 本身,
  283. // 描边向外扩的部分全被切掉——「一半有一半没有、部分被切割」的根因)。
  284. // 输出**直接缩到屏显尺寸**(`plan.draw`,本体 + 2×pad 屏显值):GPU 1:1 贴图不参与
  285. // mipmap 缩小——Cesium 对 billboard 纹理默认 LINEAR_MIPMAP_LINEAR,源图远大于显示尺寸时
  286. // 缩小走 mip 高层会发虚(走查「看着糊」的渲染根因);1:1 贴图最锐利。
  287. const pad = MASTER_SOURCE.outlinePad
  288. const cropX = bbox.x - pad
  289. const cropY = bbox.y - pad
  290. const cropW = bbox.width + pad * 2
  291. const cropH = bbox.height + pad * 2
  292. const out = document.createElement('canvas')
  293. const drawW = plan.draw.width
  294. const drawH = plan.draw.height
  295. out.width = Math.round(drawW)
  296. out.height = Math.round(drawH)
  297. // 高分源 → 屏显尺寸的高质量下采样(imageSmoothingQuality high)
  298. const octx = out.getContext('2d')
  299. octx.imageSmoothingEnabled = true
  300. octx.imageSmoothingQuality = 'high'
  301. octx.drawImage(outlined, cropX, cropY, cropW, cropH, 0, 0, out.width, out.height)
  302. return out.toDataURL('image/png')
  303. }
  304. export function markerFallbackSvg(shape, color, size = 18) {
  305. const c = color || '#ffffff'
  306. const svg =
  307. `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14" width="${size}" height="${Math.round(size * 1.257)}" ` +
  308. `fill="currentColor" color="${c}">` +
  309. '<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>'
  310. return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
  311. }