SLOPSHOPPER

celebrate-th

ฉลองความสำเร็จ: เทสต์ผ่าน commit สำเร็จ หรือ deploy เสร็จ จะมีพลุในแผง เสียงแตร และสถิติความสำเร็จ

newpaneguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · celebrate-th
│ ┃ 🎉 Celebrate ✕ › fix the failing auth te╭─────────────────────────────────────────╮ │ ┃ │ celebrate-th │ │ ┃ ⏺ Read(src/auth.ts) │ 🎉 เย้! เก่งมาก! (วันนี้ฉลองไป 1 ครั้ง) │ │ ┃ ⎿ Read 6 lines ╰─────────────────────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /celebrate │ ┃ ⎿ celebrate-th: 🎉🎊✨ │ ┃ │ ┃ │ ┃ 🎉 เย้! เก่งมาก! │ ┃ วันนี้ 1 · ทั้งหมด 1 ครั้ง │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 🎉 Celebrate
🎉 เย้! เก่งมาก! วันนี้ 1 · ทั้งหมด 1 ครั้ง
README

Mod 45: Celebrate TH

ฉลองความสำเร็จด้วยพลุกระดาษที่ตกลงมาในแผงและเสียงแตรที่สังเคราะห์ขึ้นเอง ฉลองอัตโนมัติเมื่อ Claude รันคำสั่งแล้วสำเร็จจริง: เทสต์ผ่าน (npm/pnpm/yarn test, pytest, jest, vitest, go test, cargo test ...), git commit สำเร็จ และ deploy สำเร็จ พร้อมสถิติ

คำสั่ง

คำสั่งทำอะไร
/celebrate [ข้อความ]พลุกระดาษ + แตรฉลอง
`/celebrate auto on\off`ฉลองอัตโนมัติ
`/celebrate sound on\off`เปิด/ปิดเสียง
/celebrate statsสถิติความสำเร็จ
`/celebrate style fireworks\confetti`พลุไฟ (ค่าเริ่มต้น: จรวดพุ่งแล้วระเบิดเป็นประกายสี) หรือพลุกระดาษ

ติดตั้ง

/plugin install celebrate-th --marketplace tksoft11/claude-code-mods-th

ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)

โค้ดสอนอะไร

เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. แยกแยะความสำเร็จจากคำสั่งและผลลัพธ์ Bash
  2. พลุกระดาษที่คำนวณจากเลขเฟรม (ทดสอบได้)
  3. ดูผลลัพธ์ Bash ใน tool.call
  4. วาดพลุในแผง
  5. คำสั่ง /celebrate

หมายเหตุ

เสียงใน sounds/ สังเคราะห์ขึ้นเองด้วย Python บน Windows/Linux เล่นผ่านโปรแกรมของระบบ (PowerShell SoundPlayer / paplay / aplay) แบบเดียวกับ Mod 36 การฉลองอัตโนมัติดูจากคำสั่งที่ Claude รันเท่านั้น ไม่รวมคำสั่งที่คุณรันเองใน terminal

