ภาษาไทย
🆓 ใช้ฟรี
วงล้อสะกดคำ
วงล้อสะกดคำ และ บัตรคำภาษาไทย ป.1
Prompt สำหรับสร้างแอปแบบนี้เอง
คัดลอกไปวางกับ AI เพื่อดัดแปลงเป็นเวอร์ชันของคุณเองได้
# Prompt: วงล้อสุ่มสะกดคำ — Thai Phonics Concentric Wheel App
> **Reusable build prompt** สำหรับสร้างแอปวงล้อประสมพยางค์ภาษาไทย
> ใช้ซ้ำได้สำหรับ: สร้างใหม่, แก้บัค, เพิ่มฟีเจอร์, เปลี่ยน UI
---
## 🎯 สิ่งที่แอปนี้ทำ
แอปเดี่ยว offline HTML — วงล้อ SVG แบบซ้อนวง ออกแบบสำหรับ:
- **ครูฉาย TV / projector** landscape ในห้องเรียนภาษาไทยระดับประถม
- **นักเรียน** เลือกและสุ่มพยางค์ด้วยตัวเอง (แท็บเล็ต)
มี 3 โหมดวงล้อ + 1 แท็บบัตรคำ:
1. **2 วง (แม่ ก กา)** — วงนอก = พยัญชนะต้น 44, วงกลาง = สระ 24
2. **3 วง (มีตัวสะกด)** — เพิ่มวง = ตัวสะกด 8 มาตรา + ไม่มี
3. **4 วง (วรรณยุกต์)** — เพิ่มวงในสุด = วรรณยุกต์ 5 (สามัญ เอก โท ตรี จัตวา)
4. **บัตรคำ ป.1** — แท็บแยก โหมดบัตรคำสุ่ม (1 พยางค์ / หลายพยางค์)
ใช้ตัวแปร `mode` (2/3/4) สำหรับวงล้อ และ `view` ('wheel'/'card') แยกบัตรคำ
**การโต้ตอบ:**
- กดปุ่มแมวกลาง / ปุ่มสุ่ม → หมุนทุกวงพร้อมกัน → ผลลัพธ์ขึ้น popup เต็มจอ
- แตะช่องสีในวงไหนก็ได้ → สปินวงนั้นไปยังช่องที่เลือก (ครูเลือกเองได้)
- Live preview bar → แตะเพื่อเปิด popup ใหม่
- Space / Enter บน keyboard = สุ่ม, Escape = ปิด popup
---
## 📋 Prompt หลัก (ส่งให้ Claude)
```
สร้าง single-file offline HTML app ชื่อ "วงล้อสุ่มสะกดคำ"
ภาษา: HTML + vanilla JS + CSS เท่านั้น
ห้ามใช้ framework, library, หรือ CDN
ฟอนต์: ฝังด้วย base64 woff2 (Mali 400/700, Itim 400 — Thai subset จาก @fontsource)
เป้าหมาย: ครูใช้บนแท็บเล็ต + ฉาย TV landscape
```
---
## 🗄️ Data Structures
### พยัญชนะต้น 44 ตัว
```js
const CONS = [
'ก','ข','ฃ','ค','ฅ','ฆ','ง',
'จ','ฉ','ช','ซ','ฌ','ญ',
'ฎ','ฏ','ฐ','ฑ','ฒ','ณ',
'ด','ต','ถ','ท','ธ','น',
'บ','ป','ผ','ฝ','พ','ฟ','ภ','ม',
'ย','ร','ล','ว',
'ศ','ษ','ส','ห','ฬ','อ','ฮ'
];
```
### สระ 24 ตัว + กฎเปลี่ยนรูป/ลดรูป + วรรณยุกต์
```js
// disp: ป้ายแสดงในวง — ใช้ อ เป็นตัวแทนพยัญชนะ (อะ อา เอีย ...)
// name: ชื่อสระ เช่น "สระอะ"
// tf: true = มีตัวสะกดได้ (ใช้ใน mode>=3)
// r(c, f, t): ประสมพยางค์ — c=พยัญชนะต้น, f=ตัวสะกด ('' ถ้าไม่มี), t=วรรณยุกต์ ('' ถ้าสามัญ)
// t ต้องวางบนพยัญชนะต้น (หลังพยัญชนะ ก่อนสระบน/ตัวสะกด) ตามหลักไทย
const VOWELS = [
{disp:'อะ', name:'สระอะ', tf:true, r:(c,f,t)=> f? c+'ั'+t+f : c+t+'ะ'},
{disp:'อา', name:'สระอา', tf:true, r:(c,f,t)=> c+t+'า'+f},
{disp:'อิ', name:'สระอิ', tf:true, r:(c,f,t)=> c+'ิ'+t+f},
{disp:'อี', name:'สระอี', tf:true, r:(c,f,t)=> c+'ี'+t+f},
{disp:'อึ', name:'สระอึ', tf:true, r:(c,f,t)=> c+'ึ'+t+f},
{disp:'อือ',name:'สระอือ',tf:true, r:(c,f,t)=> f? c+'ื'+t+f : c+'ื'+t+'อ'},
{disp:'อุ', name:'สระอุ', tf:true, r:(c,f,t)=> c+'ุ'+t+f},
{disp:'อู', name:'สระอู', tf:true, r:(c,f,t)=> c+'ู'+t+f},
{disp:'เอะ',name:'สระเอะ',tf:true, r:(c,f,t)=> f? 'เ'+c+(t||'็')+f : 'เ'+c+t+'ะ'},
{disp:'เอ', name:'สระเอ', tf:true, r:(c,f,t)=> 'เ'+c+t+f},
{disp:'แอะ',name:'สระแอะ',tf:true, r:(c,f,t)=> f? 'แ'+c+(t||'็')+f : 'แ'+c+t+'ะ'},
{disp:'แอ', name:'สระแอ', tf:true, r:(c,f,t)=> 'แ'+c+t+f},
{disp:'โอะ',name:'สระโอะ',tf:true, r:(c,f,t)=> f? c+t+f : 'โ'+c+t+'ะ'}, // ลดรูปหมด: นก คน
{disp:'โอ', name:'สระโอ', tf:true, r:(c,f,t)=> 'โ'+c+t+f},
{disp:'เอาะ',name:'สระเอาะ',tf:true,r:(c,f,t)=> f? c+(t||'็')+'อ'+f : 'เ'+c+t+'าะ'}, // ล็อก
{disp:'ออ', name:'สระออ', tf:true, r:(c,f,t)=> c+t+'อ'+f},
{disp:'เออ',name:'สระเออ',tf:true, r:(c,f,t)=> f? (f==='ย'?'เ'+c+t+'ย':'เ'+c+'ิ'+t+f) : 'เ'+c+t+'อ'},
{disp:'เอีย',name:'สระเอีย',tf:true, r:(c,f,t)=> 'เ'+c+'ี'+t+'ย'+f},
{disp:'เอือ',name:'สระเอือ',tf:true, r:(c,f,t)=> 'เ'+c+'ื'+t+'อ'+f},
{disp:'อัว',name:'สระอัว',tf:true, r:(c,f,t)=> f? c+t+'ว'+f : c+'ั'+t+'ว'}, // ลดรูป: สวน
{disp:'อำ', name:'สระอำ', tf:false, r:(c,f,t)=> c+t+'ำ'},
{disp:'ไอ', name:'สระไอ', tf:false, r:(c,f,t)=> 'ไ'+c+t},
{disp:'ใอ', name:'สระใอ', tf:false, r:(c,f,t)=> 'ใ'+c+t},
{disp:'เอา',name:'สระเอา',tf:false, r:(c,f,t)=> 'เ'+c+t+'า'},
];
```
> **หมายเหตุสำคัญ**: ฟังก์ชัน r(c,f,t) มี **3 params** (รองรับวรรณยุกต์ เหมือน slot app)
> โหมด 2 วง ส่ง f='' t='' / โหมด 3 วง ส่ง t='' / โหมด 4 วง ส่งครบ
> โหมด >=3 ใช้เฉพาะสระ tf=true (20 ตัว)
### วรรณยุกต์ 5 ช่อง (วงในสุด — โหมด 4 วงเท่านั้น)
```js
const TONES = [
{mark:'', name:'สามัญ', label:'อ', hex:'#c9bfe0'},
{mark:'่', name:'เอก', label:'อ่', hex:'#FF595E'},
{mark:'้', name:'โท', label:'อ้', hex:'#3A86FF'},
{mark:'๊', name:'ตรี', label:'อ๊', hex:'#8AC926'},
{mark:'๋', name:'จัตวา',label:'อ๋', hex:'#FF9F1C'},
];
```
### ตัวสะกด (ตัวแทนมาตราละ 1 ตัว)
```js
const FIN = [
{ch:'', label:'—', maatra:'แม่ ก กา', hex:'#c9bfe0'},
{ch:'ก', label:'ก', maatra:'แม่กก', hex:'#3A86FF'},
{ch:'ด', label:'ด', maatra:'แม่กด', hex:'#FF595E'},
{ch:'บ', label:'บ', maatra:'แม่กบ', hex:'#FF9F1C'},
{ch:'ง', label:'ง', maatra:'แม่กง', hex:'#8AC926'},
{ch:'ม', label:'ม', maatra:'แม่กม', hex:'#845EC2'},
{ch:'น', label:'น', maatra:'แม่กน', hex:'#FF5DA2'},
{ch:'ย', label:'ย', maatra:'แม่เกย', hex:'#FFC300'},
{ch:'ว', label:'ว', maatra:'แม่เกอว', hex:'#1FA37A'},
];
```
---
## ⚙️ Core Architecture
### SVG วงล้อ
ใช้ SVG `viewBox="0 0 100 100"` — ทุกค่าพิกัดในหน่วย 0–100
**ขนาดวง (radii) ตามโหมด:**
| โหมด | วงนอก (CONS) | วงกลาง (VOWEL) | ตัวสะกด (FIN) | วรรณยุกต์ (TONE) | Hub |
|------|-------------|---------------|--------------|-----------------|-----|
| 2 วง | rIn=30, rOut=49 | rIn=13, rOut=30 | — | — | r=13, 25% |
| 3 วง | rIn=33, rOut=49 | rIn=21, rOut=33 | rIn=11, rOut=21 | — | r=11, 21% |
| 4 วง | rIn=36, rOut=49 | rIn=25, rOut=36 | rIn=15, rOut=25 | rIn=8, rOut=15 | r=8, 16% |
**สีวง:**
```js
// วงนอก (CONS): rainbow vivid
fillFn = i => `hsl(${i/CONS.length*360} 72% 56%)`
// วงกลาง (VOWEL): rainbow pastel
fillFn = i => `hsl(${i/vowels.length*360} 78% 78%)`
// วงใน (FIN): ใช้ hex จาก FIN array โดยตรง
fillFn = i => FIN[i].hex
// วงในสุด (TONE, โหมด 4 วง): ใช้ hex จาก TONES array
fillFn = i => TONES[i].hex
```
### buildRing() — สร้าง SVG ring จาก array
```js
function polar(cx, cy, r, deg) {
const a = deg * Math.PI / 180;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
}
// path ของ 1 segment (annular arc)
function ringSeg(rIn, rOut, a0, a1) {
const [xo0,yo0] = polar(50,50,rOut,a0), [xo1,yo1] = polar(50,50,rOut,a1);
const [xi1,yi1] = polar(50,50,rIn, a1), [xi0,yi0] = polar(50,50,rIn, a0);
const big = (a1-a0) > 180 ? 1 : 0;
return `M${xo0} ${yo0} A${rOut} ${rOut} 0 ${big} 1 ${xo1} ${yo1} `
+ `L${xi1} ${yi1} A${rIn} ${rIn} 0 ${big} 0 ${xi0} ${yi0} Z`;
}
function buildRing(list, rIn, rOut, fillFn, labelFn, fs, textFill) {
const n = list.length, step = 360/n;
let s = '';
for (let i = 0; i < n; i++) {
const a0 = -90 + i*step, a1 = -90 + (i+1)*step, mid = -90 + (i+0.5)*step;
// path segment
s += `<path class="seg" data-i="${i}" d="${ringSeg(rIn,rOut,a0,a1)}" `
+ `fill="${fillFn(i)}" stroke="#fff" stroke-width=".7"/>`;
// label text (radial orientation: rotate mid+90)
const rr = (rIn+rOut)/2, [tx,ty] = polar(50,50,rr,mid);
s += `<g transform="rotate(${mid+90} ${tx} ${ty})" style="pointer-events:none">`
+ `<text x="${tx}" y="${ty}" text-anchor="middle" dominant-baseline="central" `
+ `font-size="${fs}" fill="${textFill}" font-family="Itim">${labelFn(i)}</text></g>`;
}
return s;
}
```
**font-size แนะนำ (ตามโหมด):**
| | นอก (CONS) | สระ (VOWEL) | ตัวสะกด (FIN) | วรรณยุกต์ (TONE) |
|---|---|---|---|---|
| 2 วง | 4.2 | 3.4 | — | — |
| 3 วง | 4.2 | 3.0 | 4.4 | — |
| 4 วง | 4.0 | 2.8 | 3.8 | 3.4 |
- textFill: CONS='#fff', FIN='#fff', TONE='#fff', VOWEL='#3a2e4d'
### State & ring management
```js
let mode = 2, view = 'wheel', spinning = false; // mode: 2/3/4
// State object for each ring
const state = {
outer: { g: gOuter, rot: 0, sel: 0, list: CONS },
vowel: { g: gVowel, rot: 0, sel: 0, list: [] }, // set in buildAll()
final: { g: gFinal, rot: 0, sel: 0, list: FIN },
tone: { g: gTone, rot: 0, sel: 0, list: TONES },
};
function activeKeys() {
return mode === 2 ? ['outer','vowel']
: mode === 3 ? ['outer','vowel','final']
: ['outer','vowel','final','tone'];
}
// Set ring to position instantly (no animation)
function setInstant(st, sel) {
const n = st.list.length;
st.sel = sel;
st.rot = -((sel+0.5) * 360/n);
st.g.setAttribute('transform', `rotate(${st.rot} 50 50)`);
}
```
### Animation
```js
function easeOut(t) { return 1 - Math.pow(1-t, 3); }
// Animate one ring from current rotation to target
function animateRing(g, from, to, dur, done) {
const t0 = performance.now();
(function frame(now) {
let t = Math.min(1, (now-t0) / dur);
g.setAttribute('transform', `rotate(${from + (to-from)*easeOut(t)} 50 50)`);
if (t < 1) requestAnimationFrame(frame);
else done && done();
})(performance.now());
}
// Calculate target rotation for a segment index to appear at top pointer
// turns = number of full extra spins before landing
function targetRotation(cur, sel, n, turns) {
const step = 360/n, want = -((sel+0.5) * step);
const wm = ((want%360)+360)%360, cm = ((cur%360)+360)%360;
const delta = (wm - cm + 360) % 360;
return cur + turns*360 + delta;
}
```
### spinAll() — หมุนทุกวง
```js
function spinAll() {
if (spinning) return;
spinning = true;
catWobble(); // hub wobble animation
const keys = activeKeys();
let pending = keys.length, maxDur = 0;
keys.forEach((k, idx) => {
const st = state[k], n = st.list.length;
const sel = Math.floor(Math.random() * n);
const to = targetRotation(st.rot, sel, n, 4 + idx);
const dur = 2500 + idx*350 + Math.random()*300;
maxDur = Math.max(maxDur, dur);
animateRing(st.g, st.rot, to, dur, () => {
st.rot = to; st.sel = sel;
if (--pending === 0) { spinning = false; chime(); computeResult(true); }
});
});
spinTicks(maxDur); // deceleration tick sounds
}
```
### manualSet() — แตะช่องในวงเพื่อเลือกเอง
```js
function manualSet(key, idx) {
if (spinning) return;
spinning = true;
const st = state[key], n = st.list.length;
const to = targetRotation(st.rot, idx, n, 1);
spinTicks(680);
animateRing(st.g, st.rot, to, 720, () => {
st.rot = to; st.sel = idx;
spinning = false; chime(); computeResult(false); // false = ไม่เปิด popup อัตโนมัติ
});
}
```
### computeResult() — ประสมพยางค์ + แสดงผล
```js
function computeResult(openCelebrate) {
const c = CONS[state.outer.sel];
const v = state.vowel.list[state.vowel.sel];
let f = '', fObj = null, t = '', tObj = null;
if (mode >= 3) { fObj = FIN[state.final.sel]; f = fObj.ch; }
if (mode >= 4) { tObj = TONES[state.tone.sel]; t = tObj.mark; }
const syll = v.r(c, f, t); // 3-param: รองรับวรรณยุกต์
let eq = `<b>${c}</b> + ${v.name}`;
if (mode >= 3 && f) eq += ` + <b>${f}</b>`;
if (mode >= 4) eq += t ? ` + วรรณยุกต์${tObj.name}` : ` + เสียงสามัญ`;
const maatraChip = (mode >= 3 && f)
? `<span class="chip2" style="background:${fObj.hex}">${fObj.maatra}</span>`
: `<span class="chip2" style="background:#00BBA1">แม่ ก กา</span>`;
const toneChip = (mode >= 4)
? `<span class="chip2" style="background:#7c5cff">เสียง${tObj.name}</span>` : ``;
// Live preview bar (compact, clickable)
live.innerHTML = `<div class="lw">${syll}</div><small>แตะเพื่อแสดงเต็มจอ</small>`;
// Popup content
popContent.innerHTML = `<div class="syll">${syll}</div><div class="eq">${eq}</div>`
+ `<div class="chips">${maatraChip}${toneChip}</div>`;
if (openCelebrate) openPopup(true); // spin = auto-open + confetti
}
```
---
## 🎨 Layout
### Portrait (mobile/tablet)
```
flex-direction: column
header → tabs → legend → stage (wheel) → spinBtn → live → hint → mute
```
### Landscape (TV projection) — CSS Grid
```css
@media (min-aspect-ratio: 1/1) {
.app {
display: grid;
grid-template-columns: min(88vh,640px) minmax(240px,38vw);
grid-template-areas: "stage top" "stage bottom";
}
/* stage: วงล้อ, panel-top: header+tabs+legend, panel-bottom: spinBtn+live+mute */
}
```
### Stage (วงล้อ)
```css
.stage {
position: relative;
width: min(90vw, 420px); /* portrait */
aspect-ratio: 1/1;
}
/* landscape: width = min(88vh, 640px) */
```
### Pointer (ตัวชี้บนสุด)
```css
.pointer {
position: absolute; top: -4px; left: 50%;
transform: translateX(-50%); z-index: 6;
width: 0; height: 0;
border-left: 15px solid transparent;
border-right: 15px solid transparent;
border-top: 26px solid #ff2e63;
}
.pointer:after { /* dot above triangle */
content: ""; position: absolute;
top: -40px; left: -8px;
width: 16px; height: 16px;
border-radius: 50%; background: #ff2e63;
}
```
### Hub (ปุ่มแมวกลาง)
```css
.hub {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%); z-index: 5;
border-radius: 50%;
background: radial-gradient(circle at 50% 38%, #ffe1c2, #ffcaa0);
box-shadow: 0 0 0 5px #fff, 0 5px 12px rgba(0,0,0,.22);
}
/* 2-ring: width=25%, height=25% */
/* 3-ring: width=21%, height=21% */
```
### Popup
```css
.popup { position: fixed; inset: 0; z-index: 100; display: none;
background: rgba(40,28,60,.5); backdrop-filter: blur(3px); }
.popup.show { display: flex; animation: fade .2s ease; }
.popcard { animation: pop .45s cubic-bezier(.2,1.35,.4,1); }
.popcard .syll { font-size: clamp(70px, 26vmin, 260px); } /* ใหญ่สำหรับ TV */
```
**Popup มีปุ่ม:**
- ✕ ปิด (`popClose`)
- 🎲 หมุนอีกครั้ง (`popSpin`) → closePopup() + spinAll()
---
## 🔊 Audio
```js
let ac = null, muted = false;
function tone(f, d, type='sine', v=0.06) {
if (muted) return;
try {
ac = ac || new (window.AudioContext||window.webkitAudioContext)();
const o = ac.createOscillator(), g = ac.createGain();
o.type = type; o.frequency.value = f; g.gain.value = v;
o.connect(g); g.connect(ac.destination);
const t = ac.currentTime;
o.start(t); g.gain.exponentialRampToValueAtTime(0.0001, t+d); o.stop(t+d);
} catch(e) {}
}
const tick = () => tone(880, 0.04, 'square', 0.022);
const chime = () => { tone(660, 0.12); setTimeout(()=>tone(990,0.18), 90); };
// Decelerating tick schedule during spin
function spinTicks(dur) {
let t = 0;
(function n() {
if (t >= dur) return;
tick();
const p = t / dur;
const gap = 50 + p*p*340; // slows down: 50ms → 390ms
t += gap;
setTimeout(n, gap);
})();
}
```
---
## ⚙️ buildAll() — สร้างวงล้อใหม่เมื่อเปลี่ยนโหมด
```js
function buildAll() {
const vForMode = (mode >= 3) ? VOWELS.filter(v => v.tf) : VOWELS;
state.vowel.list = vForMode;
gFinal.innerHTML = ''; gTone.innerHTML = '';
const consFill = i => `hsl(${i/CONS.length*360} 72% 56%)`;
const vowFill = i => `hsl(${i/vForMode.length*360} 78% 78%)`;
let hubR, hubPct;
if (mode === 2) {
gOuter.innerHTML = buildRing(CONS, 30, 49, consFill, i=>CONS[i], 4.2, '#fff');
gVowel.innerHTML = buildRing(vForMode, 13, 30, vowFill, i=>vForMode[i].disp, 3.4, '#3a2e4d');
hubR = 13; hubPct = '25%';
} else if (mode === 3) {
gOuter.innerHTML = buildRing(CONS, 33, 49, consFill, i=>CONS[i], 4.2, '#fff');
gVowel.innerHTML = buildRing(vForMode, 21, 33, vowFill, i=>vForMode[i].disp, 3.0, '#3a2e4d');
gFinal.innerHTML = buildRing(FIN, 11, 21, i=>FIN[i].hex, i=>FIN[i].label, 4.4, '#fff');
hubR = 11; hubPct = '21%';
} else { // mode === 4
gOuter.innerHTML = buildRing(CONS, 36, 49, consFill, i=>CONS[i], 4.0, '#fff');
gVowel.innerHTML = buildRing(vForMode, 25, 36, vowFill, i=>vForMode[i].disp, 2.8, '#3a2e4d');
gFinal.innerHTML = buildRing(FIN, 15, 25, i=>FIN[i].hex, i=>FIN[i].label, 3.8, '#fff');
gTone.innerHTML = buildRing(TONES, 8, 15, i=>TONES[i].hex, i=>TONES[i].label, 3.4, '#fff');
hubR = 8; hubPct = '16%';
}
hubBg.setAttribute('r', hubR);
hub.style.width = hubPct; hub.style.height = hubPct;
activeKeys().forEach(k => setInstant(state[k], 0));
computeResult(false);
}
```
> SVG ต้องมี `<g>` 4 อัน: `ringOuter`, `ringVowel`, `ringFinal`, `ringTone` + `<circle id="hubBg">` ตรงกลาง
---
## 🃏 แท็บบัตรคำ ป.1 (โหมดแยก — ไม่ใช่วงล้อ)
แท็บที่ 4 สลับเป็นโหมดบัตรคำ ซ่อนวงล้อ/ปุ่มสุ่ม/legend แล้วแสดงการ์ดคำสุ่มแทน
```js
const CARDCOLS = ['#ffd06b','#ff8fab','#7fd1ff','#9ad97f','#c9a7ff','#ffb14d','#6ee7c7'];
const WORDS = {
'1': ['ตา','หู','ปู','ขา','มือ','นา','มา','ปา','ดี','ปี','งู','หมู','หมา','ม้า','ไก่',
'ปลา','ลา','เสือ','เรือ','ใบ','ไม้','ไฟ','ฝน','ลม','ฟัน','มด','นก','กบ','ปีก','รถ',
'หาง','แมว','วัว','หมี','ผม','ตัว','นม','ส้ม','ไข่','ข้าว','น้ำ','ดาว','เมฆ','ดิน',
'บ้าน','ป่า','กิน','นอน','เล่น','วิ่ง','เดิน','นั่ง','ยืน','ร้อง','มอง','ฟัง','พ่อ',
'แม่','พี่','น้อง','ปู่','ย่า','ครู','มีด','จาน','แก้ว','โต๊ะ','ดอก','ใจ','ฝา'],
'm': ['โรงเรียน','นักเรียน','ห้องเรียน','หนังสือ','ดินสอ','กระเป๋า','กระดาษ','ยางลบ',
'ไม้บรรทัด','นาฬิกา','ครอบครัว','เพื่อนบ้าน','มะม่วง','มะละกอ','แตงโม','สับปะรด',
'กล้วยไม้','มะพร้าว','ผีเสื้อ','กระต่าย','จิ้งจก','แมงมุม','ปลาทอง','นกแก้ว','ตุ๊กตา',
'รองเท้า','เสื้อผ้า','แปรงฟัน','ดอกไม้','ต้นไม้','มดแดง','ผักบุ้ง'],
};
let deck = [], deckMode = '1', lastWord = null;
function shuf(a){ for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[a[i],a[j]]=[a[j],a[i]];} return a; }
function drawCard() {
if (!deck.length) deck = shuf([...WORDS[deckMode]]);
let w = deck.pop();
if (w === lastWord && deck.length) { deck.unshift(w); w = deck.pop(); } // เลี่ยงซ้ำติดกัน
lastWord = w;
fcWord.textContent = w;
flashcard.style.borderColor = CARDCOLS[Math.floor(Math.random()*CARDCOLS.length)];
flashcard.classList.remove('in'); void flashcard.offsetWidth; flashcard.classList.add('in');
chime();
}
```
**ปุ่มสลับชุดคำ** (cTog1 / cTogM, ปุ่ม active สีเขียว):
```js
cTog1.onclick = () => { deckMode='1'; cTog1.classList.add('on'); cTogM.classList.remove('on'); deck=[]; lastWord=null; drawCard(); };
cTogM.onclick = () => { deckMode='m'; cTogM.classList.add('on'); cTog1.classList.remove('on'); deck=[]; lastWord=null; drawCard(); };
flashcard.onclick = drawCard; // แตะการ์ด = คำใหม่
cardNextBtn.onclick = drawCard; // ปุ่มบัตรถัดไป
```
> การ์ดแสดง **คำเต็มอย่างเดียว** (ไม่แยกพยางค์) เพื่อเลี่ยงความผิดพลาด — ครูสลับใช้บัญชีคำพื้นฐาน สพฐ. ภายหลังได้
### สลับแท็บ / view (วงล้อ ↔ บัตรคำ)
```js
function selectTab(id) {
['tab2','tab3','tab4','tabCard'].forEach(t =>
document.getElementById(t).classList.toggle('on', t === id));
}
function showWheel(m) {
view = 'wheel'; mode = m; selectTab('tab' + m);
appEl.classList.remove('mode-card');
stageEl.style.display=''; panelBottom.style.display=''; legendEl.style.display='';
cardModeEl.style.display='none';
setLegend(); buildAll();
}
function showCard() {
view = 'card'; selectTab('tabCard');
appEl.classList.add('mode-card');
stageEl.style.display='none'; panelBottom.style.display='none'; legendEl.style.display='none';
cardModeEl.style.display='flex';
if (!lastWord) drawCard();
}
tab2.onclick = () => showWheel(2);
tab3.onclick = () => showWheel(3);
tab4.onclick = () => showWheel(4);
tabCard.onclick = showCard;
```
---
## ⚠️ Bug Traps
| หัวข้อ | กฎ |
|---|---|
| VOWELS.disp ใช้ อ | ป้ายแสดงในวงใช้ อ เป็นตัวแทน (อะ อา เอีย) — ห้ามใส่ใน r() |
| r(c,f,t) มี 3 params | รองรับวรรณยุกต์ — t วางบนพยัญชนะต้น (หลัง c ก่อนสระบน/f) |
| โหมด 2 วงส่ง f='' t='' | โหมด 3 ส่ง t='' / โหมด 4 ส่งครบ — มิฉะนั้นวรรณยุกต์ตกหล่น |
| setInstant ต้องคำนวณ rot | `rot = -((sel+0.5) * 360/n)` เพื่อ center segment ที่ top pointer |
| targetRotation: บวก turns | บวก turns*360 ไม่งั้นวง "ถอย" แทนหมุนไปข้างหน้า |
| mode change: rebuild หมด | `buildAll()` reset ทุกวง + ล้าง gFinal/gTone ก่อน |
| ขนาด hub ตามโหมด | 2วง=25% 3วง=21% 4วง=16% เปลี่ยน style.width + .height |
| hubBg radius ตามโหมด | r=13/11/8 ต้องตรงกับ rIn ของวงในสุด |
| สระ tf=false เฉพาะ mode 2 | โหมด >=3 filter เอาเฉพาะ tf=true (20 ตัว) |
| manualSet ใช้ turns=1 | สั้นกว่า spinAll (4-5 turns) |
| computeResult(false)/(true) | manual=ไม่เปิด popup / spin=เปิด popup+confetti |
| easeOut ใช้ใน animateRing | จำเป็น (ต่างจาก slot) |
| SVG g rotate syntax | `rotate(${deg} 50 50)` — ต้อง specify center |
| Click detection rings | `e.target.closest('path.seg')` + map `g.id`→key (KEYMAP) |
| flashcard ไม่ซ้ำติดกัน | drawCard เช็ค lastWord แล้ว unshift กลับถ้าซ้ำ |
| view='card' ซ่อน UI วงล้อ | ซ่อน stage/panelBottom/legend แสดง cardMode |
---
## 📐 ขนาดไฟล์เป้าหมาย
| ส่วน | ขนาด |
|---|---|
| JS logic | ~16 KB |
| CSS | ~5 KB |
| Fonts base64 (Mali+Itim Thai subset) | ~70 KB |
| **รวม** | **~90 KB** |
---
## 🔧 Font embedding script
```python
# inject-fonts.py — เรียกใช้หลังสร้าง template
fonts = {}
for line in open('fonts_b64.txt'):
k, b = line.rstrip('\n').split('|||')
fonts[k] = b
html = open('template_wheel.html', encoding='utf-8').read()
for k in ('mali400', 'mali700', 'itim400'):
html = html.replace('###' + k.upper() + '###', fonts[k])
open('วงล้อสุ่มสะกดคำ.html', 'w', encoding='utf-8').write(html)
print('done', html.count('###'), 'placeholders left')
```
Font placeholder ใน HTML template:
```css
@font-face{font-family:'Mali';font-weight:400;src:url(data:font/woff2;base64,###MALI400###)...}
@font-face{font-family:'Mali';font-weight:700;src:url(data:font/woff2;base64,###MALI700###)...}
@font-face{font-family:'Itim';font-weight:400;src:url(data:font/woff2;base64,###ITIM400###)...}
```
---
## 🔮 Features ที่ยังไม่ได้ทำ
- **ล็อกวง** — กดล็อกวงใดวงหนึ่ง แล้วสุ่มเฉพาะวงที่เหลือ (เหมือน slot app)
- **Step ▲▼** — ปุ่มเลื่อนทีละช่อง hold-to-repeat
- **คลังคำ สพฐ.** — สลับบัตรคำ ป.1 ไปใช้บัญชีคำพื้นฐานทางการ
- **วงล้อมาตราตัวสะกด** — แยกแอปที่ 1 (9 segments, learn+quiz) ดูหมายเหตุด้านล่าง
---
## 📌 หมายเหตุ: แอปในเซสชันนี้มี 2 ตัว
**แอป 1: วงล้อมาตราตัวสะกด** (`วงล้อมาตราตัวสะกด.html`)
- วงล้อเดี่ยว 9 segment = 9 มาตรา
- 2 tab: หมุนเรียนรู้ (spin+card) และเล่นเกมทาย (quiz 4-choice)
- Data M[] มี color, name, cons, words[6] ต่อมาตรา
- ไม่มีวงซ้อน — เป็น single-ring เหมือน slot machine มาตรา
**แอป 2: วงล้อสุ่มสะกดคำ** (`วงล้อสุ่มสะกดคำ.html`) ← **prompt นี้**
- วงซ้อน 2-4 วง (CONS × VOWEL × FIN × TONE) + แท็บบัตรคำ ป.1
- ประสมพยางค์ได้จริงตามหลักภาษาไทย
---
*สร้างโดย Sakka + Claude | สื่อดิจิทัลฝึกสะกดคำภาษาไทย*