ReadingLab — ฝึก Reading ภาษาอังกฤษ A-Level English และ TGAT 1 Reading Comprehension

Image You
0
วันต่อเนื่อง
สูงสุด: 0 วัน
7 วัน 14 วัน 21 วัน 30+
คำ นาที
0:00
กำลังเตรียมบทอ่านวันนี้…
บทความเก่า ทำแบบฝึกหัดได้ แต่ไม่นับในสถิติและ streak
บทความนี้ถูกยกเลิก — ไม่นับในสถิติและคะแนน

จับเวลา นับในสถิติ
เวลา
คำ/นาที
คำ
กลับมาอีกพรุ่งนี้นะ
บทอ่านใหม่
— ชั่วโมง — นาที — วินาที
อยากจำศัพท์ได้เร็วขึ้น?
ฝึกคำศัพท์ A-Level ด้วย PudVocab เกมปัดคำศัพท์
PudVocab
รางวัลต่อเนื่อง
Streak Shield
Streak Shield 0
โล่ป้องกันหลุด Streak 1 วันเพื่อพักผ่อน
ได้ Shield อัตโนมัติทุก 14 วันที่อ่านต่อเนื่อง (วันที่ 14, 28, 42, ...) ใช้อัตโนมัติเช่นกัน — ถ้าคุณพลาดอ่านไป 1 วัน ระบบจะใช้ Shield 1 อันปกป้อง streak ให้ทันทีที่คุณกลับมาอ่าน ไม่ต้องกดใช้เอง แต่ถ้าพลาด 2 วันขึ้นไป streak จะเริ่มนับใหม่แม้จะมี Shield อยู่ก็ตาม
อันดับบทความนี้
กำลังโหลด...
กลุ่มอ่านของฉัน
สถิติของฉัน
กำลังโหลด...
กำลังโหลด...
ข้อมูล
ข้อมูลทั้งหมดอ้างอิงตาม mytcas.com/blueprint/ และตามสถิติของข้อสอบเก่าย้อนหลัง 5 ปี
TGAT 1 Reading Comprehension
จำนวนคำ100 – 200 คำ ต่อบทอ่าน
หัวข้อบทความทั่วไป, จดหมาย, โฆษณา, คำเตือน, ข่าว
จำนวนข้อ / บทความ5 ข้อ / บทความ  (3 บทความ = 15 ข้อรวม)
จุดเน้น
  • แต่ละบทอ่านมีรูปแบบต่างกัน ต้องปรับกรอบความคิดใหม่ทุกบทที่เปลี่ยน
  • คำถามส่วนใหญ่ถามรายละเอียดตรงๆ จากบทอ่าน ไม่ต้องอนุมานลึก
  • ต้องหาคำสำคัญในบทอ่านให้เร็ว เวลาต่อข้อน้อยมาก
  • ระวังตัวเลือกที่ "ใกล้เคียง" แต่ขัดแย้งเล็กน้อยกับบทอ่าน เช่น ใช้คำสุดโต่งเกินไป
A-Level Advertisement
จำนวนคำวิเคราะห์รูปภาพ / โปสเตอร์ประกอบข้อความ
หัวข้อโฆษณาทั่วไป, สินค้า, บริการ
จำนวนข้อ / บทความ3 ข้อ / โฆษณา  (2 โฆษณา = 6 ข้อรวม)
จุดเน้น
  • ต้องอ่านทุกส่วนของโฆษณา รวมถึงรายละเอียดปลีกย่อย เงื่อนไข วัน เวลา สถานที่
  • จุดประสงค์หลักของโฆษณามักเป็นหนึ่งในคำถาม ต้องระบุให้ตรง
  • ข้อมูลในรูปภาพประกอบสำคัญพอกับข้อความ อย่ามองข้าม
  • ระวังตัวเลือกที่ "ดูเหมือนจริง" แต่ข้อมูลนั้นไม่มีในโฆษณาจริงๆ
A-Level Product / Service Review
จำนวนคำ150 – 250 คำ
หัวข้อรีวิวสินค้า, บริการ
จำนวนข้อ / บทความ6 ข้อ / 1 บทความ
จุดเน้น
  • บทความมีทั้งด้านบวกและด้านลบ ต้องระบุน้ำเสียงผู้เขียน ว่าชื่นชม วิจารณ์ หรือผสมกัน
  • อย่าสับสนข้อดี-ข้อเสีย โดยเฉพาะเมื่อแต่ละย่อหน้าพูดถึงคนละประเด็น
  • คำถามคำศัพท์ในบริบทพบบ่อย ต้องอ่านความหมายจากบริบทประโยค ไม่ใช่แค่แปลตรงๆ
  • ระวังคำที่มีนัยยะแฝง เช่น "salvaged", "mixed bag", "on the bright side"
  • คำถามการอนุมานเกี่ยวกับพฤติกรรมในอนาคต ให้ดูย่อหน้าสรุปท้ายบทเป็นหลัก
A-Level News Report
จำนวนคำ150 – 250 คำ
หัวข้อข่าวปัจจุบัน, เหตุการณ์โลก, วิทยาศาสตร์, สิ่งแวดล้อม, เศรษฐกิจ
จำนวนข้อ / บทความ6 ข้อ / 1 บทความ
จุดเน้น
  • โครงสร้างข่าวเริ่มจากข้อมูลสำคัญที่สุดก่อน แล้วค่อยขยายรายละเอียดในย่อหน้าถัดไป
  • ใช้ภาษาทางการและรูปกริยา passive voice บ่อย ต้องอ่านออกว่าใครทำอะไร
  • คำถามมักถามถึงความขัดแย้งหลัก สาเหตุและผลลัพธ์ หรือคำพูดของผู้เชี่ยวชาญ
  • ต้องแยกแยะ "ข้อเท็จจริงที่ระบุในบทอ่าน" กับ "ความเห็นของผู้เชี่ยวชาญ" ให้ชัดเจน
  • คำถามการอนุมานต้องอ่านระหว่างบรรทัด ไม่ใช่ข้อมูลที่บอกตรงๆ
  • คำศัพท์ที่ออกมักเป็นคำภาษาทางการ/ภาษาข่าว เช่น "overwhelmed", "surge", "amid"
A-Level Visuals
จำนวนคำวิเคราะห์แผนภาพ, แผนภูมิ, ตาราง
หัวข้อวิเคราะห์ตาราง, แผนภูมิ, กราฟเส้น
จำนวนข้อ / บทความ3 ข้อ / visual  (2 visuals = 6 ข้อรวม)
จุดเน้น
  • ต้องอ่านทั้งแผนภาพ/ตาราง และข้อความประกอบควบคู่กัน ไม่อ่านแค่อย่างใดอย่างหนึ่ง
  • ระวังกับดักตัวเลข เช่น นำตัวเลขผิดคอลัมน์หรือผิดหน่วยมาเปรียบเทียบ
  • คำถามมักถามเปรียบเทียบ 2 กลุ่ม/ช่วงเวลา หรือสรุปแนวโน้มของข้อมูล
  • อย่าสรุปกว้างเกินข้อมูลที่มี ให้ยึด "ข้อสรุปที่ถูกต้องที่สุด" จากที่เห็นในภาพเท่านั้น
  • ผังงาน/แผนภาพลำดับชั้น: ต้องเข้าใจความสัมพันธ์ระหว่างแนวคิด ไม่ใช่แค่อ่านคำ
  • ข้อสรุปที่ถูกต้องต้องรองรับได้จากข้อมูลในภาพเท่านั้น ไม่ใช่ความรู้ทั่วไป
A-Level General Articles
จำนวนคำ500 – 600 คำ
หัวข้อไลฟ์สไตล์, วัฒนธรรม, จิตวิทยา, วิทยาศาสตร์ทั่วไป, ประวัติศาสตร์
จำนวนข้อ / บทความ8 ข้อ / บทความ  (2 บทความ = 16 ข้อรวม)
จุดเน้น
  • บทความยาวที่สุดในชุดข้อสอบ ต้องจัดสรรเวลาให้ดี อาจใช้เวลาต่อพาร์ทนี้มากที่สุด
  • ต้องเข้าใจแนวคิดและโครงสร้างเนื้อหาของผู้เขียน ไม่ใช่แค่จำรายละเอียด
  • คำถามการอนุมานต้องสรุปจากนัยยะที่ผู้เขียนสื่อ ไม่ใช่ข้อความที่บอกตรงๆ
  • ใช้หมายเลขย่อหน้ากลับไปหาข้อมูลอย่างรวดเร็ว แทนการอ่านใหม่ทั้งหมด
  • คำถาม "หัวข้อที่เหมาะสมที่สุด" และ "น้ำเสียงผู้เขียน" ต้องมองภาพรวมทั้งบท
  • คำถามจุดประสงค์ของย่อหน้า/ตัวอย่าง ให้คิดว่าผู้เขียนกำลังสร้างเหตุผลอะไรอยู่
  • ระวังตัวเลือกหลอกที่ "ถูกบางส่วน" แต่สรุปกว้างเกินกว่าที่บทอ่านระบุ