Source 2 files
hooks/register.tsx 263 lines
1/**
2 * ============================================================================
3 * MOD 45: Celebrate TH - ฉลองความสำเร็จ พลุกระดาษ + แตรฉลอง (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: เอฟเฟกต์พลุตอนปิดงานใน Asana และ Linear
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. /celebrate - พลุกระดาษ (confetti) ตกลงมาในแผง พร้อมเสียงแตรที่สังเคราะห์เอง
9 * 2. ฉลองอัตโนมัติเมื่อ Claude รันคำสั่งแล้วสำเร็จจริง:
10 *    เทสต์ผ่าน / git commit สำเร็จ / deploy สำเร็จ (ดูจากผลลัพธ์ Bash)
11 * 3. สถิติความสำเร็จ: วันนี้ / ทั้งหมด
12 * 4. พลุไฟ (fireworks): จรวดพุ่งขึ้นแล้วระเบิดเป็นประกายสีตกตามแรงโน้มถ่วง วาดด้วย Raster/SVG (grid.tsx)
13 *    เลือกแบบได้ /celebrate style fireworks|confetti
14 *
15 * เรื่องเสียง: $.audio.play เล่นได้บน macOS แต่ terminal ของ Windows/Linux ไม่มีเครื่องเล่น
16 * จึงเล่นผ่านโปรแกรมของระบบแทน (PowerShell SoundPlayer / paplay / aplay) แบบเดียวกับ Mod 36
17 */
18
19import type { EngineInterface, Register, Timer } from 'claude-code'
20import { Grid, drawGrid, hsl, rgb } from './grid'
21
22type Kind = 'test' | 'commit' | 'deploy' | 'manual'
23
24// ==========================================================================
25// พลุไฟ: จรวด 5 ลูกยิงห่างกัน ถึงจุดสูงสุดแล้วระเบิดเป็นประกาย 28 ชิ้น
26// ทุกอย่างคำนวณจากเลขเฟรม (ไม่สุ่มตอนวาด) จึงเทสต์ได้และภาพไม่กระตุก
27// ==========================================================================
28const FW_FRAMES = 48
29const FW_W = 60
30const FW_H = 16
31
32function fireworks(f: number): Grid {
33  const g = new Grid(FW_W, FW_H, rgb(5, 6, 20))
34  for (let k = 0; k < 5; k++) {
35    const start = k * 6
36    const age = f - start
37    if (age < 0) continue
38    const seed = Math.sin(k * 91.7) * 0.5 + 0.5
39    const x = 6 + ((k * 13 + Math.round(seed * 9)) % (FW_W - 12))
40    const peak = 3 + Math.round(seed * 4)
41    const rise = 7
42    const hue = (k * 77 + 20) % 360
43    if (age < rise) {
44      // จรวดกำลังพุ่งขึ้น + หางไฟ
45      const y = FW_H - 1 - ((FW_H - 1 - peak) * age) / rise
46      g.set(x, y, '^', rgb(255, 240, 200))
47      g.set(x, y + 1, '|', rgb(255, 160, 60))
48      continue
49    }
50    const t = age - rise
51    const life = 1 - t / 20
52    if (life <= 0) continue
53    for (let p = 0; p < 28; p++) {
54      const a = (p / 28) * Math.PI * 2
55      const speed = 0.9 + (p % 3) * 0.35
56      // แกน x คูณ 2 เพราะช่องตัวอักษรสูงกว่ากว้าง ประกายจะได้เป็นวงกลม
57      const px = x + Math.cos(a) * speed * t * 1.6
58      const py = peak + Math.sin(a) * speed * t * 0.8 + 0.035 * t * t
59      g.set(px, py, life > 0.6 ? '*' : life > 0.3 ? '+' : '.', hsl(hue + (p % 4) * 12, 0.95, 0.35 + life * 0.35))
60    }
61  }
62  return g
63}
64
65const PANE = 'celebrate-th'
66const COLORS = ['#ef5350', '#ffca28', '#66bb6a', '#42a5f5', '#ab47bc', '#ff7043', '#26c6da']
67const PIECES = ['▪', '●', '▲', '✦', '❖', '♦', '★', '✿']
68const FRAMES = 28
69const WIDTH = 44
70const HEIGHT = 9
71
72const KIND_TEXT: Record<Kind, string> = {
73  test: '🧪 เทสต์ผ่านหมด!',
74  commit: '📦 commit สำเร็จ!',
75  deploy: '🚀 deploy สำเร็จ!',
76  manual: '🎉 เย้!',
77}
78
79interface Stats {
80  total: number
81  byKind: Record<Kind, number>
82  day: string
83  today: number
84}
85
86let stats: Stats = { total: 0, byKind: { test: 0, commit: 0, deploy: 0, manual: 0 }, day: '', today: 0 }
87let settings = { auto: true, sound: true, style: 'fireworks' as 'fireworks' | 'confetti' }
88let frame = FRAMES
89let headline = ''
90let animator: Timer | undefined
91let isWindows = false
92let isMac = false
93
94// ==========================================================================
95// บทที่ 1: แยกแยะว่าคำสั่ง Bash ที่เพิ่งรัน "สำเร็จ" แบบไหน
96// ==========================================================================
97function detect(command: string, output: string): Kind | undefined {
98  const failed = /\b[1-9]\d*\s+(?:failed|failing|failures?|errors?)\b|\bFAILED\b|✘|\(fail\)/i.test(output)
99  if (/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|\bpytest\b|\bjest\b|\bvitest\b|\bgo\s+test\b|\bcargo\s+test\b|\bphpunit\b|\bplugin\s+test\b/.test(command)) {
100    return failed ? undefined : 'test'
101  }
102  // git commit สำเร็จจะพิมพ์ "[branch abc1234] ข้อความ"
103  if (/\bgit\s+commit\b/.test(command) && /^\[[^\]\n]+ [0-9a-f]{7,}\]/m.test(output)) return 'commit'
104  if (/\bdeploy\b|\bvercel\b.*--prod|\bfly\s+deploy\b|\bwrangler\s+(?:deploy|publish)\b/.test(command) && !failed && !/\berror\b/i.test(output)) return 'deploy'
105  return undefined
106}
107
108// ==========================================================================
109// บทที่ 2: พลุกระดาษ - ตำแหน่งแต่ละชิ้นคำนวณจาก "เลขเฟรม" (สุ่มแบบคงที่ เทสต์ได้)
110// ==========================================================================
111function rand(n: number): number {
112  const x = Math.sin(n * 12.9898) * 43758.5453
113  return x - Math.floor(x)
114}
115
116function confetti(f: number): { text: string; color: string }[][] {
117  const rows: { text: string; color: string }[][] = []
118  for (let y = 0; y < HEIGHT; y++) {
119    const row: { text: string; color: string }[] = []
120    for (let x = 0; x < WIDTH; x++) {
121      // แต่ละคอลัมน์มีชิ้นหนึ่งตกลงด้วยความเร็วและจังหวะเริ่มต่างกัน
122      const speed = 0.4 + rand(x) * 0.6
123      const fall = Math.floor((f - rand(x + 99) * 10) * speed)
124      const hit = fall === y || (rand(x * 7 + y) > 0.97 && f < FRAMES - 6)
125      row.push(hit ? { text: PIECES[Math.floor(rand(x + y + f) * PIECES.length)] ?? '●', color: COLORS[(x + y) % COLORS.length] ?? '#fff' } : { text: ' ', color: '' })
126    }
127    rows.push(row)
128  }
129  return rows
130}
131
132function play($: EngineInterface, name: string): void {
133  if (!settings.sound) return
134  if (isMac) {
135    void $.audio.play({ asset: `sounds/${name}.wav` }).catch(() => undefined)
136    return
137  }
138  const file = `${$.plugin.root}${isWindows ? '\\sounds\\' : '/sounds/'}${name}.wav`
139  const argv = isWindows
140    ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', '(New-Object Media.SoundPlayer $env:CB_FILE).PlaySync()']
141    : ['sh', '-c', 'paplay "$CB_FILE" 2>/dev/null || aplay -q "$CB_FILE"']
142  void $.process.run(argv, { env: { CB_FILE: file }, timeoutMs: 10_000 }).catch(() => undefined)
143}
144
145async function celebrate($: EngineInterface, kind: Kind, message: string, big: boolean): Promise<void> {
146  const now = new Date(await $.clock.now())
147  const day = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`
148  if (stats.day !== day) stats = { ...stats, day, today: 0 }
149  stats = { ...stats, total: stats.total + 1, today: stats.today + 1, byKind: { ...stats.byKind, [kind]: stats.byKind[kind] + 1 } }
150  await $.store.set('stats', stats)
151
152  headline = message
153  play($, big ? 'fanfare' : 'ding')
154  $.ui.toast(`${message}  (วันนี้ฉลองไป ${stats.today} ครั้ง)`)
155  if (!big) return
156  frame = 0
157  await $.ui.open({ id: PANE, title: '🎉 Celebrate' })
158  animator?.cancel()
159  const total = settings.style === 'fireworks' ? FW_FRAMES : FRAMES
160  animator = $.clock.every(settings.style === 'fireworks' ? 100 : 150, () => {
161    frame++
162    if (frame >= total) {
163      animator?.cancel()
164      animator = undefined
165    }
166    $.ui.invalidate('ui.render')
167  })
168}
169
170const HELP = `🎉 Celebrate TH (Mod 45)
171========================================
172/celebrate [ข้อความ]      พลุกระดาษ + แตรฉลอง
173/celebrate auto on|off    ฉลองอัตโนมัติเมื่อเทสต์ผ่าน / commit / deploy สำเร็จ (ตอนนี้: AUTO)
174/celebrate sound on|off   เปิด/ปิดเสียง (ตอนนี้: SOUND)
175/celebrate style fireworks|confetti   พลุไฟ หรือ พลุกระดาษ
176/celebrate stats          สถิติความสำเร็จ`
177
178export const register: Register = on => {
179  on('session.start', async ($, e, next) => {
180    isWindows = /^[A-Za-z]:/.test(e.cwd)
181    isMac = !isWindows && e.cwd.startsWith('/Users/')
182    stats = ((await $.store.get('stats')) as Stats | undefined) ?? stats
183    settings = { ...settings, ...((await $.store.get('settings')) as typeof settings | undefined) }
184    await $.command.register({ name: 'celebrate', description: 'ฉลองความสำเร็จ พลุกระดาษ + แตร และฉลองอัตโนมัติเมื่อเทสต์ผ่าน (/celebrate help)', argumentHint: '[ข้อความ]|auto|sound|stats' })
185    return next(e)
186  })
187
188  // ==========================================================================
189  // บทที่ 3: ดูผลลัพธ์ Bash - สำเร็จจริงเท่านั้นถึงฉลอง
190  // ==========================================================================
191  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
192    const r = await next(e)
193    if (!settings.auto || r.deny !== undefined || r.isError) return r
194    const kind = detect(e.command, `${r.result.stdout}\n${r.result.stderr}`)
195    if (kind) void celebrate($, kind, KIND_TEXT[kind], kind !== 'commit')
196    return r
197  })
198
199  // ==========================================================================
200  // บทที่ 4: วาดพลุกระดาษในแผง
201  // ==========================================================================
202  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
203    const els = $.ui.resolve(e)
204    const { Box, Text } = els
205    const done = frame >= (settings.style === 'fireworks' ? FW_FRAMES : FRAMES)
206    return (
207      <Box flexDirection="column" alignItems="center">
208        {!done && settings.style === 'fireworks' && (drawGrid(els, e.surface, fireworks(frame), 'fw', 'พลุไฟฉลองความสำเร็จ') as never)}
209        {!done &&
210          settings.style === 'confetti' &&
211          confetti(frame).map(row => (
212            <Text>
213              {row.map(c => (c.color ? <Text color={c.color}>{c.text}</Text> : c.text))}
214            </Text>
215          ))}
216        <Text bold color="#ffca28">
217          {headline || '🎉'}
218        </Text>
219        <Text dimColor>
220          วันนี้ {stats.today} · ทั้งหมด {stats.total} ครั้ง
221        </Text>
222      </Box>
223    )
224  })
225
226  on('command.run', { command: 'celebrate' }, async ($, e) => {
227    const [sub = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
228    switch (sub) {
229      case 'help':
230        return { text: HELP.replace('AUTO', settings.auto ? 'เปิด' : 'ปิด').replace('SOUND', settings.sound ? 'เปิด' : 'ปิด') }
231      case 'auto':
232      case 'sound': {
233        if (rest[0] !== 'on' && rest[0] !== 'off') return { text: `วิธีใช้: /celebrate ${sub} on|off` }
234        settings = { ...settings, [sub]: rest[0] === 'on' }
235        await $.store.set('settings', settings)
236        return { text: `${sub === 'auto' ? '🤖 ฉลองอัตโนมัติ' : '🔊 เสียง'}: ${rest[0] === 'on' ? 'เปิด' : 'ปิด'}` }
237      }
238      case 'style': {
239        if (rest[0] !== 'fireworks' && rest[0] !== 'confetti') return { text: `วิธีใช้: /celebrate style fireworks|confetti (ตอนนี้: ${settings.style})` }
240        settings = { ...settings, style: rest[0] }
241        await $.store.set('settings', settings)
242        return { text: rest[0] === 'fireworks' ? '🎆 ใช้พลุไฟ' : '🎊 ใช้พลุกระดาษ' }
243      }
244      case 'stats': {
245        const k = stats.byKind
246        return { text: `🏆 ฉลองไปแล้ว ${stats.total} ครั้ง (วันนี้ ${stats.today})\n  🧪 เทสต์ผ่าน ${k.test}\n  📦 commit ${k.commit}\n  🚀 deploy ${k.deploy}\n  🎉 ฉลองเอง ${k.manual}` }
247      }
248      default:
249        await celebrate($, 'manual', e.args.trim().slice(0, 60) || '🎉 เย้! เก่งมาก!', true)
250        return { text: '🎉🎊✨' }
251    }
252  })
253}
254
255/**
256 * ========================================================================
257 * การบ้าน / ต่อยอด
258 * ========================================================================
259 * 1. ฉลองแบบพิเศษเมื่อครบ 10, 50, 100 ครั้ง
260 * 2. ฉลองเมื่อ PR ถูก merge (เช็คผล gh pr merge)
261 * 3. ส่งพลุไปมือถือด้วย Mod 22 ตอน deploy สำเร็จ
262 */
263
hooks/grid.tsx 154 lines
1/**
2 * grid.tsx - ตารางตัวอักษรมีสี วาดได้ทุกหน้าจอ
3 * terminal: ใช้ Raster (ช่องสีจริง เร็ว) / desktop, vscode, mobile: แปลงเป็น SVG
4 * ไฟล์นี้ไม่รับ $ (ตัวตรวจของ engine ไม่ให้ส่ง $ ข้ามไฟล์) รับแค่ element ที่ resolve มาแล้ว
5 */
6
7export const DEFAULT = 0x01000000 // สีเริ่มต้นของ terminal
8
9export class Grid {
10  readonly w: number
11  readonly h: number
12  readonly ch: string[]
13  readonly fg: number[]
14  readonly bg: number[]
15
16  constructor(w: number, h: number, bg = DEFAULT) {
17    this.w = w
18    this.h = h
19    this.ch = new Array<string>(w * h).fill(' ')
20    this.fg = new Array<number>(w * h).fill(DEFAULT)
21    this.bg = new Array<number>(w * h).fill(bg)
22  }
23
24  set(x: number, y: number, ch: string, fg = DEFAULT, bg?: number): void {
25    const xi = Math.round(x)
26    const yi = Math.round(y)
27    if (xi < 0 || yi < 0 || xi >= this.w || yi >= this.h) return
28    const i = yi * this.w + xi
29    this.ch[i] = ch
30    this.fg[i] = fg
31    if (bg !== undefined) this.bg[i] = bg
32  }
33
34  paint(x: number, y: number, bg: number): void {
35    const xi = Math.round(x)
36    const yi = Math.round(y)
37    if (xi < 0 || yi < 0 || xi >= this.w || yi >= this.h) return
38    this.bg[yi * this.w + xi] = bg
39  }
40
41  text(x: number, y: number, s: string, fg = DEFAULT, bg?: number): void {
42    ;[...s].forEach((c, i) => this.set(x + i, y, c, fg, bg))
43  }
44}
45
46// สี RGB เป็นตัวเลข 0xRRGGBB
47export function rgb(r: number, g: number, b: number): number {
48  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
49  return (c(r) << 16) | (c(g) << 8) | c(b)
50}
51
52export function hsl(h: number, s: number, l: number): number {
53  const a = s * Math.min(l, 1 - l)
54  const f = (n: number) => {
55    const k = (n + h / 30) % 12
56    return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
57  }
58  return rgb(f(0) * 255, f(8) * 255, f(4) * 255)
59}
60
61// ==========================================================================
62// Raster ต้องการ base64 ของ u32 little-endian [codePoint, fg, bg] ต่อช่อง
63// ==========================================================================
64const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
65
66function base64(bytes: Uint8Array): string {
67  let out = ''
68  for (let i = 0; i < bytes.length; i += 3) {
69    const a = bytes[i] ?? 0
70    const b = bytes[i + 1] ?? 0
71    const c = bytes[i + 2] ?? 0
72    out += B64[a >> 2] ?? ''
73    out += B64[((a & 3) << 4) | (b >> 4)] ?? ''
74    out += i + 1 < bytes.length ? (B64[((b & 15) << 2) | (c >> 6)] ?? '') : '='
75    out += i + 2 < bytes.length ? (B64[c & 63] ?? '') : '='
76  }
77  return out
78}
79
80export function rasterCells(g: Grid): string {
81  const bytes = new Uint8Array(g.w * g.h * 12)
82  const view = new DataView(bytes.buffer)
83  for (let i = 0; i < g.w * g.h; i++) {
84    view.setUint32(i * 12, (g.ch[i] ?? ' ').codePointAt(0) ?? 32, true)
85    view.setUint32(i * 12 + 4, g.fg[i] ?? DEFAULT, true)
86    view.setUint32(i * 12 + 8, g.bg[i] ?? DEFAULT, true)
87  }
88  return base64(bytes)
89}
90
91// ==========================================================================
92// SVG: รวมช่องที่สีเดียวกันติดกันเป็นก้อนเดียว ให้ไฟล์เล็ก (จำกัด 131,072 ตัวอักษร)
93// ==========================================================================
94const CW = 8.4
95const CH = 16
96
97function hex(c: number, fallback: string): string {
98  return c === DEFAULT ? fallback : `#${c.toString(16).padStart(6, '0')}`
99}
100
101function esc(s: string): string {
102  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
103}
104
105export function svgOf(g: Grid, background = '#0b0e14', foreground = '#d8dee9'): string {
106  const parts: string[] = [`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(g.w * CW)}" height="${g.h * CH}" viewBox="0 0 ${g.w * CW} ${g.h * CH}"><rect width="100%" height="100%" fill="${background}"/>`]
107  for (let y = 0; y < g.h; y++) {
108    // พื้นหลัง
109    let x = 0
110    while (x < g.w) {
111      const c = g.bg[y * g.w + x] ?? DEFAULT
112      let end = x + 1
113      while (end < g.w && g.bg[y * g.w + end] === c) end++
114      if (c !== DEFAULT) parts.push(`<rect x="${(x * CW).toFixed(1)}" y="${y * CH}" width="${((end - x) * CW).toFixed(1)}" height="${CH}" fill="${hex(c, background)}"/>`)
115      x = end
116    }
117    // ตัวอักษร
118    x = 0
119    while (x < g.w) {
120      const i = y * g.w + x
121      if ((g.ch[i] ?? ' ') === ' ') {
122        x++
123        continue
124      }
125      const c = g.fg[i] ?? DEFAULT
126      let end = x + 1
127      while (end < g.w && g.fg[y * g.w + end] === c && (g.ch[y * g.w + end] ?? ' ') !== ' ') end++
128      const run = g.ch.slice(i, y * g.w + end).join('')
129      parts.push(`<text x="${(x * CW).toFixed(1)}" y="${y * CH + 12}" fill="${hex(c, foreground)}" textLength="${((end - x) * CW).toFixed(1)}" lengthAdjust="spacingAndGlyphs">${esc(run)}</text>`)
130      x = end
131    }
132  }
133  parts.push('</svg>')
134  const svg = parts.join('')
135  return svg.replace('<svg ', '<svg font-family="Consolas, Menlo, monospace" font-size="14" xml:space="preserve" ')
136}
137
138// ==========================================================================
139// วาดตามหน้าจอ: els = $.ui.resolve(e), surface = e.surface
140// ==========================================================================
141// eslint-disable-next-line @typescript-eslint/no-explicit-any
142export function drawGrid(els: any, surface: string, g: Grid, key: string, alt: string): unknown {
143  if (surface === 'terminal' && els.Raster) {
144    const Raster = els.Raster
145    return <Raster key={key} columns={g.w} rows={g.h} cells={rasterCells(g)} />
146  }
147  if (els.Svg) {
148    const Svg = els.Svg
149    return <Svg source={svgOf(g)} alt={alt} />
150  }
151  const Text = els.Text
152  return <Text>{alt}</Text>
153}
154