| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { FriendLite } from '../home/types' |
| | | |
| | | import { loadImage } from './image' |
| | | import { getAvatarBgColor, getAvatarText } from './user' |
| | | |
| | | /** é»è®¤ç¾¤åçæï¼æé好åå 4 个ååæ¼æ¥ï¼è¶
è¿è¡¥ãç N 人ãï¼ä¸ºç©ºå
åºã群èã */ |
| | | export function buildDefaultGroupName(members: FriendLite[]): string { |
| | | if (members.length === 0) { |
| | | return '群è' |
| | | } |
| | | const names = members.slice(0, 4).map((m) => m.displayName || m.nickname || '') |
| | | const head = names.filter(Boolean).join('ã') |
| | | if (members.length > 4) { |
| | | // members åªå«è¢«é好åï¼+1 æå建è
ä¹è®¡å
¥å®é
æåæ° |
| | | return `${head}ç${members.length + 1}人` |
| | | } |
| | | return head || '群è' |
| | | } |
| | | |
| | | /** 群头ååæ ¼çè¾å
¥ï¼å¤´å URL + åå ï¼è³å°ç»ä¸ä¸ªï¼äºè
é½ç¼ºæ¶èµ°ç°åºç©ºæ ¼ */ |
| | | export interface GroupAvatarMember { |
| | | /** 头å URLï¼ç¼ºå¤±æå è½½å¤±è´¥æ¶æ name ç»è²å¡ */ |
| | | avatar?: string |
| | | /** æ¾ç¤ºåï¼æµç§° / 夿³¨ï¼ï¼è²å¡æå + åºè² hash æ¥æº */ |
| | | name?: string |
| | | } |
| | | |
| | | /** ç¾¤å¤´åæ¼æ¥çå¯éåæ° */ |
| | | export interface BuildGroupAvatarOptions { |
| | | /** è¾åºç»å¸è¾¹é¿ï¼åç´ ï¼ï¼é»è®¤ 64 */ |
| | | targetSize?: number |
| | | /** åæ ¼ä¹é´çé´éï¼åç´ ï¼ï¼é»è®¤ 1 */ |
| | | divider?: number |
| | | /** ç»å¸åºè²ï¼é»è®¤éæï¼è®©ä¸ä¸çç½éåºå®¿ä¸»å®¹å¨åºè² */ |
| | | background?: string |
| | | } |
| | | |
| | | /** åæ ¼å¨ç»å¸ä¸çä½ç½® + 尺寸 */ |
| | | interface CellRect { |
| | | x: number |
| | | y: number |
| | | w: number |
| | | h: number |
| | | } |
| | | |
| | | /** åæ ¼çæç»ç»å¶å
容 */ |
| | | type Cell = |
| | | | { bg: string; kind: 'color'; text: string; } |
| | | | { img: HTMLImageElement; kind: 'image'; } |
| | | |
| | | /** |
| | | * æç¾¤æå头忼æä¸å¼ æ¹å½¢ç¾¤å¤´å dataURLï¼æ 1 ~ 9 å¼ èµ°ä¹å®«æ ¼åä½å¸å± |
| | | * |
| | | * - ä»
åå 9 个æå |
| | | * - åæ ¼ avatar 为空 / å è½½å¤±è´¥ï¼æ name å¨ canvas ä¸ç»è²å¡å
åº |
| | | * - è·¨åè¦æ±å¾çæºå¼å¯ CORSï¼img.crossOrigin = 'anonymous'ï¼ |
| | | */ |
| | | export async function buildGroupAvatar( |
| | | members: GroupAvatarMember[], |
| | | options: BuildGroupAvatarOptions = {} |
| | | ): Promise<string> { |
| | | const targetSize = options.targetSize ?? 64 |
| | | const divider = options.divider ?? 1 |
| | | const background = options.background |
| | | |
| | | const top = members.slice(0, 9) |
| | | if (top.length === 0) { |
| | | return '' |
| | | } |
| | | |
| | | const cells = await Promise.all(top.map((m) => resolveCell(m))) |
| | | const rects = computeCellRects(cells.length, targetSize, divider) |
| | | |
| | | const canvas = document.createElement('canvas') |
| | | canvas.width = targetSize |
| | | canvas.height = targetSize |
| | | const ctx = canvas.getContext('2d') |
| | | if (!ctx) { |
| | | return '' |
| | | } |
| | | if (background) { |
| | | ctx.fillStyle = background |
| | | ctx.fillRect(0, 0, targetSize, targetSize) |
| | | } |
| | | cells.forEach((cell, idx) => { |
| | | const rect = rects[idx] |
| | | if (!rect) { |
| | | return |
| | | } |
| | | drawCell(ctx, rect, cell) |
| | | }) |
| | | return canvas.toDataURL('image/png') |
| | | } |
| | | |
| | | /** 群头å dataURL ç¼åä¸éï¼æ¯æ¡çº¦ 5 ~ 30KBï¼200 æ¡è½¯å°é¡¶çº¦ 5MB 常驻 */ |
| | | const MERGED_AVATAR_CACHE_MAX = 200 |
| | | const mergedAvatarCache = new Map<string, string>() |
| | | |
| | | /** å群头å dataURL ç¼åï¼å½ä¸æ¶æ LRU æå°æ«å°¾ */ |
| | | export function getCachedGroupAvatar(key: string): string | undefined { |
| | | const cached = mergedAvatarCache.get(key) |
| | | if (cached === undefined) { |
| | | return undefined |
| | | } |
| | | mergedAvatarCache.delete(key) |
| | | mergedAvatarCache.set(key, cached) |
| | | return cached |
| | | } |
| | | |
| | | /** å群头å dataURL ç¼åï¼è¶
ä¸éæ¶ä¸¢å¼ææ©ä¸æ¡ï¼Map è¿ä»£é¡ºåº = æå
¥é¡ºåºï¼é
å get çæåå³ LRUï¼ */ |
| | | export function setCachedGroupAvatar(key: string, value: string): void { |
| | | if (mergedAvatarCache.has(key)) { |
| | | mergedAvatarCache.delete(key) |
| | | } else if (mergedAvatarCache.size >= MERGED_AVATAR_CACHE_MAX) { |
| | | const oldest = mergedAvatarCache.keys().next().value |
| | | if (oldest !== undefined) { |
| | | mergedAvatarCache.delete(oldest) |
| | | } |
| | | } |
| | | mergedAvatarCache.set(key, value) |
| | | } |
| | | |
| | | /** åæå â Cellï¼æ avatar ä¸å è½½æåèµ° imageï¼å¦åèµ° color */ |
| | | async function resolveCell(member: GroupAvatarMember): Promise<Cell> { |
| | | if (member.avatar) { |
| | | const img = await loadImage(member.avatar) |
| | | if (img) { |
| | | return { kind: 'image', img } |
| | | } |
| | | } |
| | | return { |
| | | kind: 'color', |
| | | text: getAvatarText(member.name), |
| | | bg: getAvatarBgColor(member.name) |
| | | } |
| | | } |
| | | |
| | | /** æå个 Cell ç»å° ctx 䏿宿 ¼åéï¼image èµ° cover è£åªä¿æ¯ä¾ï¼color èµ° fillRect + å±
䏿å */ |
| | | function drawCell( |
| | | ctx: CanvasRenderingContext2D, |
| | | rect: CellRect, |
| | | cell: Cell |
| | | ): void { |
| | | const { x, y, w, h } = rect |
| | | if (cell.kind === 'image') { |
| | | drawImageCover(ctx, cell.img, x, y, w, h) |
| | | return |
| | | } |
| | | ctx.fillStyle = cell.bg |
| | | ctx.fillRect(x, y, w, h) |
| | | if (!cell.text) { |
| | | return |
| | | } |
| | | // åå·æçè¾¹ç®ï¼åå 0.42ãåå 0.34 |
| | | const baseSize = Math.min(w, h) |
| | | const fontSize = Math.floor(baseSize * (cell.text.length > 1 ? 0.34 : 0.42)) |
| | | ctx.fillStyle = '#FFFFFF' |
| | | ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif` |
| | | ctx.textAlign = 'center' |
| | | ctx.textBaseline = 'middle' |
| | | ctx.fillText(cell.text, x + w / 2, y + h / 2) |
| | | } |
| | | |
| | | /** cover è£åªï¼ä»æºå¾ä¸å¿è£åºä¸ç®æ ç©å½¢åæ¯ä¾çååºåï¼ç»å°ç®æ ç©å½¢ï¼ä¿ææ¯ä¾ä¸åå½¢ï¼ */ |
| | | function drawImageCover( |
| | | ctx: CanvasRenderingContext2D, |
| | | img: HTMLImageElement, |
| | | dx: number, |
| | | dy: number, |
| | | dw: number, |
| | | dh: number |
| | | ): void { |
| | | const srcAspect = img.width / img.height |
| | | const dstAspect = dw / dh |
| | | let sx = 0 |
| | | let sy = 0 |
| | | let sw = img.width |
| | | let sh = img.height |
| | | if (srcAspect > dstAspect) { |
| | | sw = sh * dstAspect |
| | | sx = (img.width - sw) / 2 |
| | | } else if (srcAspect < dstAspect) { |
| | | sh = sw / dstAspect |
| | | sy = (img.height - sh) / 2 |
| | | } |
| | | ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) |
| | | } |
| | | |
| | | /** |
| | | * æå¼ æ°è®¡ç®æ¯æ ¼çä½ç½® + 尺寸 |
| | | * |
| | | * - 1 å¼ ï¼å 满ç»å¸ |
| | | * - 2~4 å¼ ï¼2 å 2 è¡æ£æ¹å½¢åæ ¼ |
| | | * - 5~6 å¼ ï¼3 å 2 è¡æ£æ¹å½¢åæ ¼ + åç´å±
ä¸ |
| | | * - 7~9 å¼ ï¼3 å 3 è¡æ£æ¹å½¢åæ ¼ |
| | | */ |
| | | function computeCellRects(count: number, target: number, divider: number): CellRect[] { |
| | | if (count <= 0) { |
| | | return [] |
| | | } |
| | | if (count === 1) { |
| | | return [{ x: 0, y: 0, w: target, h: target }] |
| | | } |
| | | if (count <= 4) { |
| | | return computeCellRectsSmall(count, target, divider) |
| | | } |
| | | if (count <= 6) { |
| | | return computeCellRectsMedium(count, target, divider) |
| | | } |
| | | return computeCellRectsLarge(count, target, divider) |
| | | } |
| | | |
| | | /** 2~4 å¼ ï¼2 å 2 è¡æ£æ¹å½¢ */ |
| | | function computeCellRectsSmall(count: number, target: number, divider: number): CellRect[] { |
| | | const s = (target - 3 * divider) / 2 |
| | | const step = s + divider |
| | | const rects: CellRect[] = [] |
| | | switch (count) { |
| | | case 2: { |
| | | // å±
ä¸ 1 è¡ï¼çä¸ä¸ç©ºç½ï¼ |
| | | const y = target / 4 |
| | | rects.push({ x: divider, y, w: s, h: s }, { x: divider + step, y, w: s, h: s }) |
| | | break |
| | | } |
| | | case 3: { |
| | | // ä¸ 1 å±
ä¸ + ä¸ 2 |
| | | rects.push({ x: target / 4, y: divider, w: s, h: s }) |
| | | const y1 = divider + step |
| | | rects.push({ x: divider, y: y1, w: s, h: s }, { x: divider + step, y: y1, w: s, h: s }) |
| | | break |
| | | } |
| | | case 4: { |
| | | // 2Ã2 æ»¡éº |
| | | rects.push({ x: divider, y: divider, w: s, h: s }, { x: divider + step, y: divider, w: s, h: s }) |
| | | const y1 = divider + step |
| | | rects.push({ x: divider, y: y1, w: s, h: s }, { x: divider + step, y: y1, w: s, h: s }) |
| | | break |
| | | } |
| | | } |
| | | return rects |
| | | } |
| | | |
| | | /** 5~6 å¼ ï¼3 å 2 è¡æ£æ¹å½¢åæ ¼ + åç´å±
ä¸ï¼ä¸ä¸çç½ç±éæèæ¯éåº GroupAvatar 容å¨åºè² */ |
| | | function computeCellRectsMedium(count: number, target: number, divider: number): CellRect[] { |
| | | const s = (target - 4 * divider) / 3 |
| | | const step = s + divider |
| | | const xs = [divider, divider + step, divider + 2 * step] as const |
| | | // 2 è¡é« + 1 è¡é´ div |
| | | const totalH = 2 * s + divider |
| | | const y0 = (target - totalH) / 2 |
| | | const y1 = y0 + step |
| | | const rects: CellRect[] = [] |
| | | if (count === 5) { |
| | | // ä¸ 2 å±
ä¸ï¼å·¦å³å¯¹ç§°çç½ï¼ + ä¸ 3 å·¦å³è´´è¾¹ |
| | | const upX0 = (target - 2 * s - divider) / 2 |
| | | rects.push({ x: upX0, y: y0, w: s, h: s }, { x: upX0 + step, y: y0, w: s, h: s }, { x: xs[0], y: y1, w: s, h: s }, { x: xs[1], y: y1, w: s, h: s }, { x: xs[2], y: y1, w: s, h: s }) |
| | | return rects |
| | | } |
| | | // count === 6ï¼ä¸ 3 + ä¸ 3 æ»¡éº |
| | | rects.push({ x: xs[0], y: y0, w: s, h: s }, { x: xs[1], y: y0, w: s, h: s }, { x: xs[2], y: y0, w: s, h: s }, { x: xs[0], y: y1, w: s, h: s }, { x: xs[1], y: y1, w: s, h: s }, { x: xs[2], y: y1, w: s, h: s }) |
| | | return rects |
| | | } |
| | | |
| | | /** 7~9 å¼ ï¼3 å 3 è¡æ£æ¹å½¢åæ ¼ */ |
| | | function computeCellRectsLarge(count: number, target: number, divider: number): CellRect[] { |
| | | const s = (target - 4 * divider) / 3 |
| | | const step = s + divider |
| | | const xs = [divider, divider + step, divider + 2 * step] as const |
| | | const ys = [divider, divider + step, divider + 2 * step] as const |
| | | const rects: CellRect[] = [] |
| | | if (count === 7) { |
| | | // ä¸ 1 å±
ä¸ + ä¸ 3 + ä¸ 3 |
| | | rects.push({ x: xs[1], y: ys[0], w: s, h: s }, { x: xs[0], y: ys[1], w: s, h: s }, { x: xs[1], y: ys[1], w: s, h: s }, { x: xs[2], y: ys[1], w: s, h: s }, { x: xs[0], y: ys[2], w: s, h: s }, { x: xs[1], y: ys[2], w: s, h: s }, { x: xs[2], y: ys[2], w: s, h: s }) |
| | | return rects |
| | | } |
| | | if (count === 8) { |
| | | // ä¸ 2 å±
ä¸ + ä¸ 3 + ä¸ 3 |
| | | const upX0 = (target - 2 * s - divider) / 2 |
| | | rects.push({ x: upX0, y: ys[0], w: s, h: s }, { x: upX0 + step, y: ys[0], w: s, h: s }, { x: xs[0], y: ys[1], w: s, h: s }, { x: xs[1], y: ys[1], w: s, h: s }, { x: xs[2], y: ys[1], w: s, h: s }, { x: xs[0], y: ys[2], w: s, h: s }, { x: xs[1], y: ys[2], w: s, h: s }, { x: xs[2], y: ys[2], w: s, h: s }) |
| | | return rects |
| | | } |
| | | // count === 9ï¼3Ã3 æ»¡éº |
| | | for (const y of ys) { |
| | | for (const x of xs) { |
| | | rects.push({ x, y, w: s, h: s }) |
| | | } |
| | | } |
| | | return rects |
| | | } |