คุณกำลังใช้ ReadingLab เป็นแอปอยู่แล้ว
iPhone / iPad
Safari เท่านั้น
อุปกรณ์ของคุณ
1
เปิดหน้านี้ใน Safari (ไม่รองรับบน Chrome หรือแอปอื่นบน iOS)
2
กดปุ่ม แชร์ ที่แถบเครื่องมือด้านล่าง
3
เลื่อนลงในเมนูแชร์ แล้วเลือก "เพิ่มไปยังหน้าจอโฮม"
4
กด "เพิ่ม" มุมขวาบน — ไอคอน ReadingLab จะปรากฏบนหน้าจอโฮม
Android
Chrome / Samsung Internet
อุปกรณ์ของคุณ
1
เปิดหน้านี้ใน Chrome หรือ Samsung Internet
2
กดเมนู มุมขวาบน แล้วเลือก "เพิ่มในหน้าจอหลัก" หรือ "ติดตั้งแอป"
3
กด "ติดตั้ง" หรือ "เพิ่ม" เพื่อยืนยัน
4
บางครั้ง Chrome จะแสดง banner "ติดตั้ง ReadingLab" อัตโนมัติที่ด้านล่างหน้าจอ กดนั้นได้เลย
คอมพิวเตอร์
Chrome / Edge
อุปกรณ์ของคุณ
1
มองที่แถบ address bar — จะมีไอคอน ติดตั้ง ปรากฏขึ้น
2
คลิกไอคอนนั้น แล้วเลือก "ติดตั้ง" ในกล่องที่ pop-up ขึ้นมา
3
ReadingLab จะเปิดเป็นหน้าต่างแยกต่างหาก ไม่มี browser bar — เหมือนแอปจริง
'); } const btn = document.getElementById('hlDownloadBtn'); if (btn) { btn.disabled = true; btn.classList.add('loading'); } try { const [jsPDF, logoSrc, thaiFont] = await Promise.all([_loadJsPDF(), _preloadLogoDataUri(), _fetchRlThaiFont().catch(e => { console.error('[Thai font]', e); return null; })]); const qs = Array.isArray(passage.questions) ? passage.questions : []; const words = passage.wordCount || wordCount(passage.content); const qCount = passage.questionCount || qs.length; const estMins = Math.ceil(words / 150) + qCount; const doc = new jsPDF({ unit: 'pt', format: 'a4' }); if (thaiFont) _registerRlThaiFont(doc, thaiFont); const FONT = thaiFont ? 'Sarabun' : 'helvetica'; // fall back to helvetica (boxes for Thai) only if the font fetch failed const pageW = doc.internal.pageSize.getWidth(); const pageH = doc.internal.pageSize.getHeight(); const margin = 44; const contentW = pageW - margin * 2; let y = margin; function newPageIfNeeded(h) { if (y + h > pageH - margin) { doc.addPage(); y = margin; } } function writeLines(lines, x, lineH, opts) { lines.forEach(line => { newPageIfNeeded(lineH); doc.text(line, x, y, opts); y += lineH; }); } // Header — logo left, title + meta right. if (logoSrc) { try { doc.addImage(logoSrc, 'PNG', margin, y - 6, 92, 22); } catch (e) { /* non-fatal */ } } doc.setFont(FONT, 'bold'); doc.setFontSize(13); doc.text(passage.title || 'Reading Passage', pageW - margin, y + 6, { align: 'right' }); doc.setFont(FONT, 'normal'); doc.setFontSize(9); doc.setTextColor(120); doc.text(`${words} words · ${estMins} min · ${qCount} questions`, pageW - margin, y + 20, { align: 'right' }); doc.setTextColor(20); y += 40; doc.setDrawColor(210); doc.line(margin, y, pageW - margin, y); y += 22; // Passage doc.setFont(FONT, 'normal'); doc.setFontSize(11); (passage.content || '').split(/\n\s*\n/).map(p => p.trim()).filter(Boolean).forEach(para => { writeLines(doc.splitTextToSize(para, contentW), margin, 16); y += 8; }); y += 12; // Questions — stem + options, no correct answer or explanation qs.forEach((q, qi) => { newPageIfNeeded(20); doc.setFont(FONT, 'bold'); doc.setFontSize(11); writeLines(doc.splitTextToSize(`${qi + 1}. ${q.q}`, contentW), margin, 15); doc.setFont(FONT, 'normal'); doc.setFontSize(10.5); (q.options || []).forEach((opt, oi) => { writeLines(doc.splitTextToSize(`${LETTERS[oi]}. ${opt}`, contentW - 14), margin + 14, 14); }); y += 12; }); const url = URL.createObjectURL(doc.output('blob')); if (pdfWin) pdfWin.location.href = url; else toast('เบราว์เซอร์บล็อกป๊อปอัป กรุณาอนุญาตแล้วลองใหม่', 'err'); } catch (e) { console.error('[downloadPassagePDF]', e); if (pdfWin) pdfWin.close(); toast('สร้าง PDF ไม่สำเร็จ กรุณาลองใหม่', 'err'); } finally { if (btn) { btn.disabled = false; btn.classList.remove('loading'); } } }; function attachHlListeners() { if (_hlAttached) return; _hlAttached = true; const body = document.getElementById('passageBody'); /* Desktop */ body.addEventListener('mouseup', onBodyMouseRelease); /* Mobile: native long-press → drag selection → lift finger. getSelection() is fully populated at touchend, so we reuse the same selection-based path as desktop. */ body.addEventListener('touchend', e => { if (!hlTool) return; const r = selectionToRange(); if (!r || r.end <= r.start) return; const color = hlTool === 'erase' ? 0 : hlTool === 'y' ? 1 : hlTool === 'u' ? 3 : 2; applyHighlight(r.start, r.end, color); const sel = window.getSelection(); if (sel) sel.removeAllRanges(); e.preventDefault(); }); } function onBodyMouseRelease() { if (!hlTool) return; const r = selectionToRange(); if (!r || r.end <= r.start) return; const color = hlTool === 'erase' ? 0 : hlTool === 'y' ? 1 : hlTool === 'u' ? 3 : 2; applyHighlight(r.start, r.end, color); const sel = window.getSelection(); if (sel) sel.removeAllRanges(); } function applyHighlight(start, end, c) { start = Math.max(0, start); end = Math.min(RE_canonLen, end); if (end <= start) return; for (let i = start; i < end; i++) RE_marks[i] = c; renderPassageBody(); saveHighlights(); } function selectionToRange() { const sel = window.getSelection(); if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null; const body = document.getElementById('passageBody'); const range = sel.getRangeAt(0); if (!body.contains(range.commonAncestorContainer)) return null; const startP = paragraphElFromNode(range.startContainer); const endP = paragraphElFromNode(range.endContainer); if (!startP || !endP) return null; const gs = Number(startP.dataset.ps) + measureOffset(startP, range.startContainer, range.startOffset); const ge = Number(endP.dataset.ps) + measureOffset(endP, range.endContainer, range.endOffset); return { start: Math.min(gs, ge), end: Math.max(gs, ge) }; } function measureOffset(pEl, node, offset) { const r = document.createRange(); r.setStart(pEl, 0); r.setEnd(node, offset); return r.toString().length; } function paragraphElFromNode(node) { let n = node.nodeType === 3 ? node.parentNode : node; while (n && n.id !== 'passageBody') { if (n.dataset && n.dataset.ps !== undefined) return n; n = n.parentNode; } return null; } function marksToRuns() { const runs = []; let k = 0; while (k < RE_marks.length) { const c = RE_marks[k]; if (c) { let j = k + 1; while (j < RE_marks.length && RE_marks[j] === c) j++; runs.push({ s: k, e: j, c }); k = j; } else k++; } return runs; } function runsToMarks(runs) { RE_marks = new Uint8Array(RE_canonLen); (runs || []).forEach(r => { for (let i = r.s; i < r.e && i < RE_canonLen; i++) RE_marks[i] = r.c; }); } const HL_LS = pid => 'csg_reading_hl_' + pid; function saveHighlights() { clearTimeout(hlSaveTimer); hlSaveTimer = setTimeout(() => { const runs = marksToRuns(); const u = getLoggedInUser(); if (u) { db.collection('users').doc(u.uid).collection('readingHighlights').doc(passage.id) .set({ runs, updatedAt: firebase.firestore.FieldValue.serverTimestamp() }) .catch(e => console.error('highlight save', e)); } else { try { localStorage.setItem(HL_LS(passage.id), JSON.stringify(runs)); } catch {} } }, 700); } async function loadHighlights() { let runs = null; const u = getLoggedInUser(); if (u) { try { const s = await db.collection('users').doc(u.uid).collection('readingHighlights').doc(passage.id).get(); if (s.exists) runs = s.data().runs; } catch {} } if (!runs) { try { runs = JSON.parse(localStorage.getItem(HL_LS(passage.id))); } catch {} } if (runs && runs.length) { runsToMarks(runs); renderPassageBody(); } } /* ════════════════════════════ Resume after login redirect ════════════════════════════ */ async function tryResumePending() { let pending = null; try { pending = JSON.parse(sessionStorage.getItem(PENDING_KEY)); } catch {} if (!pending || !getLoggedInUser()) return; if (!passage || pending.passageId !== passage.id) { sessionStorage.removeItem(PENDING_KEY); return; } sessionStorage.removeItem(PENDING_KEY); // Dashboard is showing — transition to reading mode before restoring answers if (document.getElementById('splitGrid').classList.contains('hide')) enterReading(); (pending.answers || []).forEach((oi, qi) => { if (oi >= 0) selectOpt(qi, oi); }); const elapsedSec = Math.max(1, Math.round((pending.elapsedMs || 0) / 1000)); const btn = document.getElementById('gradeBtn'); if (btn) { btn.disabled = true; btn.textContent = 'กำลังตรวจ…'; } try { const res = await _gradeReadingFn({ passageId: passage.id, answers: pending.answers || answers, elapsedSec }); gradeNow(pending.answers || answers, res.data, elapsedSec); } catch (e) { if (btn) { btn.disabled = false; btn.textContent = 'ตรวจข้อสอบ'; } if (e.code === 'functions/already-exists') { markAlreadySubmitted(); return; } if (e.code === 'functions/unauthenticated') { _handleSessionExpired(); return; } toast('ไม่สามารถบันทึกผลได้ กรุณาลองใหม่', 'err'); } } const _origRender = render; render = function() { _origRender(); if (!graded) tryResumePending(); tryResumeGroupJoin(); }; // ── Stats view ──────────────────────────────────────────────────────────── const _statsSessionData = {}; // idx → { answers, passageId } const _statsPassageCache = {}; // passageId → passage doc data let _statsCachedSessions = null, _statsCachedAt = 0; const STATS_CACHE_TTL = 60_000; // 1 minute let _statsViewMode = 'alevel'; // 'alevel' | 'tgat1' window.switchStatsView = function(mode) { _statsViewMode = mode; const alBtn = document.getElementById('statsViewTabAlevel'); const t1Btn = document.getElementById('statsViewTabTgat1'); if (alBtn) alBtn.classList.toggle('active', mode === 'alevel'); if (t1Btn) t1Btn.classList.toggle('active', mode === 'tgat1'); if (_statsCachedSessions) renderStatsView(_statsCachedSessions); }; let _voidedPassageIds = new Set(); let _calActiveDates = {}, _calBkkDate = null; let _noticeData = null; const MODE_LABELS_STATS = { tgat1: 'TGAT 1', alevel: 'A-Level อังกฤษ', 'alevel-review': 'A-Level: Review', 'alevel-visual': 'A-Level: Visuals', 'alevel-ads': 'A-Level: Ads', 'alevel-news': 'A-Level: News', 'alevel-general': 'A-Level: General', cloze: 'A-Level: เติมคำ', general: 'ทั่วไป' }; // A-Level-family stats view includes reading (alevel*) AND cloze. const _isAlevelStatsMode = m => !!m && (m.startsWith('alevel') || m === 'cloze'); window.openInfo = function() { document.getElementById('infoView').classList.remove('hide'); _initPwaTab(); }; window.closeInfo = function() { document.getElementById('infoView').classList.add('hide'); }; let _pwaModalInited = false; window.openPwaModal = function() { document.getElementById('pwaInstallModal').classList.remove('hide'); if (_pwaModalInited) return; _pwaModalInited = true; const isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches; if (isStandalone) document.getElementById('pmAlready').classList.remove('hide'); const ua = navigator.userAgent; const isIos = /iPhone|iPad|iPod/.test(ua); const isAndroid = /Android/.test(ua); if (isIos) { document.getElementById('pmCardIos').classList.add('highlighted'); document.getElementById('pmHdrIos').classList.add('highlighted'); document.getElementById('pmYouIos').classList.remove('hide'); } else if (isAndroid) { document.getElementById('pmCardAndroid').classList.add('highlighted'); document.getElementById('pmHdrAndroid').classList.add('highlighted'); document.getElementById('pmYouAndroid').classList.remove('hide'); } else { document.getElementById('pmCardDesktop').classList.add('highlighted'); document.getElementById('pmHdrDesktop').classList.add('highlighted'); document.getElementById('pmYouDesktop').classList.remove('hide'); } }; window.closePwaModal = function() { document.getElementById('pwaInstallModal').classList.add('hide'); }; window.switchInfoTab = function(tab) { document.getElementById('infoTabExam').classList.toggle('hide', tab !== 'exam'); document.getElementById('infoTabInstall').classList.toggle('hide', tab !== 'install'); document.getElementById('infoTabBtnExam').classList.toggle('active', tab === 'exam'); document.getElementById('infoTabBtnInstall').classList.toggle('active', tab === 'install'); }; let _pwaTabInited = false; function _initPwaTab() { if (_pwaTabInited) return; _pwaTabInited = true; /* Already installed as standalone app? */ const isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches; if (isStandalone) { document.getElementById('pwaAlreadyInstalled').classList.remove('hide'); } /* Detect platform and highlight the relevant card */ const ua = navigator.userAgent; const isIos = /iPhone|iPad|iPod/.test(ua); const isAndroid = /Android/.test(ua); const isDesktop = !isIos && !isAndroid; if (isIos) { document.getElementById('pwaCardIos').classList.add('highlighted'); document.getElementById('pwaHdrIos').classList.add('highlighted'); document.getElementById('pwaYouIos').classList.remove('hide'); } else if (isAndroid) { document.getElementById('pwaCardAndroid').classList.add('highlighted'); document.getElementById('pwaHdrAndroid').classList.add('highlighted'); document.getElementById('pwaYouAndroid').classList.remove('hide'); } else { document.getElementById('pwaCardDesktop').classList.add('highlighted'); document.getElementById('pwaHdrDesktop').classList.add('highlighted'); document.getElementById('pwaYouDesktop').classList.remove('hide'); } } async function _fetchNotice() { try { const snap = await db.collection('readingMeta').doc('notice').get(); _noticeData = snap.exists ? snap.data() : null; } catch (_) { _noticeData = null; } _renderNoticeBanner(); } function _renderNoticeBanner() { const el = document.getElementById('noticeBanner'); if (!el) return; if (!_noticeData || !_noticeData.active) { el.classList.add('hide'); return; } const dismissed = sessionStorage.getItem('readinglab_notice_dismissed'); if (dismissed === _noticeData.message) { el.classList.add('hide'); return; } document.getElementById('noticeBannerMsg').textContent = _noticeData.message || ''; el.className = 'notice-banner ' + (_noticeData.type === 'warning' ? 'warning' : 'info'); el.classList.remove('hide'); } window.dismissNoticeBanner = function() { if (_noticeData) sessionStorage.setItem('readinglab_notice_dismissed', _noticeData.message || ''); const el = document.getElementById('noticeBanner'); if (el) el.classList.add('hide'); }; function buildCalSvg(days) { if (!_calBkkDate) return ''; const todayStr = _calBkkDate(new Date()); const todayUTC = new Date(todayStr + 'T00:00:00Z'); const firstTarget = new Date(todayUTC); firstTarget.setUTCDate(todayUTC.getUTCDate() - (days - 1)); const dow0 = (firstTarget.getUTCDay() + 6) % 7; firstTarget.setUTCDate(firstTarget.getUTCDate() - dow0); const lastTarget = new Date(todayUTC); const dowLast = (todayUTC.getUTCDay() + 6) % 7; lastTarget.setUTCDate(todayUTC.getUTCDate() + (6 - dowLast)); const cells = []; const cur = new Date(firstTarget); while (cur <= lastTarget) { cells.push(cur.toISOString().slice(0, 10)); cur.setUTCDate(cur.getUTCDate() + 1); } const numWeeks = cells.length / 7; const CELL = 22, GAP = 4, COL = CELL + GAP; const DOW_W = 18, DOW_GAP = 5; const MONTH_H = 14, MONTH_GAP = 4; const gridX = DOW_W + DOW_GAP; const gridY = MONTH_H + MONTH_GAP; const VW = gridX + numWeeks * COL - GAP; const VH = gridY + 7 * COL - GAP + 2; const MONTH_TH = ['ม.ค.','ก.พ.','มี.ค.','เม.ย.','พ.ค.','มิ.ย.','ก.ค.','ส.ค.','ก.ย.','ต.ค.','พ.ย.','ธ.ค.']; const DOW_LABELS = ['จ','','พ','','ศ','','อา']; let s = ''; DOW_LABELS.forEach((lbl, i) => { if (!lbl) return; const y = gridY + i * COL + CELL / 2 + 3.5; s += `${lbl}`; }); let prevMon = ''; for (let w = 0; w < numWeeks; w++) { const ci = w * 7; if (ci >= cells.length) break; const mon = cells[ci].slice(5, 7); if (mon !== prevMon) { s += `${MONTH_TH[parseInt(mon) - 1]}`; prevMon = mon; } for (let d = 0; d < 7 && ci + d < cells.length; d++) { const ds = cells[ci + d]; if (ds > todayStr) continue; const fill = _calActiveDates[ds] !== undefined ? 'var(--accent)' : 'var(--surface-2)'; const tip = ds + (_calActiveDates[ds] !== undefined ? ': ' + _calActiveDates[ds] + '%' : ''); s += `${tip}`; } } return `${s}`; } window.switchCalRange = function(months) { const days = months === 1 ? 30 : 90; const wrap = document.getElementById('calSvgWrap'); if (wrap) wrap.innerHTML = buildCalSvg(days); const b1 = document.getElementById('calBtn1'); const b3 = document.getElementById('calBtn3'); if (b1) b1.classList.toggle('cal-range-active', months === 1); if (b3) b3.classList.toggle('cal-range-active', months === 3); }; window.openStats = async function() { const u = getLoggedInUser(); if (!u) { goLogin(); return; } document.getElementById('statsView').classList.remove('hide'); // Sync tab button state in case _statsViewMode was changed before the view was open const alBtn = document.getElementById('statsViewTabAlevel'); const t1Btn = document.getElementById('statsViewTabTgat1'); if (alBtn) alBtn.classList.toggle('active', _statsViewMode === 'alevel'); if (t1Btn) t1Btn.classList.toggle('active', _statsViewMode === 'tgat1'); const now = Date.now(); if (_statsCachedSessions && now - _statsCachedAt < STATS_CACHE_TTL) { renderStatsView(_statsCachedSessions); return; } document.getElementById('statsInner').innerHTML = '
กำลังโหลด...
'; try { const [sessionSnap, tgat1Snap, voidedSnap] = await Promise.all([ db.collection('readingSessions') .where('userId', '==', u.uid) .orderBy('submittedAt', 'desc') .limit(150) .get(), db.collection('users').doc(u.uid).collection('tgat1Sessions').get(), db.collection('readingPassages').where('voided', '==', true).get().catch(() => ({ docs: [] })), ]); const aLevelSessions = sessionSnap.docs.map(d => ({ id: d.id, ...d.data() })); // Merge TGAT 1 sessions (stored in a subcollection) into the combined list const tgat1Sessions = tgat1Snap.docs.map(d => ({ id: d.id, ...d.data(), mode: 'tgat1' })); // Fetch passage titles for TGAT 1 sessions (needed by history view) const t1PassageIds = [...new Set(tgat1Sessions.map(s => s.passageId).filter(Boolean))]; if (t1PassageIds.length) { const t1Docs = {}; await Promise.all(t1PassageIds.map(async pid => { try { const s = _statsPassageCache[pid] || (await db.collection('readingPassages').doc(pid).get().then(snap => snap.exists ? { id: snap.id, ...snap.data() } : null)); if (s) { t1Docs[pid] = s; _statsPassageCache[pid] = s; } } catch {} })); tgat1Sessions.forEach(s => { if (s.passageId && t1Docs[s.passageId]) s.passageTitle = t1Docs[s.passageId].title || ''; }); } _statsCachedSessions = [...aLevelSessions, ...tgat1Sessions].sort((a, b) => { const ta = a.submittedAt && a.submittedAt.toDate ? a.submittedAt.toDate() : new Date(0); const tb = b.submittedAt && b.submittedAt.toDate ? b.submittedAt.toDate() : new Date(0); return tb - ta; }); _statsCachedAt = Date.now(); _voidedPassageIds = new Set(voidedSnap.docs.map(d => d.id)); const _sc = document.getElementById('dashSessCount'); if (_sc) _sc.textContent = aLevelSessions.filter(s => !_voidedPassageIds.has(s.passageId)).length; renderStatsView(_statsCachedSessions); } catch (e) { console.error(e); document.getElementById('statsInner').innerHTML = '
โหลดไม่สำเร็จ กรุณาลองใหม่
'; } }; window.closeStats = function() { document.getElementById('statsView').classList.add('hide'); }; function _barSvg(items, { colorFn, unit, maxVal } = {}) { const n = items.length; if (!n) return ''; const AXIS_W = 36, VW = 560, BAR_AREA = 80, LABEL_H = 18, PAD_TOP = 8; const VH = BAR_AREA + LABEL_H + PAD_TOP; const plotW = VW - AXIS_W; const gap = 4, maxBw = 36; const bw = Math.min(maxBw, Math.max(4, Math.floor((plotW - gap * (n + 1)) / n))); const totalW = n * bw + (n + 1) * gap; const startX = AXIS_W + Math.max(0, (plotW - totalW) / 2); const mx = maxVal || Math.max(...items.map(x => x.v), 1); const baseY = PAD_TOP + BAR_AREA; const labelEvery = Math.ceil(n / 7); const topVal = mx, midVal = Math.round(mx / 2); const unitStr = unit || ''; let s = ''; // grid lines + Y-axis labels s += ``; s += `${topVal}${unitStr}`; s += ``; s += `${midVal}${unitStr}`; s += ``; items.forEach((item, i) => { const bh = Math.max(3, Math.round(item.v / mx * BAR_AREA)); const x = startX + i * (bw + gap); const y = baseY - bh; const fill = colorFn ? colorFn(item.v) : 'var(--accent)'; s += `${item.label}: ${item.v}${unitStr}`; if (i % labelEvery === 0 || i === n - 1) { s += `${item.label}`; } }); return `${s}`; } function renderStatsView(sessions) { const isTgat1 = _statsViewMode === 'tgat1'; // Filter to only this view's mode family const viewSessions = sessions.filter(s => isTgat1 ? s.mode === 'tgat1' : _isAlevelStatsMode(s.mode) ); const validSessions = viewSessions.filter(s => !_voidedPassageIds.has(s.passageId)); const total = validSessions.length; const uDoc = typeof userDoc !== 'undefined' ? userDoc : null; const scoreColor = s => s >= 70 ? 'var(--green)' : s >= 50 ? '#f59e0b' : 'var(--red)'; // Personal Bests — userDoc aggregates are A-Level only; derive from sessions for TGAT 1 const pbScore = isTgat1 ? (total ? Math.max(...validSessions.map(s => s.score || 0)) : 0) : ((uDoc && uDoc.readingBestScore) || (total ? Math.max(...validSessions.map(s => s.score || 0)) : 0)); const pbWpm = isTgat1 ? (total ? Math.max(...validSessions.map(s => s.wpm || 0)) : 0) : ((uDoc && uDoc.readingBestWpm) || (total ? Math.max(...validSessions.map(s => s.wpm || 0)) : 0)); const pbStreak = isTgat1 ? null : ((uDoc && (uDoc.readingBestStreak || (uDoc.readingStreak && uDoc.readingStreak.longest))) || 0); const totalSess = validSessions.length; const pbStreakTile = isTgat1 ? '' : `
${pbStreak || '—'}
วัน streak
Best Streak
`; const pbHtml = `
Personal Bests
${pbScore ? pbScore + '%' : '—'}
คะแนนสูงสุด
Best Score
${pbWpm || '—'}
คำ/นาที
Best WPM
${pbStreakTile}
${totalSess || '—'}
บทอ่าน
Total
`; // Charts — last 14 sessions, reversed to oldest→newest (L→R) let chartHtml = ''; if (total > 0) { const chartSess = [...validSessions].slice(0, 14).reverse(); const chartItems = chartSess.map(s => { const d = s.submittedAt && s.submittedAt.toDate ? s.submittedAt.toDate() : null; const label = d ? (d.getDate() + '/' + (d.getMonth() + 1)) : '?'; return { label, score: s.score || 0, wpm: s.wpm || 0 }; }); const scoreScFn = v => v >= 70 ? '#22c55e' : v >= 50 ? '#f59e0b' : '#ef4444'; const scoreSvg = _barSvg(chartItems.map(c => ({ label: c.label, v: c.score })), { colorFn: scoreScFn, unit: '%', maxVal: 100 }); const wpmSvg = _barSvg(chartItems.map(c => ({ label: c.label, v: c.wpm })), { colorFn: () => 'var(--accent)', unit: ' wpm' }); const n = chartSess.length; const subLabel = n + ' บทล่าสุด'; chartHtml = `
คะแนน ${subLabel}
${scoreSvg}
ความเร็วในการอ่าน ${subLabel} · คำ/นาที
${wpmSvg}
`; } // Accuracy by passage type — A-Level only (multiple subtypes worth showing; TGAT 1 is one mode) let modeHtml = ''; if (!isTgat1 && total > 0) { const modeMap = {}; validSessions.forEach(s => { const m = s.mode || 'general'; if (!modeMap[m]) modeMap[m] = { sum: 0, count: 0 }; modeMap[m].sum += (s.score || 0); modeMap[m].count++; }); const modeEntries = Object.entries(modeMap).sort((a, b) => b[1].count - a[1].count); let rowsHtml = ''; modeEntries.forEach(([mode, d]) => { const avg = Math.round(d.sum / d.count); const fill = 'var(--accent)'; const label = MODE_LABELS_STATS[mode] || mode; rowsHtml += `
${esc(label)}
${avg}%
${d.count} บท
`; }); modeHtml = `
ความแม่นยำตามประเภท
${rowsHtml}
`; } // Skill stats — A-Level only (server-aggregated on userDoc; not tracked for TGAT 1) let skillHtml = ''; if (!isTgat1) { const skillStats = (uDoc && uDoc.readingSkillStats) || {}; const skillEntries = Object.entries(skillStats).filter(([, v]) => v.total > 0); if (skillEntries.length > 0) { skillEntries.sort((a, b) => (a[1].correct / a[1].total) - (b[1].correct / b[1].total)); const SKILL_LABELS_TH = { 'main-idea': 'ใจความสำคัญ', detail: 'รายละเอียด', inference: 'การอนุมาน', vocabulary: 'คำศัพท์', tone: 'น้ำเสียง', negative: 'ข้อยกเว้น', purpose: 'จุดประสงค์', other: 'พิเศษ' }; let rowsHtml = ''; skillEntries.forEach(([skill, v]) => { const correct = v.correct || 0; const pct = Math.round((correct / v.total) * 100); const fill = pct >= 70 ? 'var(--green)' : pct >= 50 ? '#f59e0b' : 'var(--red)'; const label = SKILL_LABELS_TH[skill] || skill; rowsHtml += `
${esc(label)}
${pct}%
${correct}/${v.total}
`; }); skillHtml = `
ความแม่นยำตามทักษะ
${rowsHtml}
`; } } // Consistency calendar — filtered to current view's sessions let calHtml = ''; { const bkkDate = d => new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Bangkok', year: 'numeric', month: '2-digit', day: '2-digit' }).format(d && d.toDate ? d.toDate() : (d instanceof Date ? d : new Date(d))); const activeDates = {}; validSessions.forEach(s => { if (!s.submittedAt) return; const ds = bkkDate(s.submittedAt); if (!activeDates[ds] || s.score > activeDates[ds]) activeDates[ds] = s.score || 0; }); _calBkkDate = bkkDate; _calActiveDates = activeDates; calHtml = `
ความสม่ำเสมอ
${buildCalSvg(30)}
`; } // History — stash all sessions by original index (needed by openHistDetail), // but only render entries that match the current view Object.keys(_statsSessionData).forEach(k => delete _statsSessionData[k]); let histItems = ''; let histCount = 0; sessions.forEach((s, i) => { _statsSessionData[i] = { answers: s.answers || [], passageId: s.passageId || '' }; const inView = isTgat1 ? s.mode === 'tgat1' : _isAlevelStatsMode(s.mode); if (!inView) return; histCount++; const d = s.submittedAt && s.submittedAt.toDate ? s.submittedAt.toDate() : null; const when = d ? d.toLocaleDateString('th-TH', { year: 'numeric', month: 'short', day: 'numeric' }) : '—'; const modeLabel = MODE_LABELS_STATS[s.mode] || s.mode || ''; const sc = s.score || 0; const isVoided = _voidedPassageIds.has(s.passageId); const voidBadge = isVoided ? ' ยกเลิก' : ''; // TGAT 1 has no "on-time vs archive" concept (always freely accessible), so the // check-mark differentiation only applies to A-Level sessions. const doneCls = s.practice ? 'hist-check-yellow' : 'hist-check-green'; const doneTitle = s.practice ? 'ทำแบบฝึกหัดย้อนหลังแล้ว' : 'ทำตรงเวลาแล้ว'; const doneBadge = isTgat1 ? '' : ``; histItems += `
`; }); const histHtml = `
ประวัติการทำข้อสอบ
${histCount === 0 ? '
ยังไม่มีประวัติการทำข้อสอบ
' : histItems}
`; document.getElementById('statsInner').innerHTML = pbHtml + chartHtml + modeHtml + skillHtml + calHtml + histHtml; } window.openHistDetail = async function(idx) { const s = (_statsCachedSessions || [])[idx]; const { answers: userAnswers, passageId } = _statsSessionData[idx] || {}; document.getElementById('statsView').classList.add('hide'); const detailView = document.getElementById('histDetailView'); detailView.classList.remove('hide'); detailView.scrollTop = 0; document.getElementById('histDetailTitle').textContent = s ? (s.passageTitle || 'ไม่มีชื่อ') : ''; document.getElementById('histDetailInner').innerHTML = '
กำลังโหลด...
'; let p = _statsPassageCache[passageId]; if (!p && passageId) { try { const snap = await db.collection('readingPassages').doc(passageId).get(); p = snap.exists ? { id: snap.id, ...snap.data() } : null; if (p) _statsPassageCache[passageId] = p; } catch(e) { console.error(e); } } if (!p) { document.getElementById('histDetailInner').innerHTML = '
ไม่พบบทอ่านนี้อีกต่อไป
'; return; } const sc = s ? (typeof s.score === 'number' ? s.score : (s.total ? Math.round((s.correct / s.total) * 100) : 0)) : 0; const scoreCol = sc >= 70 ? 'var(--green)' : sc >= 50 ? '#f59e0b' : 'var(--red)'; const qs = Array.isArray(p.questions) ? p.questions : []; // Passage card const isImageOnlyHist = p.mode === 'alevel-visual' || p.mode === 'alevel-ads'; const imgHtml = p.imageUrl ? `
Passage image
` : ''; const paraHtml = isImageOnlyHist ? '' : (p.content || '').split(/\n\s*\n/).map(para => `

${esc(para.trim())}

`).join(''); const srcHtml = p.source ? `
Sources: ${esc(p.source)}
` : ''; const passageCardHtml = `
${esc(p.title || 'ไม่มีชื่อ')}
${imgHtml}
${paraHtml}
${srcHtml}
`; if (!qs.length) { document.getElementById('histDetailInner').innerHTML = passageCardHtml; return; } // Skill feedback from stored answers let skillsHtml = ''; if (userAnswers && userAnswers.length) { const _skW = {}, _skR = {}; qs.forEach((q, qi) => { if (!q.skill) return; if (userAnswers[qi] === q.correct) _skR[q.skill] = true; else _skW[q.skill] = true; }); const rightLabels = Object.keys(_skR).filter(k => !_skW[k]).map(k => SKILL_LABELS[k] || k); const wrongLabels = Object.keys(_skW).map(k => SKILL_LABELS[k] || k); if (rightLabels.length || wrongLabels.length) { skillsHtml = '
'; if (rightLabels.length) skillsHtml += `
ทำได้
${rightLabels.map(l => `${esc(l)}`).join('')}
`; if (wrongLabels.length) skillsHtml += `
พลาด
${wrongLabels.map(l => `${esc(l)}`).join('')}
`; skillsHtml += '
'; } } // Stats (ดูผล) tab content const timeStr = s && s.elapsedSec > 0 ? fmtClock(s.elapsedSec * 1000) : '—'; const wpm = s ? Math.min(s.wpm || 0, 600) : 0; const words = p.wordCount || wordCount(p.content); const resultHtml = `
${s ? (s.correct ?? 0) : 0} / ${s ? (s.total || 0) : 0}
${timeStr}เวลา
${wpm > 0 ? wpm : '—'}คำ/นาที
${words > 0 ? words : '—'}คำ
${skillsHtml}
`; // Questions (ดูเฉลย) tab content const qsHtml = qs.map((q, qi) => { const chosen = (userAnswers && userAnswers[qi] !== undefined) ? userAnswers[qi] : -1; const optsHtml = (q.options || []).map((opt, oi) => { let cls = 'opt'; if (oi === q.correct) cls += ' correct'; else if (oi === chosen && oi !== q.correct) cls += ' wrong'; return `
${LETTERS[oi]}
${esc(opt)}
`; }).join(''); const skillHtml = q.skill ? `
${esc(SKILL_LABELS[q.skill] || q.skill)}` : ''; const exBody = q.explain ? `เฉลย: ${esc(q.explain)}${skillHtml}` : (q.skill ? skillHtml.slice(4) : ''); return `
${qi + 1}${esc(q.q)}
${optsHtml}
${exBody ? `
${exBody}
` : ''}
`; }).join(''); const omrCardHtml = `
${resultHtml}
`; document.getElementById('histDetailInner').innerHTML = passageCardHtml + omrCardHtml; }; window.hdTab = function(tab) { const rv = document.getElementById('hdResultView'); const qv = document.getElementById('hdReviewView'); const btns = document.querySelectorAll('#hdTabs .ogt-btn'); if (tab === 'result') { if (rv) rv.style.display = ''; if (qv) qv.style.display = 'none'; btns[0].classList.add('active'); btns[1].classList.remove('active'); } else { if (rv) rv.style.display = 'none'; if (qv) qv.style.display = ''; btns[0].classList.remove('active'); btns[1].classList.add('active'); } }; window.closeHistDetail = function() { document.getElementById('histDetailView').classList.add('hide'); document.getElementById('statsView').classList.remove('hide'); }; // ── End stats view ──────────────────────────────────────────────────────── // ── Vocab tap / word gloss ──────────────────────────────────────────────── // Click-to-translate: single-word click (unchanged trigger) plus drag-select for short // phrases (idioms/phrasal verbs a bare word can't cover). Both paths funnel into // lookupAndShowGloss(), which sends the word/phrase AND its sentence to the // Claude-Haiku-backed wordGloss function — see functions/index.js for why (context-aware // gloss instead of the old bare-word Longdo scrape) and the per-passage caching that // keeps this cheap even at 100-200 users/day reading the same daily passage. const _glossCache = {}; let _activeWEl = null; // Touch capability, not screen width — an iPad is >639px in both orientations but has // exactly the same fiddly-drag-selection problem a phone does, so the extend chip needs // to key off "is this a touchscreen" rather than "is this a narrow window." const IS_COARSE_POINTER = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches); // ── Mobile phrase builder state (tap-to-extend, see CSS comment above .wg-extend-btn) ── let _phraseMode = false; let _phraseAnchorEl = null; let _phraseStartEl = null; let _phraseCurrentEls = []; let _phraseRange = null; function activateVocabMode() { const body = document.getElementById('passageBody'); if (!body || body.dataset.vocabReady) return; body.dataset.vocabReady = '1'; // Walk all text nodes; wrap English word tokens in const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); for (const node of nodes) { const text = node.textContent; if (!/[A-Za-z]/.test(text)) continue; const parts = text.split(/([A-Za-z][A-Za-z'-]*)/); if (parts.length < 2) continue; const frag = document.createDocumentFragment(); for (const part of parts) { if (/^[A-Za-z]/.test(part)) { const sp = document.createElement('span'); sp.className = 'w'; sp.textContent = part; frag.appendChild(sp); } else { frag.appendChild(document.createTextNode(part)); } } node.parentNode.replaceChild(frag, node); } body.classList.add('vocab-mode'); // Insert hint strip with lookup counter above passage body if (!document.getElementById('vocabHintStrip')) { const strip = document.createElement('div'); strip.id = 'vocabHintStrip'; strip.className = 'vocab-hint-strip'; strip.innerHTML = `กดคำ หรือลากคลุมวลี เพื่อดูคำแปลภาษาไทย0/${GLOSS_LIMIT} คำ`; body.parentNode.insertBefore(strip, body); } updateGlossCounter(); } // Delegated click on passage body — single-word path document.getElementById('passageBody').addEventListener('click', async function(e) { const wEl = e.target.closest('.w'); if (_phraseMode) { // Swallow every tap in the passage while building a phrase — a tap on a word // extends the range, a tap elsewhere does nothing (use the ยกเลิก button to bail). e.stopPropagation(); if (wEl) _extendPhraseTo(wEl); return; } if (!wEl) { closeWordGloss(); return; } if (!this.classList.contains('vocab-mode')) return; e.stopPropagation(); const raw = wEl.textContent.trim(); const word = raw.replace(/[^A-Za-z'-]/g, '').replace(/^['-]+|['-]+$/g, ''); if (!word || word.length < 2) return; if (_activeWEl) _activeWEl.classList.remove('wg-active'); _activeWEl = wEl; wEl.classList.add('wg-active'); wEl.classList.remove('wg-tap-pulse'); void wEl.offsetWidth; // restart the animation even on a fast repeat tap of the same word wEl.classList.add('wg-tap-pulse'); wEl.addEventListener('animationend', () => wEl.classList.remove('wg-tap-pulse'), { once: true }); await lookupAndShowGloss(word, _extractSentence(wEl), wEl.getBoundingClientRect()); _maybeShowExtendChip(wEl); }); // Only offered on touchscreens (phone or tablet) — a mouse-driven window keeps // drag-selection (see translateSelection below), since dragging with a pointer is fine; // it's dragging with a finger that's fiddly, regardless of how wide the screen is. function _maybeShowExtendChip(wEl) { const chip = document.getElementById('wgExtendBtn'); if (!chip) return; const glossVisible = !document.getElementById('wordGloss').classList.contains('hide'); if (glossVisible && IS_COARSE_POINTER) chip.classList.remove('hide'); else chip.classList.add('hide'); } function _paraOf(el) { const body = document.getElementById('passageBody'); let p = el; while (p && p.parentElement !== body && p !== body) p = p.parentElement; return p; } window.enterPhraseExtendMode = function() { if (!_activeWEl) return; _phraseMode = true; _phraseAnchorEl = _activeWEl; _phraseStartEl = _activeWEl; _phraseCurrentEls = [_activeWEl]; _phraseRange = null; document.getElementById('passageBody').classList.add('phrase-extend-active'); document.getElementById('wgExtendBtn').classList.add('hide'); document.getElementById('wgMeta').classList.add('hide'); document.getElementById('wgThai').classList.add('hide'); document.getElementById('wgPhraseBar').classList.remove('hide'); document.getElementById('wgPhrasePreview').textContent = _phraseAnchorEl.textContent.trim(); const strip = document.getElementById('vocabHintStrip'); if (strip && strip.dataset.savedHtml === undefined) { strip.dataset.savedHtml = strip.innerHTML; strip.innerHTML = `แตะคำถัดไปเพื่อขยายวลี`; } }; function _extendPhraseTo(tappedEl) { if (tappedEl === _phraseAnchorEl && _phraseCurrentEls.length === 1) return; const para = _paraOf(_phraseAnchorEl); if (!para || _paraOf(tappedEl) !== para) { toast('ขยายวลีได้เฉพาะในย่อหน้าเดียวกัน', ''); return; } const allW = Array.from(para.querySelectorAll('.w')); const ai = allW.indexOf(_phraseAnchorEl); const ti = allW.indexOf(tappedEl); if (ai === -1 || ti === -1) return; const lo = Math.min(ai, ti), hi = Math.max(ai, ti); if (hi - lo + 1 > 6) { toast('วลียาวเกินไป (สูงสุด 6 คำ)', ''); return; } _phraseCurrentEls.forEach(el => el.classList.remove('wg-active')); _phraseCurrentEls = allW.slice(lo, hi + 1); _phraseCurrentEls.forEach(el => el.classList.add('wg-active')); _phraseStartEl = allW[lo]; const range = document.createRange(); range.setStartBefore(allW[lo]); range.setEndAfter(allW[hi]); _phraseRange = range; document.getElementById('wgPhrasePreview').textContent = range.toString().trim().replace(/\s+/g, ' '); } function _exitPhraseMode() { _phraseMode = false; document.getElementById('passageBody').classList.remove('phrase-extend-active'); _phraseCurrentEls.forEach(el => el.classList.remove('wg-active')); _phraseCurrentEls = []; _phraseAnchorEl = null; _phraseStartEl = null; _phraseRange = null; document.getElementById('wgMeta').classList.remove('hide'); document.getElementById('wgThai').classList.remove('hide'); document.getElementById('wgPhraseBar').classList.add('hide'); const strip = document.getElementById('vocabHintStrip'); if (strip && strip.dataset.savedHtml !== undefined) { strip.innerHTML = strip.dataset.savedHtml; delete strip.dataset.savedHtml; } } window.cancelPhraseExtendMode = function() { _exitPhraseMode(); closeWordGloss(); }; window.confirmPhraseExtend = async function() { const text = (_phraseRange ? _phraseRange.toString() : (_phraseAnchorEl && _phraseAnchorEl.textContent) || '') .trim().replace(/\s+/g, ' '); const startEl = _phraseStartEl || _phraseAnchorEl; const rect = _phraseRange ? _phraseRange.getBoundingClientRect() : (startEl && startEl.getBoundingClientRect()); _exitPhraseMode(); if (_activeWEl) { _activeWEl.classList.remove('wg-active'); _activeWEl = null; } if (!text || text.length < 2 || !startEl) return; await lookupAndShowGloss(text, _extractSentence(startEl), rect); }; // ── Phrase selection → translate (extends the click path to short multi-word // selections). A confirm popover, not an instant lookup, so idle text-selecting // (e.g. while reading) never spends a quota slot. ── let _selDebounce = null; let _selPopoverText = null; let _selPopoverRange = null; document.addEventListener('selectionchange', () => { clearTimeout(_selDebounce); _selDebounce = setTimeout(_handleSelectionChange, 150); }); function _handleSelectionChange() { const body = document.getElementById('passageBody'); const popover = document.getElementById('selTranslateBtn'); if (!body || !popover || !body.classList.contains('vocab-mode')) return; const sel = window.getSelection(); if (!sel || sel.isCollapsed || sel.rangeCount === 0) { popover.classList.add('hide'); return; } const range = sel.getRangeAt(0); if (!body.contains(range.commonAncestorContainer)) { popover.classList.add('hide'); return; } const text = sel.toString().trim().replace(/\s+/g, ' '); // Only offer for a short, plausible phrase — not an accidental whole-paragraph drag. if (!text || text.length < 2 || text.length > 60 || text.split(' ').length > 6 || !/[A-Za-z]/.test(text)) { popover.classList.add('hide'); return; } _selPopoverText = text; _selPopoverRange = range.cloneRange(); const rect = range.getBoundingClientRect(); const pW = 60, pH = 34; let left = rect.left + rect.width / 2 - pW / 2; left = Math.max(8, Math.min(left, window.innerWidth - pW - 8)); let top = rect.top - pH - 8; if (top < 8) top = rect.bottom + 8; popover.style.left = left + 'px'; popover.style.top = top + 'px'; popover.classList.remove('hide'); } window.translateSelection = async function() { const text = _selPopoverText; const range = _selPopoverRange; document.getElementById('selTranslateBtn').classList.add('hide'); const sel = window.getSelection(); if (sel) sel.removeAllRanges(); if (!text || !range) return; if (_activeWEl) { _activeWEl.classList.remove('wg-active'); _activeWEl = null; } await lookupAndShowGloss(text, _extractSentence(range.startContainer), range.getBoundingClientRect()); }; // Walk outward from a node through its paragraph's siblings, collecting text until a // sentence-ending punctuation mark (or the paragraph boundary) on each side — a plain-DOM // approximation of "the sentence this word/selection sits in," sent to wordGloss as context. function _extractSentence(node) { const body = document.getElementById('passageBody'); let para = node.nodeType === 3 ? node.parentElement : node; while (para && para.parentElement !== body && para !== body) para = para.parentElement; if (!para || !para.parentElement) return node.textContent || ''; const nodes = Array.from(para.childNodes); const anchor = node.nodeType === 3 ? node : node; let idx = nodes.indexOf(anchor); if (idx === -1) { // Selection may start inside a text node that isn't a direct child (rare with the // .w-span structure, but be defensive) — fall back to the whole paragraph text. return (para.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 500); } let before = ''; for (let i = idx - 1; i >= 0; i--) { const t = nodes[i].textContent || ''; const m = t.match(/[.!?][^.!?]*$/); if (m) { before = t.slice(t.lastIndexOf(m[0]) + 1) + before; break; } before = t + before; } let after = ''; for (let i = idx + 1; i < nodes.length; i++) { const t = nodes[i].textContent || ''; const m = t.match(/[.!?]/); if (m) { after += t.slice(0, m.index + 1); break; } after += t; } return (before + (anchor.textContent || '') + after).replace(/\s+/g, ' ').trim().slice(0, 500); } async function lookupAndShowGloss(text, sentence, rect) { // Only count quota for new (non-cached) lookups const cacheKey = text.toLowerCase(); if (!(cacheKey in _glossCache)) { if (_glossUsed >= GLOSS_LIMIT) { toast(`ใช้การค้นหาคำครบ ${GLOSS_LIMIT} คำแล้วสำหรับบทอ่านนี้`, ''); return; } _glossUsed++; updateGlossCounter(); } const gloss = document.getElementById('wordGloss'); document.getElementById('wgWord').textContent = text; document.getElementById('wgMeta').textContent = ''; document.getElementById('wgMeta').classList.add('hide'); // no part-of-speech yet — an empty chip would just be a blank blob document.getElementById('wgThai').classList.remove('hide'); document.getElementById('wgThai').innerHTML = 'กำลังค้นหา…'; document.getElementById('wgExtendBtn').classList.add('hide'); document.getElementById('wgPhraseBar').classList.add('hide'); positionWordGloss(rect); gloss.classList.remove('hide'); document.getElementById('wgBackdrop').classList.remove('hide'); const g = await fetchWordGloss(text, sentence); const metaEl = document.getElementById('wgMeta'); metaEl.textContent = g.type || ''; metaEl.classList.toggle('hide', !g.type); const thaiEl = document.getElementById('wgThai'); if (g.thai) { thaiEl.textContent = g.thai; } else { thaiEl.innerHTML = 'ไม่พบคำแปล'; } } function positionWordGloss(rect) { const gloss = document.getElementById('wordGloss'); if (window.innerWidth <= 639) return; // bottom sheet on mobile — no positioning needed gloss.style.visibility = 'hidden'; gloss.classList.remove('hide'); const gW = gloss.offsetWidth || 180; const gH = gloss.offsetHeight || 70; let left = rect.left + rect.width / 2 - gW / 2; left = Math.max(8, Math.min(left, window.innerWidth - gW - 8)); let top = rect.top - gH - 10; const placedAbove = top >= 8; if (!placedAbove) top = rect.bottom + 8; gloss.style.left = left + 'px'; gloss.style.top = top + 'px'; gloss.classList.toggle('wg-tail-down', placedAbove); // card sits above the word — tail points down at it gloss.classList.toggle('wg-tail-up', !placedAbove); // card sits below the word — tail points up at it // Keep the tail lined up with the word's actual center even when the card itself // got clamped away from the viewport edge (so left !== rect center - gW/2). const tailLeft = Math.max(14, Math.min(rect.left + rect.width / 2 - left, gW - 14)); gloss.style.setProperty('--wg-tail-left', tailLeft + 'px'); gloss.style.visibility = ''; } window.closeWordGloss = function() { if (_phraseMode) _exitPhraseMode(); document.getElementById('wordGloss').classList.add('hide'); document.getElementById('wgBackdrop').classList.add('hide'); if (_activeWEl) { _activeWEl.classList.remove('wg-active'); _activeWEl = null; } }; document.addEventListener('click', function(e) { if (document.getElementById('wordGloss').classList.contains('hide')) return; if (!e.target.closest('#wordGloss') && !e.target.closest('#selTranslateBtn') && !e.target.classList.contains('w')) closeWordGloss(); }); const _gradeReadingFn = firebase.functions().httpsCallable('gradeReading'); const _wordGlossFn = firebase.functions().httpsCallable('wordGloss'); function updateGlossCounter() { const strip = document.getElementById('vocabHintStrip'); if (!strip) return; const remaining = GLOSS_LIMIT - _glossUsed; if (remaining <= 0) { strip.classList.add('exhausted'); strip.innerHTML = `ใช้ครบ ${GLOSS_LIMIT} คำแล้ว — ฝึกศัพท์ไม่จำกัดที่ PudVocab →`; return; } strip.classList.remove('exhausted'); const counter = strip.querySelector('.gloss-counter'); if (!counter) return; counter.textContent = `${_glossUsed}/${GLOSS_LIMIT} คำ`; counter.style.background = remaining <= 5 ? 'rgba(212,134,11,0.14)' : ''; counter.style.color = remaining <= 5 ? 'var(--amber)' : ''; } async function fetchWordGloss(text, sentence) { const cacheKey = text.toLowerCase(); if (cacheKey in _glossCache) return _glossCache[cacheKey]; try { const res = await _wordGlossFn({ text, sentence, passageId: passage && passage.id }); const d = res.data || {}; _glossCache[cacheKey] = d; return d; } catch (e) { console.warn('[wordGloss]', e.message); if (e.code === 'functions/resource-exhausted') { toast('ใช้การค้นหาคำครบสำหรับวันนี้แล้ว ลองใหม่พรุ่งนี้นะ', 'err'); } // Deliberately not cached — a transient failure shouldn't stick as a permanent miss. return {}; } } // ── End vocab tap ────────────────────────────────────────────────────────── // ── Share result ───────────────────────────────────────────────────────── function _tomorrowText() { // Compute ms remaining until midnight in Bangkok (UTC+7) const ms = bangkokDayEnd(bangkokToday()) - new Date(); const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); if (h > 0) return `มาในอีก ${h} ชั่วโมง ${m} นาที`; return `มาในอีก ${m} นาที`; } async function _loadTomorrowPassageTease() { try { const bkkNow = new Date(Date.now() + 7 * 3600 * 1000); const tmrwStr = new Date(Date.UTC(bkkNow.getUTCFullYear(), bkkNow.getUTCMonth(), bkkNow.getUTCDate() + 1)).toISOString().slice(0, 10); const snap = await db.collection('readingPassages').where('activeDate', '==', tmrwStr).limit(1).get(); const topicEl = document.getElementById('tmrwTopic'); if (!topicEl) return; if (snap.empty) { topicEl.textContent = 'บทอ่านใหม่'; return; } const p = snap.docs[0].data(); const full = MODE_LABELS[p.mode] || ''; const typeLabel = full.replace('A-Level อังกฤษ: ', '').replace('A-Level อังกฤษ', 'A-Level'); topicEl.textContent = typeLabel || 'บทอ่านใหม่'; } catch(e) { /* silent */ } } function _startTmrwCountdown(el) { if (!el) return; function tick() { const now = new Date(); const ms = bangkokDayEnd(bangkokToday()) - now; if (ms <= 0) { el.textContent = 'ถึงเวลาแล้ว!'; return; } const h = Math.floor(ms / 3600000); const m = Math.floor((ms % 3600000) / 60000); const s = Math.floor((ms % 60000) / 1000); el.textContent = `${h} ชั่วโมง ${m} นาที ${s} วินาที`; setTimeout(tick, 1000); } tick(); } window.shareResult = function() { if (!_shareData) return; const { score, correct, total, wpm, elapsedSec, dailyStreak } = _shareData; const timeStr = fmtClock(elapsedSec * 1000); const streakLine = dailyStreak ? `🔥 ติดต่อกัน ${dailyStreak} วัน ` : ''; const text = `📖 ReadingLab วันนี้ ✅ ${score}% — ${correct}/${total} ข้อถูก ⏱️ ${timeStr} · ${wpm} คำ/นาที ${streakLine}chestudygroup.com/readinglab`; if (navigator.share) { navigator.share({ title: 'ReadingLab', text, url: 'https://www.chestudygroup.com/readinglab' }).catch(() => {}); } else { const fallback = () => { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); toast('คัดลอกแล้ว!'); }; if (navigator.clipboard) { navigator.clipboard.writeText(text).then(() => toast('คัดลอกแล้ว!')).catch(fallback); } else { fallback(); } } }; // ── Review cancel confirm ──────────────────────────────────────────────── window._cancelReview = function() { document.getElementById('reviewMainContent').classList.add('hide'); document.getElementById('reviewCancelConfirm').classList.remove('hide'); }; window._confirmCancelNo = function() { document.getElementById('reviewCancelConfirm').classList.add('hide'); document.getElementById('reviewMainContent').classList.remove('hide'); }; window._confirmCancelYes = function() { _closeReviewModal(null); }; // ── End share / cancel confirm ────────────────────────────────────────────
รีวิวบทอ่านวันนี้
ช่วยให้ทีมปรับปรุงบทอ่านให้เหมาะสมยิ่งขึ้น
หยุดจับเวลาแล้ว เขียนรีวิวได้นะครับ
ความพอดีของความยากและความยาว
น้อยที่สุดมากที่สุด
ความคิดเห็น ไม่บังคับ
ยืนยันการยกเลิก?
คำตอบจะไม่ถูกบันทึก และต้องเริ่มทำใหม่
ติดตั้ง ReadingLab
เพิ่มลงหน้าจอโฮมเพื่อใช้งานแบบแอป
คุณกำลังใช้ ReadingLab เป็นแอปอยู่แล้ว
iPhone / iPad
Safari เท่านั้น
อุปกรณ์ของคุณ
1
เปิดหน้านี้ใน Safari (ไม่รองรับบน Chrome หรือแอปอื่นบน iOS)
2
กดปุ่ม แชร์ ที่แถบเครื่องมือด้านล่าง
3
เลื่อนลงในเมนูแชร์ แล้วเลือก "เพิ่มไปยังหน้าจอโฮม"
4
กด "เพิ่ม" มุมขวาบน — ไอคอน ReadingLab จะปรากฏบนหน้าจอโฮม
Android
Chrome / Samsung Internet
อุปกรณ์ของคุณ
1
เปิดหน้านี้ใน Chrome หรือ Samsung Internet
2
กดเมนู มุมขวาบน แล้วเลือก "เพิ่มในหน้าจอหลัก" หรือ "ติดตั้งแอป"
3
กด "ติดตั้ง" หรือ "เพิ่ม" เพื่อยืนยัน
4
บางครั้ง Chrome จะแสดง banner "ติดตั้ง ReadingLab" อัตโนมัติที่ด้านล่างหน้าจอ กดนั้นได้เลย
คอมพิวเตอร์
Chrome / Edge
อุปกรณ์ของคุณ
1
มองที่แถบ address bar — จะมีไอคอน ติดตั้ง ปรากฏขึ้น
2
คลิกไอคอนนั้น แล้วเลือก "ติดตั้ง" ในกล่องที่ pop-up ขึ้นมา
3
ReadingLab จะเปิดเป็นหน้าต่างแยกต่างหาก ไม่มี browser bar — เหมือนแอปจริง