import { clamp, smooth, backOut, hash } from './math' export const FONT = '"PingFang SC","Noto Sans SC","Source Han Sans SC","Microsoft YaHei","Hiragino Sans GB",sans-serif' type C = CanvasRenderingContext2D export const COL = { cyan: '#5fd4ff', teal: '#2fe0c0', red: '#ff3b3b', yellow: '#ffd23f', white: '#ffffff', green: '#3ee08f', } function rr(ctx: C, x: number, y: number, w: number, h: number, r: number) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r) ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath() } /* ---------------- subtitle ---------------- */ export function subtitle(ctx: C, text: string, alpha: number, y = 1555) { if (alpha <= 0.01 || !text) return ctx.save() ctx.globalAlpha = alpha ctx.font = `800 58px ${FONT}` ctx.textAlign = 'center'; ctx.textBaseline = 'middle' const lines = wrap(ctx, text, 940) const lh = 80 lines.forEach((l, i) => { const yy = y + (i - (lines.length - 1) / 2) * lh + (1 - alpha) * 10 ctx.lineJoin = 'round'; ctx.lineWidth = 12; ctx.strokeStyle = 'rgba(0,8,20,0.85)'; ctx.strokeText(l, 540, yy) ctx.fillStyle = '#ffffff'; ctx.fillText(l, 540, yy) }) ctx.restore() } export function wrap(ctx: C, text: string, maxW: number) { if (ctx.measureText(text).width <= maxW) return [text] // split at punctuation near the middle when possible const chars = [...text]; const lines: string[] = []; let cur = '' for (const ch of chars) { if (ctx.measureText(cur + ch).width > maxW) { lines.push(cur); cur = ch } else cur += ch } if (cur) lines.push(cur) if (lines.length === 2) { const mid = Math.floor(chars.length / 2) let best = -1 for (let d = 0; d < 6; d++) for (const k of [mid + d, mid - d]) if (best < 0 && ',、:;'.includes(chars[k] ?? '')) best = k if (best > 0) return [chars.slice(0, best + 1).join(''), chars.slice(best + 1).join('')] } return lines } /* ---------------- chips / keywords ---------------- */ export interface ChipOpt { alpha?: number; scale?: number; color?: string; size?: number; icon?: 'check' | 'cross' | 'warn' | 'dot' | 'num'; num?: number; fill?: boolean; strike?: number } export function chip(ctx: C, x: number, y: number, text: string, o: ChipOpt = {}) { const a = o.alpha ?? 1; if (a <= 0.01) return const size = o.size ?? 46, color = o.color ?? COL.cyan ctx.save() ctx.globalAlpha = a ctx.translate(x, y); const sc = o.scale ?? 1; ctx.scale(sc, sc) ctx.font = `800 ${size}px ${FONT}` const tw = ctx.measureText(text).width const iconW = o.icon ? size * 1.05 : 0 const w = tw + iconW + size * 1.1, h = size * 1.7 ctx.shadowColor = color; ctx.shadowBlur = 24 rr(ctx, -w / 2, -h / 2, w, h, h / 2) ctx.fillStyle = o.fill ? color : 'rgba(6,18,36,0.78)'; ctx.fill() ctx.shadowBlur = 0 ctx.lineWidth = 3; ctx.strokeStyle = color; ctx.stroke() const tx = -w / 2 + size * 0.55 + iconW if (o.icon) icon(ctx, o.icon, -w / 2 + size * 0.55 + size * 0.42, 0, size * 0.8, o.fill ? '#061224' : color, o.num) ctx.fillStyle = o.fill ? '#061224' : '#fff'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left' ctx.fillText(text, tx, 2) if (o.strike) { ctx.strokeStyle = COL.red; ctx.lineWidth = 7; ctx.lineCap = 'round' ctx.beginPath(); ctx.moveTo(-w / 2 + 10, 0); ctx.lineTo(-w / 2 + 10 + (w - 20) * clamp(o.strike), 0); ctx.stroke() } ctx.restore() } export function icon(ctx: C, kind: string, x: number, y: number, s: number, color: string, num?: number) { ctx.save(); ctx.translate(x, y); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = s * 0.13; ctx.lineCap = 'round'; ctx.lineJoin = 'round' if (kind === 'check') { ctx.beginPath(); ctx.moveTo(-s * 0.35, 0); ctx.lineTo(-s * 0.08, s * 0.28); ctx.lineTo(s * 0.4, -s * 0.3); ctx.stroke() } else if (kind === 'cross') { ctx.beginPath(); ctx.moveTo(-s * 0.3, -s * 0.3); ctx.lineTo(s * 0.3, s * 0.3); ctx.moveTo(s * 0.3, -s * 0.3); ctx.lineTo(-s * 0.3, s * 0.3); ctx.stroke() } else if (kind === 'warn') { ctx.beginPath(); ctx.moveTo(0, -s * 0.42); ctx.lineTo(s * 0.45, s * 0.38); ctx.lineTo(-s * 0.45, s * 0.38); ctx.closePath(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, -s * 0.1); ctx.lineTo(0, s * 0.12); ctx.stroke(); ctx.beginPath(); ctx.arc(0, s * 0.25, s * 0.04, 0, 7); ctx.fill() } else if (kind === 'dot') { ctx.beginPath(); ctx.arc(0, 0, s * 0.22, 0, 7); ctx.fill() } else if (kind === 'num') { ctx.beginPath(); ctx.arc(0, 0, s * 0.45, 0, 7); ctx.fill(); ctx.fillStyle = '#061224'; ctx.font = `900 ${s * 0.6}px ${FONT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(num ?? 1), 0, s * 0.03) } ctx.restore() } /** big centered keyword with pop-in */ export function bigWord(ctx: C, text: string, x: number, y: number, p: number, color = COL.red, size = 92) { if (p <= 0.01) return ctx.save() const sc = 0.6 + 0.4 * backOut(Math.min(1, p * 1.6)) ctx.globalAlpha = clamp(p * 2) ctx.translate(x, y); ctx.scale(sc, sc) ctx.font = `900 ${size}px ${FONT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle' ctx.shadowColor = color; ctx.shadowBlur = 40 ctx.lineWidth = 14; ctx.strokeStyle = 'rgba(0,6,16,0.9)'; ctx.lineJoin = 'round'; ctx.strokeText(text, 0, 0) ctx.fillStyle = color; ctx.fillText(text, 0, 0) ctx.shadowBlur = 0; ctx.fillStyle = '#fff'; ctx.globalAlpha *= 0.25; ctx.fillText(text, 0, -3) ctx.restore() } /* ---------------- labels with leader lines ---------------- */ export function label(ctx: C, ax: number, ay: number, tx: number, ty: number, text: string, alpha: number, color = COL.cyan, size = 40) { if (alpha <= 0.01) return ctx.save(); ctx.globalAlpha = alpha const p = smooth(alpha) const ex = ax + (tx - ax) * p, ey = ay + (ty - ay) * p ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.shadowColor = color; ctx.shadowBlur = 10 ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(ex, ey); ctx.stroke() ctx.fillStyle = color; ctx.beginPath(); ctx.arc(ax, ay, 8, 0, 7); ctx.fill() ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(ax, ay, 14 + Math.sin(alpha * 6) * 2, 0, 7); ctx.stroke() ctx.shadowBlur = 0 ctx.font = `800 ${size}px ${FONT}` const w = ctx.measureText(text).width + 36, h = size * 1.5 const left = tx < ax const bx = left ? ex - w : ex rr(ctx, bx, ey - h / 2, w, h, 12); ctx.fillStyle = 'rgba(4,14,30,0.85)'; ctx.fill(); ctx.strokeStyle = color; ctx.lineWidth = 2.5; ctx.stroke() ctx.fillStyle = '#fff'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(text, bx + 18, ey + 2) ctx.restore() } /* ---------------- reticle ---------------- */ export function reticle(ctx: C, x: number, y: number, r: number, alpha: number, t: number, color = COL.red, lock = 1) { if (alpha <= 0.01) return ctx.save(); ctx.globalAlpha = alpha; ctx.translate(x, y) const R = r * (1 + (1 - lock) * 1.4) ctx.rotate((1 - lock) * 1.2 + t * 0.3) ctx.strokeStyle = color; ctx.lineWidth = 5; ctx.shadowColor = color; ctx.shadowBlur = 16 for (let i = 0; i < 4; i++) { ctx.rotate(Math.PI / 2) ctx.beginPath(); ctx.moveTo(R, R * 0.45); ctx.lineTo(R, R); ctx.lineTo(R * 0.45, R); ctx.stroke() } ctx.rotate(-t * 0.3 - (1 - lock) * 1.2) ctx.lineWidth = 2; ctx.setLineDash([8, 10]); ctx.beginPath(); ctx.arc(0, 0, R * 0.72, t, t + Math.PI * 2); ctx.stroke(); ctx.setLineDash([]) ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(-14, 0); ctx.lineTo(14, 0); ctx.moveTo(0, -14); ctx.lineTo(0, 14); ctx.stroke() ctx.restore() } /* ---------------- pain burst ---------------- */ export function painBurst(ctx: C, x: number, y: number, p: number, t: number) { if (p <= 0.01) return ctx.save(); ctx.translate(x, y); ctx.globalCompositeOperation = 'lighter' for (let k = 0; k < 3; k++) { const ph = ((t * 1.3 + k / 3) % 1) ctx.strokeStyle = `rgba(255,60,50,${(1 - ph) * 0.6 * p})`; ctx.lineWidth = 6 * (1 - ph) + 1 ctx.beginPath(); ctx.arc(0, 0, 40 + ph * 170, 0, Math.PI * 2); ctx.stroke() } ctx.strokeStyle = `rgba(255,90,70,${0.9 * p})`; ctx.lineWidth = 7; ctx.lineCap = 'round' for (let i = 0; i < 9; i++) { const a = i / 9 * Math.PI * 2 + 0.3, j = 0.6 + 0.4 * Math.sin(t * 20 + i * 3) const r1 = 70 + 20 * j, r2 = r1 + 50 * p * j ctx.beginPath(); ctx.moveTo(Math.cos(a) * r1, Math.sin(a) * r1); ctx.lineTo(Math.cos(a) * r2, Math.sin(a) * r2); ctx.stroke() } ctx.restore() } /* ---------------- cross-section inset (sheath + tendons) ---------------- */ export function crossSection(ctx: C, cx: number, cy: number, R: number, thick: number, friction: number, alpha: number, t: number, title = '横截面示意') { if (alpha <= 0.01) return ctx.save(); ctx.globalAlpha = alpha; ctx.translate(cx, cy) const box = R * 1.5 rr(ctx, -box, -box - 50, box * 2, box * 2 + 70, 26); ctx.fillStyle = 'rgba(4,14,30,0.82)'; ctx.fill() ctx.strokeStyle = 'rgba(95,212,255,0.7)'; ctx.lineWidth = 2.5; ctx.stroke() ctx.font = `800 30px ${FONT}`; ctx.fillStyle = COL.cyan; ctx.textAlign = 'center'; ctx.fillText(title, 0, -box - 12) // sheath ring const outer = R * (1 + thick * 0.22), inner = R * (0.82 - thick * 0.3) const sc = `rgb(${Math.round(140 + 115 * thick)},${Math.round(200 - 120 * thick)},${Math.round(255 - 170 * thick)})` ctx.fillStyle = sc; ctx.globalAlpha = alpha * 0.85 ctx.beginPath(); ctx.arc(0, 10, outer, 0, Math.PI * 2); ctx.arc(0, 10, inner, 0, Math.PI * 2, true); ctx.fill('evenodd') ctx.globalAlpha = alpha ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 10, inner, 0, 7); ctx.stroke() // space (synovial fluid) – shrinking ctx.fillStyle = 'rgba(70,170,255,0.25)'; ctx.beginPath(); ctx.arc(0, 10, inner - 2, 0, 7); ctx.fill() // two tendons const tr = R * 0.3, wob = Math.sin(t * 5) * 2 const hot = friction for (const dx of [-tr * 1.05, tr * 1.05]) { const g = ctx.createRadialGradient(dx - 6, 4 + wob, 2, dx, 10 + wob, tr) g.addColorStop(0, '#ffffff'); g.addColorStop(1, hot > 0.1 ? `rgb(255,${Math.round(230 - 170 * hot)},${Math.round(200 - 170 * hot)})` : '#e6dfcf') ctx.fillStyle = g; ctx.beginPath(); ctx.arc(dx, 10 + wob, tr, 0, 7); ctx.fill() ctx.strokeStyle = 'rgba(0,0,0,0.25)'; ctx.lineWidth = 1.5 for (let k = -2; k <= 2; k++) { ctx.beginPath(); ctx.arc(dx + k * 6, 10 + wob, tr * 0.18, 0, 7); ctx.stroke() } } if (hot > 0.05) { ctx.globalCompositeOperation = 'lighter' const g = ctx.createRadialGradient(0, 10, inner * 0.5, 0, 10, outer) g.addColorStop(0, 'rgba(255,40,30,0)'); g.addColorStop(0.7, `rgba(255,60,40,${0.5 * hot})`); g.addColorStop(1, 'rgba(255,40,30,0)') ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 10, outer * 1.1, 0, 7); ctx.fill() ctx.globalCompositeOperation = 'source-over' } // gap arrows const gap = inner - tr * 2.05 ctx.font = `700 24px ${FONT}`; ctx.fillStyle = '#cfefff'; ctx.textAlign = 'center' ctx.fillText(gap > R * 0.12 ? '间隙充足' : '间隙变窄', 0, R * 1.45) ctx.restore() } /* ---------------- ultrasound ---------------- */ let speckle: HTMLCanvasElement | null = null function getSpeckle() { if (speckle) return speckle const c = document.createElement('canvas'); c.width = 360; c.height = 300 const g = c.getContext('2d')!; const im = g.createImageData(360, 300) for (let i = 0; i < 360 * 300; i++) { const v = Math.pow(hash(i * 1.37 + 0.5), 2.2) * 120 im.data[i * 4] = im.data[i * 4 + 1] = im.data[i * 4 + 2] = v; im.data[i * 4 + 3] = 255 } g.putImageData(im, 0, 0) speckle = c; return c } export interface USOpt { alpha: number; thick: number; locator?: number; labels?: number; knife?: number; release?: number; narrowMark?: number; title?: string } export function ultrasound(ctx: C, x: number, y: number, w: number, h: number, t: number, o: USOpt) { if (o.alpha <= 0.01) return ctx.save(); ctx.globalAlpha = o.alpha rr(ctx, x - 14, y - 64, w + 28, h + 84, 24); ctx.fillStyle = 'rgba(2,8,16,0.94)'; ctx.fill() ctx.strokeStyle = 'rgba(95,212,255,0.6)'; ctx.lineWidth = 2.5; ctx.stroke() ctx.font = `800 30px ${FONT}`; ctx.fillStyle = COL.cyan; ctx.textAlign = 'left'; ctx.textBaseline = 'middle' ctx.fillText(o.title ?? '超声检查 · 示意', x + 6, y - 32) ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.font = `600 22px ${FONT}`; ctx.textAlign = 'right' ctx.fillText(`B-MODE 12MHz ${(t % 60).toFixed(1)}s`, x + w - 6, y - 32) // field ctx.save(); rr(ctx, x, y, w, h, 10); ctx.clip() ctx.fillStyle = '#000'; ctx.fillRect(x, y, w, h) const sp = getSpeckle() ctx.globalAlpha = o.alpha * 0.9 ctx.drawImage(sp, (t * 7) % 40, 0, 320, 300, x, y, w, h) ctx.globalAlpha = o.alpha // layers: skin (bright line), subcut, sheath (hypoechoic halo), tendons (fibrillar bright), bone (bright line + shadow) const cy = y + h * 0.48, cx = x + w * 0.5 ctx.fillStyle = 'rgba(230,230,230,0.55)'; ctx.fillRect(x, y + h * 0.06, w, 5) // sheath halo (dark) thickness grows with thick const sh = h * (0.12 + o.thick * 0.1) const narrowW = w * 0.32 ctx.fillStyle = 'rgba(0,0,0,0.75)' ctx.beginPath(); ctx.ellipse(cx, cy, w * 0.46, h * 0.1 + sh * 0.6, 0, 0, 7); ctx.fill() // tendon fibrillar pattern (narrowed in the middle) for (let i = 0; i < 9; i++) { const yy = cy - h * 0.07 + i * h * 0.0175 ctx.strokeStyle = `rgba(240,240,240,${0.35 + 0.35 * hash(i)})`; ctx.lineWidth = 2.2 ctx.beginPath() for (let k = 0; k <= 40; k++) { const xx = x + w * 0.06 + (w * 0.88) * k / 40 const dn = Math.exp(-Math.pow((xx - cx) / (narrowW * 0.5), 2)) * o.thick * (o.release ? 1 - o.release * 0.7 : 1) const yv = cy + (yy - cy) * (1 - dn * 0.35) + Math.sin(k * 1.7 + i) * 1.2 if (k === 0) ctx.moveTo(xx, yv); else ctx.lineTo(xx, yv) } ctx.stroke() } // bone ctx.strokeStyle = 'rgba(255,255,255,0.9)'; ctx.lineWidth = 8 ctx.beginPath(); ctx.moveTo(x, y + h * 0.8); ctx.quadraticCurveTo(cx, y + h * 0.7, x + w, y + h * 0.82); ctx.stroke() const gsh = ctx.createLinearGradient(0, y + h * 0.8, 0, y + h); gsh.addColorStop(0, 'rgba(0,0,0,0.6)'); gsh.addColorStop(1, 'rgba(0,0,0,0.95)') ctx.fillStyle = gsh; ctx.fillRect(x, y + h * 0.8, w, h * 0.2) // knife echo line if (o.knife && o.knife > 0) { const kx0 = x + w * 0.1, ky0 = y + h * 0.04 const kx1 = kx0 + (cx - kx0) * o.knife, ky1 = ky0 + (cy - h * 0.05 - ky0) * o.knife ctx.strokeStyle = 'rgba(255,255,255,0.95)'; ctx.lineWidth = 5; ctx.shadowColor = '#fff'; ctx.shadowBlur = 12 ctx.beginPath(); ctx.moveTo(kx0, ky0); ctx.lineTo(kx1, ky1); ctx.stroke(); ctx.shadowBlur = 0 } if (o.narrowMark && o.narrowMark > 0) { ctx.globalAlpha = o.alpha * o.narrowMark ctx.strokeStyle = COL.red; ctx.lineWidth = 4; ctx.setLineDash([12, 8]) ctx.beginPath(); ctx.ellipse(cx, cy, narrowW * 0.62, h * 0.2, 0, 0, 7); ctx.stroke(); ctx.setLineDash([]) ctx.globalAlpha = o.alpha } if (o.locator && o.locator > 0) { ctx.globalAlpha = o.alpha * o.locator const r = 10 + Math.sin(t * 8) * 3 ctx.fillStyle = '#fff'; ctx.shadowColor = '#fff'; ctx.shadowBlur = 25 ctx.beginPath(); ctx.arc(cx, cy - h * 0.05, r, 0, 7); ctx.fill(); ctx.shadowBlur = 0 ctx.strokeStyle = '#fff'; ctx.lineWidth = 2 ctx.beginPath(); ctx.arc(cx, cy - h * 0.05, r + 18, 0, 7); ctx.stroke() ctx.globalAlpha = o.alpha } // depth ruler ctx.fillStyle = 'rgba(255,255,255,0.6)' for (let i = 0; i < 8; i++) ctx.fillRect(x + w - 16, y + 10 + i * (h - 20) / 7, i % 2 ? 6 : 12, 2) ctx.restore() // labels const L = o.labels ?? 0 if (L > 0) { const ls = [ { t: '肌腱', ax: x + w * 0.2, ay: cy, tx: x + w * 0.08, ty: y + h + 50, c: COL.white, d: 0 }, { t: '腱鞘', ax: x + w * 0.78, ay: cy - h * 0.13, tx: x + w * 0.66, ty: y + h + 50, c: COL.cyan, d: 0.25 }, { t: '狭窄位置', ax: cx, ay: cy + h * 0.08, tx: x + w * 0.36, ty: y + h + 50, c: COL.red, d: 0.5 }, ] for (const l of ls) { const a = clamp((L - l.d) * 2.5) if (a <= 0) continue ctx.save(); ctx.globalAlpha = o.alpha * a ctx.strokeStyle = l.c; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(l.ax, l.ay); ctx.lineTo(l.ax, l.ty - 26); ctx.stroke() ctx.fillStyle = l.c; ctx.beginPath(); ctx.arc(l.ax, l.ay, 7, 0, 7); ctx.fill() ctx.font = `800 34px ${FONT}`; ctx.textAlign = 'center'; ctx.fillStyle = l.c; ctx.fillText(l.t, l.ax, l.ty) ctx.restore() } } ctx.restore() } /* ---------------- step bar ---------------- */ export function steps(ctx: C, list: string[], active: number, alpha: number, y = 260) { if (alpha <= 0.01) return ctx.save(); ctx.globalAlpha = alpha const n = list.length, gap = 230, x0 = 540 - (gap * (n - 1)) / 2 ctx.strokeStyle = 'rgba(95,212,255,0.35)'; ctx.lineWidth = 4 ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + gap * (n - 1), y); ctx.stroke() const prog = clamp(active / (n - 1)) ctx.strokeStyle = COL.teal; ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + gap * (n - 1) * prog, y); ctx.stroke() list.forEach((s, i) => { const x = x0 + i * gap const on = active >= i - 0.01, cur = Math.abs(active - i) < 0.5 ctx.fillStyle = on ? COL.teal : '#0b2238'; ctx.strokeStyle = on ? COL.teal : 'rgba(95,212,255,0.5)'; ctx.lineWidth = 3 ctx.beginPath(); ctx.arc(x, y, cur ? 30 : 24, 0, 7); ctx.fill(); ctx.stroke() ctx.fillStyle = on ? '#04121f' : '#9fd8ff'; ctx.font = `900 28px ${FONT}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle' ctx.fillText(String(i + 1), x, y + 1) ctx.fillStyle = cur ? '#fff' : 'rgba(255,255,255,0.65)'; ctx.font = `800 ${cur ? 36 : 30}px ${FONT}` ctx.fillText(s, x, y + 62) }) ctx.restore() } /* ---------------- header / footer ---------------- */ export function header(ctx: C, idx: number, title: string, alpha: number) { ctx.save() // persistent brandless series tag ctx.globalAlpha = 0.9 ctx.font = `800 30px ${FONT}`; ctx.textBaseline = 'middle'; ctx.textAlign = 'left' rr(ctx, 48, 120, 300, 54, 27); ctx.fillStyle = 'rgba(4,14,30,0.6)'; ctx.fill(); ctx.strokeStyle = 'rgba(95,212,255,0.45)'; ctx.lineWidth = 2; ctx.stroke() ctx.fillStyle = COL.red; ctx.beginPath(); ctx.arc(76, 147, 8, 0, 7); ctx.fill() ctx.fillStyle = '#dff4ff'; ctx.fillText('腱鞘炎 · 3D 解剖', 96, 148) if (alpha > 0.01) { ctx.globalAlpha = alpha ctx.font = `900 40px ${FONT}` const tw = ctx.measureText(title).width const x = 48 - (1 - alpha) * 40 rr(ctx, x, 190, tw + 120, 66, 14); ctx.fillStyle = 'rgba(95,212,255,0.14)'; ctx.fill() ctx.fillStyle = COL.cyan; ctx.fillRect(x, 190, 8, 66) ctx.fillStyle = COL.cyan; ctx.font = `900 34px ${FONT}`; ctx.fillText(String(idx).padStart(2, '0'), x + 26, 224) ctx.fillStyle = '#fff'; ctx.font = `900 40px ${FONT}`; ctx.fillText(title, x + 84, 224) } ctx.restore() } export function footer(ctx: C) { ctx.save(); ctx.globalAlpha = 0.55 ctx.font = `600 24px ${FONT}`; ctx.fillStyle = '#cfe6ff'; ctx.textAlign = 'center' ctx.fillText('原创3D医学示意动画 · 仅供科普,不替代面诊', 540, 1870) ctx.restore() } /* ---------------- panel frame ---------------- */ export function panel(ctx: C, x: number, y: number, w: number, h: number, title: string, num: number, active: number, dim: number) { ctx.save() if (dim > 0.01) { ctx.fillStyle = `rgba(2,6,14,${dim * 0.6})`; ctx.fillRect(x, y, w, h) } const c = active > 0.5 ? COL.teal : 'rgba(95,212,255,0.4)' ctx.strokeStyle = c; ctx.lineWidth = active > 0.5 ? 6 : 2 if (active > 0.5) { ctx.shadowColor = COL.teal; ctx.shadowBlur = 20 } rr(ctx, x + 6, y + 6, w - 12, h - 12, 22); ctx.stroke(); ctx.shadowBlur = 0 ctx.font = `900 40px ${FONT}` const tw = ctx.measureText(title).width rr(ctx, x + 22, y + h - 100, tw + 100, 70, 35); ctx.fillStyle = active > 0.5 ? COL.teal : 'rgba(4,14,30,0.85)'; ctx.fill() icon(ctx, 'num', x + 62, y + h - 65, 46, active > 0.5 ? '#04121f' : COL.cyan, num) ctx.fillStyle = active > 0.5 ? '#04121f' : '#fff'; ctx.textBaseline = 'middle'; ctx.textAlign = 'left' ctx.fillText(title, x + 98, y + h - 63) ctx.restore() } export function flash(ctx: C, a: number, color = '255,255,255') { if (a <= 0.01) return ctx.save(); ctx.fillStyle = `rgba(${color},${a})`; ctx.fillRect(0, 0, 1080, 1920); ctx.restore() } /** simple line icons for actions */ export function actionIcon(ctx: C, kind: 'grab' | 'pinch' | 'twist' | 'baby' | 'keyboard' | 'tool' | 'home' | 'comment', x: number, y: number, s: number, color: string, alpha = 1) { if (alpha <= 0.01) return ctx.save(); ctx.globalAlpha = alpha; ctx.translate(x, y); ctx.strokeStyle = color; ctx.fillStyle = color; ctx.lineWidth = s * 0.07; ctx.lineCap = 'round'; ctx.lineJoin = 'round' const S = s / 2 if (kind === 'grab') { ctx.beginPath(); ctx.arc(0, 0, S * 0.55, 0, 7); ctx.stroke(); for (let i = 0; i < 4; i++) { const a = -2.4 + i * 0.45; ctx.beginPath(); ctx.arc(Math.cos(a) * S * 0.55, Math.sin(a) * S * 0.55, S * 0.16, 0, 7); ctx.fill() } } else if (kind === 'pinch') { ctx.beginPath(); ctx.moveTo(-S * 0.7, -S * 0.5); ctx.quadraticCurveTo(0, -S * 0.2, -S * 0.05, -S * 0.05); ctx.moveTo(-S * 0.7, S * 0.5); ctx.quadraticCurveTo(0, S * 0.2, -S * 0.05, S * 0.05); ctx.stroke(); ctx.beginPath(); ctx.arc(S * 0.15, 0, S * 0.15, 0, 7); ctx.fill() } else if (kind === 'twist') { ctx.beginPath(); ctx.arc(0, 0, S * 0.6, -2.6, 1.2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(S * 0.21, S * 0.56); ctx.lineTo(S * 0.5, S * 0.75); ctx.lineTo(S * 0.4, S * 0.4); ctx.stroke(); ctx.strokeRect(-S * 0.25, -S * 0.25, S * 0.5, S * 0.5) } else if (kind === 'baby') { ctx.beginPath(); ctx.arc(-S * 0.35, -S * 0.15, S * 0.28, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.ellipse(S * 0.2, S * 0.15, S * 0.55, S * 0.32, -0.3, 0, 7); ctx.stroke() } else if (kind === 'keyboard') { ctx.strokeRect(-S * 0.75, -S * 0.35, S * 1.5, S * 0.7); for (let i = 0; i < 5; i++) ctx.fillRect(-S * 0.6 + i * S * 0.27, -S * 0.15, S * 0.12, S * 0.1) } else if (kind === 'tool') { ctx.beginPath(); ctx.moveTo(-S * 0.6, S * 0.6); ctx.lineTo(S * 0.25, -S * 0.25); ctx.stroke(); ctx.fillRect(S * 0.05, -S * 0.7, S * 0.6, S * 0.35) } else if (kind === 'home') { ctx.beginPath(); ctx.moveTo(-S * 0.6, 0); ctx.lineTo(0, -S * 0.6); ctx.lineTo(S * 0.6, 0); ctx.moveTo(-S * 0.45, -S * 0.1); ctx.lineTo(-S * 0.45, S * 0.55); ctx.lineTo(S * 0.45, S * 0.55); ctx.lineTo(S * 0.45, -S * 0.1); ctx.stroke() } else if (kind === 'comment') { rr(ctx, -S * 0.7, -S * 0.55, S * 1.4, S * 0.95, S * 0.25); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-S * 0.3, S * 0.4); ctx.lineTo(-S * 0.45, S * 0.75); ctx.lineTo(0, S * 0.4); ctx.stroke(); for (let i = -1; i <= 1; i++) { ctx.beginPath(); ctx.arc(i * S * 0.32, -S * 0.08, S * 0.08, 0, 7); ctx.fill() } } ctx.restore() }