Spaces:
Running
Running
Download src/video/overlay.ts from Ethscriptions/mx: direct link, hf CLI and curl.
- Browser
- Download file 22.8 kB
-
https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/overlay.ts
- Command line
-
hf download hf://spaces/Ethscriptions/mx/src/video/overlay.ts
-
curl -L -o overlay.ts https://huggingface.co/spaces/Ethscriptions/mx/resolve/main/src/video/overlay.ts
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() | |
| } | |