SLOPSHOPPER

weather-forecast-th

พยากรณ์อากาศจริงจาก Open-Meteo: ตอนนี้ รายชั่วโมง 7 วัน ฝน UV และ PM2.5 พร้อมเตือนฝนจะตก

newpanecommandtoastnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · weather-forecast-th
│ ┃ weather-forecast-th ✕ › fix the failing auth test and add an audit log call │ ┃ ตั้งเมืองหลักด้วย /weather home กรุงเทพ │ ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /weather │ ⎿ weather-forecast-th: ยังไม่ได้ตั้งเมืองหลัก ใช้ /weather home กร │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · weather-forecast-th
ตั้งเมืองหลักด้วย /weather home กรุงเทพ
Pane · weather-radar-th
/weather radar
README

Mod 29: Weather Forecast TH

พยากรณ์อากาศจริงจาก Open-Meteo: อากาศตอนนี้ (อุณหภูมิ รู้สึกเหมือน ความชื้น ลม UV), PM2.5 และ AQI, รายชั่วโมง 12 ชม. และรายวัน 7 วันพร้อมโอกาสฝน ค้นเมืองได้ทั้งภาษาไทยและอังกฤษ ตั้งเมืองหลักและเมืองโปรด และเตือนเมื่อฝนน่าจะตกใน 2 ชม. ข้างหน้า

คำสั่ง

คำสั่งทำอะไร
/weather [เมือง]อากาศตอนนี้ (ไม่ใส่ = เมืองหลัก)
/weather hourly [เมือง]รายชั่วโมง 12 ชม.
/weather week [เมือง]7 วัน
/weather home <เมือง>ตั้งเมืองหลัก + เปิดแผง
`/weather fav add\remove <เมือง> / fav`เมืองโปรด
`/weather alert on\off`เตือนฝนที่เมืองหลัก (เช็คทุก 30 นาที)
/weather radar [เมือง]เรดาร์ฝนเคลื่อนไหว 6 ชม. ข้างหน้า รอบเมือง ~280 x 150 กม. พร้อมสรุปทิศและระยะของฝนหนัก

ติดตั้ง

/plugin install weather-forecast-th --marketplace tksoft11/claude-code-mods-th

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

โค้ดสอนอะไร

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

  1. แปลงรหัสสภาพอากาศ WMO เป็นข้อความไทย + emoji และแปล AQI/UV
  2. เรียก geocoding, forecast และ air quality ของ Open-Meteo พร้อมกัน
  3. จัดรูปแบบรายชั่วโมงและรายวัน
  4. เตือนฝนด้วย $.clock.every และกันเตือนซ้ำ
  5. แผงอากาศเมืองหลัก

หมายเหตุ

ข้อมูลจาก Open-Meteo ฟรีสำหรับใช้งานส่วนตัว (ไม่ต้องใช้ key) การเตือนฝนทำงานเฉพาะตอนเปิด Claude Code อยู่

