mx / src /video /overlay.ts
Ethscriptions's picture
Step 3-4: 3D 解剖模型与分镜时间轴
213d393 verified
Raw History Blame Contribute Delete
22.8 kB
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()
}