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

พยากรณ์อากาศจริงจาก 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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
ข้อมูลจาก Open-Meteo ฟรีสำหรับใช้งานส่วนตัว (ไม่ต้องใช้ key) การเตือนฝนทำงานเฉพาะตอนเปิด Claude Code อยู่
hooks/register.tsx 401 lines1/**
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}¤t=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}¤t=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 */
401hooks/grid.tsx 154 lines1/**
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, '&').replace(/</g, '<').replace(/>/g, '>')
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