Source 2 files
hooks/register.tsx 401 lines
1/**
2 * ============================================================================
3 * MOD 29: Weather Forecast TH - พยากรณ์อากาศจริงจาก Open-Meteo (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: แอปพยากรณ์อากาศบนมือถือ
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. อากาศตอนนี้: อุณหภูมิ, รู้สึกเหมือน, ความชื้น, ลม, UV, PM2.5 และ AQI
9 * 2. รายชั่วโมง 12 ชม. และรายวัน 7 วัน พร้อมโอกาสฝนตก
10 * 3. ค้นเมืองได้ทั้งภาษาไทยและอังกฤษ ตั้งเมืองหลัก และเมืองโปรด
11 * 4. เตือนเมื่อฝนน่าจะตกใน 2 ชม. ข้างหน้า (เช็คทุก 30 นาทีขณะเปิด Claude Code)
12 * 5. แผงด้านข้างแสดงอากาศเมืองหลัก
13 * 6. /weather radar - เรดาร์ฝนรอบเมือง ~250 x 130 กม. เคลื่อนไหว 6 ชั่วโมงข้างหน้า
14 *    (ขอพยากรณ์ 231 จุดในการเรียกครั้งเดียว แล้ววาดเป็นตารางสีด้วย grid.tsx)
15 *
16 * แหล่งข้อมูล: Open-Meteo (https://open-meteo.com) ฟรี ไม่ต้องใช้ key สำหรับใช้งานส่วนตัว
17 */
18
19import type { EngineInterface, Register, Timer } from 'claude-code'
20import { Grid, drawGrid, rgb } from './grid'
21
22// ==========================================================================
23// เรดาร์ฝน: ตาราง 21 x 11 จุด ห่างกัน 0.12 องศา (~13 กม.) รอบเมือง
24// ==========================================================================
25const RADAR_PANE = 'weather-radar-th'
26const R_COLS = 21
27const R_ROWS = 11
28const R_STEP = 0.12
29let radar: { place: Place; times: string[]; frames: number[][][] } | undefined
30let radarFrame = 0
31let radarTimer: Timer | undefined
32
33function rainColor(mm: number): number | undefined {
34  if (mm < 0.1) return undefined
35  if (mm < 1) return rgb(120, 200, 255)
36  if (mm < 4) return rgb(40, 110, 240)
37  if (mm < 10) return rgb(255, 210, 40)
38  return rgb(240, 50, 90)
39}
40
41function radarGrid(frame: number[][], isCenterBlink: boolean): Grid {
42  const g = new Grid(R_COLS * 2, R_ROWS, rgb(20, 40, 30))
43  frame.forEach((row, r) =>
44    row.forEach((mm, c) => {
45      const color = rainColor(mm)
46      // แต่ละจุดกว้าง 2 ช่อง เพราะช่องตัวอักษรสูงเป็น 2 เท่าของกว้าง
47      if (color) {
48        g.set(c * 2, r, '█', color)
49        g.set(c * 2 + 1, r, '█', color)
50      } else if ((r + c) % 4 === 0) g.set(c * 2, r, '·', rgb(60, 90, 70))
51    }),
52  )
53  g.set(R_COLS, Math.floor(R_ROWS / 2), isCenterBlink ? '◉' : '○', rgb(255, 255, 255))
54  return g
55}
56
57function direction(dr: number, dc: number): string {
58  const ang = (Math.atan2(-dr, dc) * 180) / Math.PI
59  const names = ['ตะวันออก', 'ตะวันออกเฉียงเหนือ', 'เหนือ', 'ตะวันตกเฉียงเหนือ', 'ตะวันตก', 'ตะวันตกเฉียงใต้', 'ใต้', 'ตะวันออกเฉียงใต้']
60  return names[((Math.round(ang / 45) % 8) + 8) % 8] ?? ''
61}
62
63async function loadRadar($: EngineInterface, place: Place): Promise<string> {
64  const lats: number[] = []
65  const lons: number[] = []
66  for (let r = 0; r < R_ROWS; r++) for (let c = 0; c < R_COLS; c++) {
67    lats.push(+(place.lat + (Math.floor(R_ROWS / 2) - r) * R_STEP).toFixed(3))
68    lons.push(+(place.lon + (c - Math.floor(R_COLS / 2)) * R_STEP).toFixed(3))
69  }
70  const res = await $.http.fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lats.join(',')}&longitude=${lons.join(',')}&hourly=precipitation&forecast_hours=6&timezone=auto`, { headers: { 'User-Agent': 'claude-code-weather-th' } })
71  if (!res.ok) throw new Error(`HTTP ${res.status}`)
72  const points = JSON.parse(res.text) as { hourly: { time: string[]; precipitation: number[] } }[]
73  const times = points[0]?.hourly.time ?? []
74  const frames = times.map((_, h) => Array.from({ length: R_ROWS }, (_r, r) => Array.from({ length: R_COLS }, (_c, c) => points[r * R_COLS + c]?.hourly.precipitation[h] ?? 0)))
75  radar = { place, times, frames }
76  radarFrame = 0
77  // สรุปเป็นข้อความ: แต่ละชั่วโมงฝนกี่ % ของพื้นที่ และกลุ่มฝนหนักสุดอยู่ทิศไหน
78  const lines = frames.map((f, h) => {
79    const cells = f.flat()
80    const wet = cells.filter(v => v >= 0.1).length
81    let best = { v: 0, r: 0, c: 0 }
82    f.forEach((row, r) => row.forEach((v, c) => v > best.v && (best = { v, r, c })))
83    const here = f[Math.floor(R_ROWS / 2)]?.[Math.floor(R_COLS / 2)] ?? 0
84    const where = best.v >= 0.1 ? ` หนักสุด ${best.v.toFixed(1)} มม. ทาง${direction(best.r - Math.floor(R_ROWS / 2), best.c - Math.floor(R_COLS / 2))} ~${Math.round(Math.hypot(best.r - 5, best.c - 10) * 13)} กม.` : ''
85    return `  ${(times[h] ?? '').slice(11, 16)}  ${here >= 0.1 ? '☔ ตกที่นี่' : '      '} ฝน ${Math.round((wet / cells.length) * 100)}% ของพื้นที่${where}`
86  })
87  return [`📡 เรดาร์ฝนรอบ ${place.name} (~${Math.round(R_COLS * R_STEP * 111)} x ${Math.round(R_ROWS * R_STEP * 111)} กม.)`, ...lines, 'ภาพเคลื่อนไหวอยู่ในแผง · ฟ้า=ปรอย น้ำเงิน=ปานกลาง เหลือง=หนัก แดง=หนักมาก'].join('\n')
88}
89
90interface Place {
91  name: string
92  admin?: string
93  lat: number
94  lon: number
95}
96interface Weather {
97  place: Place
98  current: Record<string, number>
99  hourly: { time: string[]; temperature_2m: number[]; precipitation_probability: number[]; weather_code: number[] }
100  daily: { time: string[]; weather_code: number[]; temperature_2m_max: number[]; temperature_2m_min: number[]; precipitation_probability_max: number[]; uv_index_max: number[] }
101  air?: { pm2_5?: number; us_aqi?: number }
102  fetchedAt: number
103}
104
105const PANE = 'weather-forecast-th'
106let home: Place | undefined
107let favorites: Place[] = []
108let lastHome: Weather | undefined
109let alertTimer: Timer | undefined
110let alertedFor = '' // กันเตือนซ้ำช่วงเดียวกัน
111
112// ==========================================================================
113// บทที่ 1: รหัสสภาพอากาศ (WMO) -> ข้อความไทย + emoji
114// ==========================================================================
115function describe(code: number): string {
116  if (code === 0) return '☀️ แจ่มใส'
117  if (code <= 2) return '🌤️ มีเมฆบางส่วน'
118  if (code === 3) return '☁️ เมฆมาก'
119  if (code <= 48) return '🌫️ หมอก'
120  if (code <= 57) return '🌦️ ฝนปรอยๆ'
121  if (code <= 67) return '🌧️ ฝนตก'
122  if (code <= 77) return '🌨️ หิมะ'
123  if (code <= 82) return '🌧️ ฝนซู่'
124  if (code <= 86) return '🌨️ หิมะตกหนัก'
125  return '⛈️ พายุฝนฟ้าคะนอง'
126}
127
128function aqiText(aqi?: number): string {
129  if (aqi === undefined) return '-'
130  if (aqi <= 50) return `🟢 ${aqi} ดี`
131  if (aqi <= 100) return `🟡 ${aqi} ปานกลาง`
132  if (aqi <= 150) return `🟠 ${aqi} เริ่มมีผลต่อกลุ่มเสี่ยง`
133  if (aqi <= 200) return `🔴 ${aqi} มีผลต่อสุขภาพ`
134  return `🟣 ${aqi} อันตราย`
135}
136
137function uvText(uv: number): string {
138  return uv < 3 ? `${uv.toFixed(0)} ต่ำ` : uv < 6 ? `${uv.toFixed(0)} ปานกลาง` : uv < 8 ? `${uv.toFixed(0)} สูง ☂️` : `${uv.toFixed(0)} สูงมาก ⚠️`
139}
140
141// ==========================================================================
142// บทที่ 2: เรียก Open-Meteo - ค้นเมือง (geocoding), พยากรณ์, คุณภาพอากาศ
143// ==========================================================================
144async function getJson($: EngineInterface, url: string): Promise<Record<string, unknown>> {
145  const res = await $.http.fetch(url, { headers: { 'User-Agent': 'claude-code-weather-th' } })
146  if (!res.ok) throw new Error(`HTTP ${res.status}`)
147  return JSON.parse(res.text) as Record<string, unknown>
148}
149
150async function findPlace($: EngineInterface, query: string): Promise<Place | undefined> {
151  const body = await getJson($, `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=1&language=th`)
152  const r = (body.results as { name: string; admin1?: string; latitude: number; longitude: number }[] | undefined)?.[0]
153  return r ? { name: r.name, admin: r.admin1, lat: r.latitude, lon: r.longitude } : undefined
154}
155
156async function forecast($: EngineInterface, place: Place): Promise<Weather> {
157  const q = `latitude=${place.lat}&longitude=${place.lon}&timezone=auto`
158  const [wx, air] = await Promise.all([
159    getJson($, `https://api.open-meteo.com/v1/forecast?${q}&current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,uv_index,precipitation&hourly=temperature_2m,precipitation_probability,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,uv_index_max&forecast_days=7`),
160    getJson($, `https://air-quality-api.open-meteo.com/v1/air-quality?${q}&current=pm2_5,us_aqi`).catch(() => undefined),
161  ])
162  return {
163    place,
164    current: wx.current as Record<string, number>,
165    hourly: wx.hourly as Weather['hourly'],
166    daily: wx.daily as Weather['daily'],
167    air: air?.current as Weather['air'],
168    fetchedAt: await $.clock.now(),
169  }
170}
171
172// ชั่วโมงถัดจากตอนนี้ (เวลาใน hourly เป็นเวลาท้องถิ่นของเมืองนั้น เช่น "2026-10-24T14:00")
173function upcomingHours(w: Weather, count: number): number[] {
174  const nowIso = String(w.current.time ?? '')
175  const start = Math.max(0, w.hourly.time.findIndex(t => t >= nowIso.slice(0, 13)))
176  return Array.from({ length: count }, (_, i) => start + i).filter(i => i < w.hourly.time.length)
177}
178
179// ==========================================================================
180// บทที่ 3: จัดรูปแบบ
181// ==========================================================================
182function placeName(p: Place): string {
183  return p.admin && p.admin !== p.name ? `${p.name}, ${p.admin}` : p.name
184}
185
186function nowText(w: Weather): string {
187  const c = w.current
188  const rainSoon = upcomingHours(w, 3).map(i => w.hourly.precipitation_probability[i] ?? 0)
189  return [
190    `📍 ${placeName(w.place)}`,
191    `${describe(c.weather_code ?? 0)}  ${c.temperature_2m?.toFixed(1)}°C (รู้สึกเหมือน ${c.apparent_temperature?.toFixed(1)}°C)`,
192    `💧 ความชื้น ${c.relative_humidity_2m}% | 🌬️ ลม ${c.wind_speed_10m} กม./ชม. | 🔆 UV ${uvText(c.uv_index ?? 0)}`,
193    `😷 PM2.5 ${w.air?.pm2_5?.toFixed(0) ?? '-'} µg/m³ | AQI ${aqiText(w.air?.us_aqi)}`,
194    `☔ โอกาสฝน 3 ชม. ข้างหน้า: ${rainSoon.map(p => `${p}%`).join(' → ')}`,
195  ].join('\n')
196}
197
198function hourlyText(w: Weather): string {
199  return [`🕐 รายชั่วโมง ${placeName(w.place)}`, ...upcomingHours(w, 12).map(i => `  ${(w.hourly.time[i] ?? '').slice(11, 16)}  ${describe(w.hourly.weather_code[i] ?? 0).padEnd(16)} ${w.hourly.temperature_2m[i]?.toFixed(0)}°C  ☔${w.hourly.precipitation_probability[i] ?? 0}%`)].join('\n')
200}
201
202const DAY_TH = ['อา', 'จ', 'อ', 'พ', 'พฤ', 'ศ', 'ส']
203function dailyText(w: Weather): string {
204  return [
205    `📅 7 วัน ${placeName(w.place)}`,
206    ...w.daily.time.map((t, i) => {
207      const d = new Date(`${t}T12:00`)
208      return `  ${DAY_TH[d.getDay()]} ${d.getDate()}/${d.getMonth() + 1}  ${describe(w.daily.weather_code[i] ?? 0).padEnd(16)} ${w.daily.temperature_2m_min[i]?.toFixed(0)}-${w.daily.temperature_2m_max[i]?.toFixed(0)}°C  ☔${w.daily.precipitation_probability_max[i] ?? 0}%  UV ${w.daily.uv_index_max[i]?.toFixed(0)}`
209    }),
210  ].join('\n')
211}
212
213// ==========================================================================
214// บทที่ 4: เตือนฝน - เช็คทุก 30 นาที ถ้า 2 ชม. ข้างหน้าโอกาสฝน >= 70%
215// ==========================================================================
216async function checkRain($: EngineInterface): Promise<void> {
217  if (!home) return
218  const w = await forecast($, home)
219  lastHome = w
220  $.ui.invalidate('ui.render')
221  const next2 = upcomingHours(w, 2)
222  const max = Math.max(...next2.map(i => w.hourly.precipitation_probability[i] ?? 0))
223  const slot = w.hourly.time[next2[0] ?? 0] ?? ''
224  if (max >= 70 && alertedFor !== slot) {
225    alertedFor = slot
226    $.ui.toast(`🌧️ ${home.name}: โอกาสฝนตก ${max}% ใน 2 ชม. ข้างหน้า พกร่มด้วย`)
227  }
228}
229
230async function save($: EngineInterface): Promise<void> {
231  await $.store.set('weather', { home, favorites, alerts: Boolean(alertTimer) })
232}
233
234const HELP = `🌦️ Weather Forecast TH (Mod 29)
235========================================
236/weather [เมือง]          อากาศตอนนี้ (ไม่ใส่ = เมืองหลัก)
237/weather hourly [เมือง]   รายชั่วโมง 12 ชม.
238/weather week [เมือง]     7 วัน
239/weather home <เมือง>     ตั้งเมืองหลัก
240/weather fav add|remove <เมือง> | fav   เมืองโปรด
241/weather alert on|off     เตือนเมื่อฝนน่าจะตกที่เมืองหลัก
242/weather radar [เมือง]    เรดาร์ฝนเคลื่อนไหว 6 ชม. ข้างหน้า
243เมืองพิมพ์ไทยหรืออังกฤษก็ได้ เช่น เชียงใหม่, Hat Yai, Tokyo`
244
245export const register: Register = on => {
246  on('session.start', async ($, e, next) => {
247    const saved = (await $.store.get('weather')) as { home?: Place; favorites?: Place[]; alerts?: boolean } | undefined
248    home = saved?.home
249    favorites = saved?.favorites ?? []
250    alertTimer?.cancel()
251    alertTimer = saved?.alerts ? $.clock.every(30 * 60_000, () => void checkRain($).catch(() => undefined)) : undefined
252    await $.command.register({ name: 'weather', description: 'พยากรณ์อากาศ ฝน UV PM2.5 (/weather help)', argumentHint: '[เมือง]|hourly|week|home|alert' })
253    return next(e)
254  })
255
256  // ==========================================================================
257  // บทที่ 5: แผงอากาศเมืองหลัก
258  // ==========================================================================
259  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
260    const { Box, Text } = $.ui.resolve(e)
261    const w = lastHome
262    if (!w) return <Text dimColor>ตั้งเมืองหลักด้วย /weather home กรุงเทพ</Text>
263    return (
264      <Box flexDirection="column">
265        <Text bold>📍 {w.place.name}</Text>
266        <Text>
267          {describe(w.current.weather_code ?? 0)} {w.current.temperature_2m?.toFixed(0)}°C
268        </Text>
269        <Text dimColor>
270          💧{w.current.relative_humidity_2m}% · PM2.5 {w.air?.pm2_5?.toFixed(0) ?? '-'}
271        </Text>
272        {upcomingHours(w, 6).map(i => (
273          <Text>
274            {(w.hourly.time[i] ?? '').slice(11, 16)} {w.hourly.temperature_2m[i]?.toFixed(0)}° ☔{w.hourly.precipitation_probability[i] ?? 0}%
275          </Text>
276        ))}
277      </Box>
278    )
279  })
280
281  on('ui.render', { component: 'Pane', requestId: RADAR_PANE }, async ($, e) => {
282    const els = $.ui.resolve(e)
283    const { Box, Text } = els
284    const rd = radar
285    if (!rd) return <Text dimColor>/weather radar</Text>
286    const f = radarFrame % Math.max(1, rd.frames.length)
287    return (
288      <Box flexDirection="column">
289        <Text bold>{`📡 ${rd.place.name} · ${(rd.times[f] ?? '').slice(11, 16)} ${f === 0 ? '(ตอนนี้)' : `(+${f} ชม.)`}`}</Text>
290        {drawGrid(els, e.surface, radarGrid(rd.frames[f] ?? [], radarFrame % 2 === 0), 'radar', `เรดาร์ฝนรอบ ${rd.place.name}`) as never}
291        <Text>
292          <Text color="#78c8ff">█</Text> ปรอย <Text color="#286ef0">█</Text> ปานกลาง <Text color="#ffd228">█</Text> หนัก <Text color="#f0325a">█</Text> หนักมาก ◉ คุณ
293        </Text>
294      </Box>
295    )
296  })
297
298  on('ui.close', async ($, e, next) => {
299    if (e.id === RADAR_PANE) {
300      radarTimer?.cancel()
301      radarTimer = undefined
302    }
303    return next(e)
304  })
305
306  on('command.run', { command: 'weather' }, async ($, e) => {
307    const words = e.args.trim().split(/\s+/).filter(Boolean)
308    const [sub = ''] = words
309
310    // หาเมืองจากข้อความ ไม่ใส่ = เมืองหลัก
311    const resolvePlace = async (text: string): Promise<Place | string> => {
312      if (!text) return home ?? 'ยังไม่ได้ตั้งเมืองหลัก ใช้ /weather home กรุงเทพ หรือ /weather <เมือง>'
313      return (await findPlace($, text)) ?? `❌ ไม่พบเมือง "${text}"`
314    }
315
316    try {
317      switch (sub) {
318        case 'help':
319          return { text: HELP }
320        case 'radar': {
321          const p = await resolvePlace(words.slice(1).join(' '))
322          if (typeof p === 'string') return { text: p }
323          const text = await loadRadar($, p)
324          await $.ui.open({ id: RADAR_PANE, title: '📡 Rain Radar' })
325          radarTimer?.cancel()
326          // วนภาพ 6 ชั่วโมง เฟรมละ 0.8 วินาที เฉพาะตอนแผงเปิด
327          radarTimer = $.clock.every(800, () => {
328            radarFrame++
329            $.ui.invalidate('ui.render')
330          })
331          return { text }
332        }
333        case 'hourly':
334        case 'week': {
335          const p = await resolvePlace(words.slice(1).join(' '))
336          if (typeof p === 'string') return { text: p }
337          const w = await forecast($, p)
338          return { text: sub === 'hourly' ? hourlyText(w) : dailyText(w) }
339        }
340        case 'home': {
341          const p = await resolvePlace(words.slice(1).join(' '))
342          if (typeof p === 'string') return { text: p }
343          home = p
344          await save($)
345          lastHome = await forecast($, p)
346          await $.ui.open({ id: PANE, title: '🌦️ Weather' })
347          $.ui.invalidate('ui.render')
348          return { text: `🏠 ตั้ง ${placeName(p)} เป็นเมืองหลักแล้ว\n\n${nowText(lastHome)}` }
349        }
350        case 'fav': {
351          const [, action, ...rest] = words
352          if (action === 'add' || action === 'remove') {
353            const p = await resolvePlace(rest.join(' '))
354            if (typeof p === 'string') return { text: p }
355            favorites = action === 'add' ? [...favorites.filter(f => f.name !== p.name), p] : favorites.filter(f => f.name !== p.name)
356            await save($)
357            return { text: `⭐ เมืองโปรด: ${favorites.map(f => f.name).join(', ') || '-'}` }
358          }
359          if (favorites.length === 0) return { text: 'ยังไม่มีเมืองโปรด /weather fav add เชียงใหม่' }
360          const all = await Promise.all(favorites.map(f => forecast($, f).catch(() => undefined)))
361          return { text: ['⭐ เมืองโปรด', ...all.map((w, i) => (w ? `  ${w.place.name.padEnd(14)} ${describe(w.current.weather_code ?? 0)} ${w.current.temperature_2m?.toFixed(0)}°C  PM2.5 ${w.air?.pm2_5?.toFixed(0) ?? '-'}` : `  ${favorites[i]?.name}: ดึงข้อมูลไม่ได้`))].join('\n') }
362        }
363        case 'alert': {
364          alertTimer?.cancel()
365          alertTimer = undefined
366          if (words[1] === 'on') {
367            if (!home) return { text: 'ตั้งเมืองหลักก่อน /weather home กรุงเทพ' }
368            alertTimer = $.clock.every(30 * 60_000, () => void checkRain($).catch(() => undefined))
369            await save($)
370            await checkRain($)
371            return { text: `🔔 จะเตือนเมื่อฝนน่าจะตกที่ ${home.name} (เช็คทุก 30 นาทีขณะเปิด Claude Code)` }
372          }
373          await save($)
374          return { text: '🔕 ปิดการเตือนฝนแล้ว' }
375        }
376        default: {
377          const p = await resolvePlace(words.join(' '))
378          if (typeof p === 'string') return { text: p }
379          const w = await forecast($, p)
380          if (home && p.name === home.name) {
381            lastHome = w
382            $.ui.invalidate('ui.render')
383          }
384          return { text: `${nowText(w)}\n\nแหล่งข้อมูล: Open-Meteo` }
385        }
386      }
387    } catch (err) {
388      return { text: `❌ ดึงข้อมูลอากาศไม่ได้: ${err instanceof Error ? err.message : String(err)}` }
389    }
390  })
391}
392
393/**
394 * ========================================================================
395 * การบ้าน / ต่อยอด
396 * ========================================================================
397 * 1. ส่งพยากรณ์ตอนเช้าไปมือถือทุกวัน 7 โมง (ต่อกับ Mod 22 และ Mod 24)
398 * 2. เตือน PM2.5 เกิน 50 ให้ใส่หน้ากาก
399 * 3. วาดกราฟอุณหภูมิรายชั่วโมงเป็นแท่ง (ดูตัวอย่างใน Mod 16)
400 */
401
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