← คลังแอปทั้งหมด
🎮
ภาษาไทย 🆓 ใช้ฟรี

วงล้อสะกดคำ

วงล้อสะกดคำ และ บัตรคำภาษาไทย ป.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 | สื่อดิจิทัลฝึกสะกดคำภาษาไทย*

แอปอื่นที่น่าสนใจ

ดูแอปทั้งหมด → คลังใบงาน → อ่านบทความ →