Trần Huy - Trợ thủ Giảng dạy Thông minh & Chuẩn hóa Sư phạm
00:00:00
Kiểu đồng hồ
Sẵn sàng
05:00
Tổng thời gian05:00
Thời gian nhanh
Tùy chỉnh phút và giây
:
Danh sách mốc thời gian ghi nhận (Laps)
Chưa có mốc thời gian nào được ghi nhận.
📋Danh sách lớp & Điểm
0 học sinh
⭐ 0 Đặc biệt
Đã được gọi gần đây (0):
Chưa ai được gọi
🎯 Phạm vi quay:
🎁
???
🎉 Xin chúc mừng bạn
Nguyễn Văn A
Thưởng điểm trả lời:
🎯 Câu hỏi tư duy
Bạn hãy tóm tắt nội dung chính của bài học bằng 1 câu ngắn gọn?
👥Chia nhóm thông minh
Cân bằng giới tính, năng lực, học sinh ⭐; ghim thành viên, ràng buộc “cùng / không cùng nhóm”, tránh lặp nhóm cũ và tự giao vai trò. Kéo thả để chỉnh tay.
🏫 Lớp
👀 Xem trước 0
Mẹo: dán nguyên bảng từ Excel cũng được — tự bỏ cột STT, dòng tiêu đề, tự ghép cột “Họ đệm” + “Tên”. Giới tính lấy từ cột Giới tính hoặc nhãn [Nữ]; nếu không có, tên chứa “Thị” được coi là nữ. Khi thay danh sách, học sinh trùng tên được giữ nguyên điểm, ⭐ và vai trò.
① Quy mô nhóm
② Chiến lược
③ Đặt tên & vai trò
④ Yếu tố cân bằng
⑤ Ràng buộc cặp học sinh
📋 Danh sách toàn bộ học sinh 0
🎭 Vai trò
Tick vào vòng tròn để giao vai trò — mỗi nhóm chỉ một bạn giữ mỗi vai trò, bạn đang giữ sẽ thành thành viên (bấm lại để bỏ) · Nhập nhanh: bấm vào một dòng rồi gõ 1–6 để giao và tự xuống bạn kế tiếp, 0 để bỏ, ↑↓ để di chuyển · Bấm tiêu đề cột để sắp xếp.
📈Đồ thị âm thanh · Dao động ký điện tử
Khảo sát đồ thị âm: biên độ, tần số, chu kì, cường độ âm và mức cường độ âm — có lưới chia ô và thang thời gian như dao động ký thật.
STOPCH1⏱ 1 ms/ô↕ 0,1 đv/ôTrig ↗ 0,00— kHz
Thời gian/ô1 ms
Biên độ/ô0,1 đv
📊 Số đo tự động
TẦN SỐf
—
—
CHU KÌT
—
—
BIÊN ĐỘA
—
—
SỐ DAO ĐỘNGN
—
toàn phần / 10 ô
MỨC CƯỜNG ĐỘL
—
—
CƯỜNG ĐỘ ÂMI
—
W/m²
ĐỘ CAO · LOẠI ÂMω = 2πf
—
—
📏 Đo bằng con trỏ
Δt = t₂ − t₁—
Số dao động toàn phần n
T = Δt / n—
f = n / Δt—
ΔY → A = ΔY/2—
Bấm ⏸ Dừng hình, kéo hai vạch vàng tới hai đỉnh (hoặc hai điểm cùng pha) cách nhau n dao động toàn phần, rồi nhập n.
🌈 Phổ tần số (FFT)
Đỉnh cao nhất bên trái thường là âm cơ bản f₀; các đỉnh ở 2f₀, 3f₀… là họa âm — quyết định âm sắc.
🎛️ Máy phát âm tần
Hz
0,50
Chọn nguồn 🎛️ Máy phát âm để xem tín hiệu chuẩn. Bật Âm thứ 2 với tần số gần nhau (440 & 444 Hz) để quan sát hiện tượng phách (fphách = |f₁ − f₂|).
🧮 Công thức & hiệu chuẩn
T = 1 / fChu kì (s) – tần số (Hz)
T = Δt / nĐọc từ đồ thị: n dao động trong Δt
Δt = số ô × (t/ô)Đọc thời gian trên lưới
L = 10 lg(I / I₀)Mức cường độ âm (dB), I₀ = 10⁻¹² W/m²
I = P / (4πr²)Cường độ âm của nguồn điểm
100 dB
⚠️ Micro máy tính chưa được hiệu chuẩn: A đo theo đơn vị tương đối (đv), còn L và I là giá trị ước lượng. Muốn chính xác, đặt cạnh máy đo dB chuẩn rồi kéo “Hiệu chuẩn” tới khi hai số trùng nhau.
🏆Bảng điểm & Vinh danh thành tích
Theo dõi điểm thi đua các đội, Level cá nhân và xuất báo cáo PDF
Danh sách các đội thi đua
Tìm kiếm:
Hạng
Họ và tên & Cấp độ
Giới tính
Ngày sinh
Email
Yếu tố đặc biệt
Điểm
Thao tác
💡Ngân hàng Thử thách & Câu hỏi Sư phạm PRO
Quản lý câu hỏi theo môn học, độ khó, chủ đề; thêm/sửa/xóa và lưu trữ dài lâu
Tung xúc xắc ngẫu nhiên
Chọn 1–3 viên, tự cộng tổng điểm
Tổng điểm–
Tung đồng xu (Sấp / Ngửa)
Lật 3D, thống kê số lần ra mỗi mặt
SẤP
NGỬA
Sấp 0Ngửa 0
Bốc thăm số ngẫu nhiên
Chọn khoảng số, có thể bỏ qua số đã bốc
--
📝Bảng vẽ & Ghi chú nhanh (Hỗ trợ Undo)
Hỗ trợ vẽ cảm ứng trên Smartboard, bút stylus và lưu lại làm tư liệu buổi học.
🌅Màn hình chờ đầu giờ
Đồng hồ, đếm ngược vào học, nhiệm vụ khởi động, danh ngôn, chúc mừng sinh nhật
🔦Rèm che & Đèn soi
Dùng được trên mọi tab: bấm nút 🔦 ở mép trái màn hình hoặc dùng phím tắt.
Rèm: kéo tấm rèm bằng chuột hoặc ngón tay để hé dần; phím mũi tên mở hoặc đóng thêm 10%. Phần đã hé vẫn bấm được bình thường.
Đèn soi: vùng sáng đi theo chuột; lăn chuột hoặc phím + / − để đổi cỡ; bấm chuột để ghim hoặc bỏ ghim.
Nhấn Esc hoặc nút ✕ để tắt.
🧮Máy tính khoa học
0
Enter để tính · nhập được dấu phẩy thập phân · ^ là lũy thừa
Bấm vào giá trị để chèn vào máy tính · bấm 🔍 để chiếu to cho cả lớp.
⏱️Đồng hồ thí nghiệm & Xử lý sai số
Bấm đo nhiều lần, tự tính giá trị trung bình, sai số tuyệt đối và tỉ đối
0,00
🧪 Tính g từ con lắc đơn (g = 4π²ℓ / T²)
📈Vẽ đồ thị nhanh
Vẽ hàm số hoặc số liệu thí nghiệm, kèm đường xu hướng và phương trình
Biến là x; hàm lượng giác tính theo radian. Có: sin, cos, tan, sqrt, ln, log, exp, abs, pi.
📷Máy chiếu vật thể (webcam)
Đặt bài làm dưới webcam để chiếu · Space chụp · lăn chuột hoặc chụm 2 ngón để phóng to · R xoay ảnh · P bút · H kéo ảnh · ← → chuyển ảnh · Ctrl+Z hoàn tác · F toàn màn hình. Không có webcam vẫn mở được ảnh từ điện thoại hoặc dán ảnh (Ctrl+V).
🤝Ghép cặp đôi (Think-Pair-Share)
Tự động bắt cặp 2 bạn thảo luận nhanh
Bấm "Ghép cặp ngay" để tự động tạo danh sách học đôi ngẫu nhiên.
💡Ngân hàng Thử thách Sư phạm
Khởi động tiết học
Hãy chia sẻ một điều thú vị hoặc bất ngờ nhất bạn đã tìm hiểu được trong tuần qua?
🎵Âm thanh Sư phạm & Tập trung PRO
Web Audio API
Tích hợp các tín hiệu âm thanh chuẩn sư phạm giúp thu hút chú ý tức thì và điều hòa nếp lớp:
Thiết Lập Bộ Công Cụ Lớp Học
Trích xuất đề PDF • Chấm tự động Excel • Chọn chế độ chơi tương tác cực đỉnh
Cấu Hình Chung & Các Đội ChơiTrò chơi: Lật Mảnh Ghép
Đang phân tích câu hỏi PDF và cấu hình dữ liệu...
📦
Lesson Planner (Gói bài giảng nhanh)
Lưu trọn vẹn trạng thái đếm giờ, đèn nề nếp, điểm đội, câu hỏi và trò chơi của buổi học
Danh sách các gói bài học đã lưu:
•
📱Mã QR & Link Xem Điểm Trực Tiếp
Học sinh hoặc phụ huynh quét mã dưới đây bằng điện thoại để xem trực tiếp bảng vàng và điểm số (Chế độ chỉ đọc):
Đường dẫn chia sẻ (Link):
🎖️Trao danh hiệu khen thưởng PRO
Chọn danh hiệu xứng đáng để trao cho bạn Học sinh:
📊
Quản lý Dữ liệu & Sao lưu
Hỗ trợ: STT, Lớp, Họ và tên, Giới tính, Ngày sinh, Mail, Yếu tố đặc biệt
Chế độ tự động nạp (Auto-load)
Hệ thống tự động tìm và nạp file classroom_data.xlsx đặt cùng thư mục khi trang được mở.
💾1. Xuất Dữ liệu ra Excel
Lưu toàn bộ danh sách lớp học ra file classroom_data.xlsx.
📂2. Nạp File Excel từ máy
Nạp trực tiếp danh sách học sinh từ file trên máy tính của bạn.
🛡️3. Sao lưu / Khôi phục TOÀN BỘ dữ liệu (.json)
Một file chứa tất cả: danh sách lớp & học sinh, điểm đội, ngân hàng câu hỏi, gói bài giảng, ván chơi đã lưu, ngôn ngữ và giao diện. Dùng để chuyển sang máy khác hoặc phòng mất dữ liệu trình duyệt.
📥
Kéo thả file Excel vào đây để nạp nhanh
Hỗ trợ các file định dạng .xlsx, .xls
💡 Cấu trúc cột Excel được hỗ trợ tự động:
File Excel chuẩn bao gồm: STT | Lớp | Họ và tên | Giới tính | Ngày sinh | Mail | Yếu tố đặc biệt.
⌨️Phím tắt hỗ trợ giảng dạy
Bắt đầu / Tạm dừng Đếm giờ:Space
Quay vòng / Gọi tên ngẫu nhiên:Phím R
Chuyển nhanh chức năng:Phím 1 → 0 theo thứ tự menu
Chọn ô → Trả lời đúng → Chiếm ô → Nối hàng để thắng!
Đội đang lượt hãy chọn một ô trống trên bàn cờ!
🎯 Chiếm ô = +điểm🔗 Nối N ô = thắng🛡️ Chặn đối thủ
BINGO
Chọn số → Trả lời đúng → Chiếm ô → Nối hàng BINGO!
Đội đang lượt hãy chọn một số trên bảng Bingo!
🎯 Chiếm ô = +điểm🔗 Nối hàng = BINGO!🏆 Thưởng BINGO lớn
HỆ THỐNG GỠ BOM
DEFCON 1
Đến lượt Đội 1 — hãy chọn 1 sợi dây để giải mã cắt kíp nổ!
✂️ Cắt đúng = Vô hiệu hóa + Thưởng💣 Chuyền bom sinh tử💥 Sai = Kích nổ trừ điểm
ĐẤU TRÍ
Hai đội đối đầu 1 câu hỏi — Đúng thắng, Sai thua!
Nhấn nút để bắt đầu hiệp đấu mới!
⚔️ 2 đội ngẫu nhiên✅ Đúng = +điểm❌ Sai = đối thủ +điểm
CHUỖI COMBO
Trả lời đúng liên tiếp để tăng hệ số điểm! Sai = đứt chuỗi + bất ngờ!
Chuỗi hiện tại
0
Hệ số: ×1
Đội đang lượt nhấn nút để trả lời và xây chuỗi!
🔥 Combo tăng hệ số🎁 Mốc 3 = siêu thưởng💥 Đứt = bất ngờ
MA TRẬN ĐIỂM
Chọn ô điểm → Trả lời đúng nhận điểm + bất ngờ ẩn!
Đội đang lượt hãy chọn một ô trên ma trận!
💰 Ô điểm 10–50🔥 x2 / Jackpot🦹 Cướp / Bẫy ẩn
OLYMPIA
Trả lời đúng +10 · Ngôi sao hy vọng: đúng +20 / sai −10 · Ít may rủi, dựa vào năng lực!
Điểm câu hỏi
+10
Câu đã chơi
0
Chưa đặt sao · Đúng = +10 · Sai = 0
Đội đang lượt có thể đặt Ngôi sao hy vọng rồi trả lời câu hỏi!
✅ Đúng = +10⭐ Sao hy vọng: đúng +20⭐ Sao + sai = −10🎯 Thuần kỹ năng
THÁCH THỨC
Chọn độ khó → Trả lời → Điểm theo mức đã chọn. Sai có thể bị trừ. Không may rủi ngẫu nhiên!
Chọn độ khó trước khi trả lời
Chưa chọn độ khó · Sai (Vừa/Khó) sẽ bị trừ điểm
Đội đang lượt hãy chọn độ khó rồi nhấn trả lời!
Câu đã chơi: 0
Dễ +10 / sai 0Vừa +20 / sai −10Khó +30 / sai −15🎯 Tự chọn rủi ro
KIM TỰ THÁP
Trả lời đúng để leo bậc. Sai có thể tụt 1 bậc. Đỉnh kim tự tháp = chiến thắng!
Đội đang lượt nhấn nút để thử thách bậc tiếp theo!
▲ Đúng = leo + điểm▼ Sai = tụt 1 bậc🏆 Đỉnh = thắng🎯 Thuần kỹ năng
CÂU HỎI SỐ 1CHI TIẾT
ĐỘI 1
20
(function(){var P=window.parent;Element.prototype.requestFullscreen=function(){return Promise.resolve();};var last=null;function send(){var on=document.body.classList.contains("present");if(on!==last){last=on;P.postMessage({seat:"fs",on:on},"*");}}document.addEventListener("DOMContentLoaded",function(){new MutationObserver(send).observe(document.body,{attributes:true,attributeFilter:["class"]});});window.addEventListener("message",function(e){if(e.data&&e.data.seat==="exit"&&document.body.classList.contains("present")&&window.togglePresent)window.togglePresent();});})();<\/script>';
const i18nSn = (typeof window.__i18nIframeSnippet === 'function') ? window.__i18nIframeSnippet() : '';
{ const inj = fix + bridge + i18nSn + tkFrameSync('seating'); html = html.replace('', () => inj + ''); }
fr.srcdoc = html;
}
/* ===== Xử lý PDF (EduDoc Studio nhúng) ===== */
/* ===== Khung nhúng tự giãn theo nội dung → chỉ còn 1 thanh cuộn (của trang chính) =====
--tk-vh0: chiều cao "màn hình" cố định thay cho 100vh trong khung (tránh vòng lặp giãn);
--tk-vt / --tk-vh: phần khung đang nhìn thấy → hộp thoại/thông báo "fixed" luôn nằm trong tầm mắt. */
const TK_AUTO_CSS = '';
function tkFitAutoFrame(fr, minBase) {
if (!fr || !fr.offsetParent) return;
const rect = fr.getBoundingClientRect();
const minH = Math.max(minBase, Math.round(window.innerHeight - (rect.top + window.scrollY) - 24));
let doc = null; try { doc = fr.contentDocument; } catch (e) {}
if (!doc || !doc.body || !doc.documentElement) { fr.style.height = minH + 'px'; return; }
const root = doc.documentElement;
const hdr = document.querySelector('header.app-header');
const hb = hdr ? Math.max(0, hdr.getBoundingClientRect().bottom) : 0;
const vTop = Math.max(rect.top, hb), vBot = Math.min(rect.bottom, window.innerHeight);
root.style.setProperty('--tk-vh0', minH + 'px');
root.style.setProperty('--tk-vt', Math.round(Math.max(0, vTop - rect.top)) + 'px');
root.style.setProperty('--tk-vh', Math.round(Math.max(200, vBot - vTop)) + 'px');
const h = Math.ceil(doc.body.getBoundingClientRect().height);
fr.style.height = Math.max(minH, h) + 'px';
}
function tkWatchAutoFrame(fr, fit) {
fr.addEventListener('load', () => {
fit();
let t = 0;
const later = () => { clearTimeout(t); t = setTimeout(fit, 60); };
try {
const w = fr.contentWindow, d = w.document;
new (w.ResizeObserver || window.ResizeObserver)(() => fit()).observe(d.body);
new (w.MutationObserver || window.MutationObserver)(later).observe(d.body, { childList: true, subtree: true, attributes: true, characterData: true });
['input', 'click', 'transitionend'].forEach(ev => d.addEventListener(ev, later, true));
w.addEventListener('resize', later);
} catch (e) {}
});
}
let tkAutoRaf = 0;
window.addEventListener('scroll', () => {
if (tkAutoRaf) return;
tkAutoRaf = requestAnimationFrame(() => { tkAutoRaf = 0; fitPdfFrame(); fitMarkwordFrame(); });
}, { passive: true });
function fitPdfFrame() {
if (currentTab !== 'pdf') return;
tkFitAutoFrame(document.getElementById('pdf-frame'), 480);
}
window.addEventListener('resize', fitPdfFrame);
function loadPdfFrame() {
const fr = document.getElementById('pdf-frame');
const src = document.getElementById('pdf-src');
if (!fr || !src || fr.dataset.loaded) return;
fr.dataset.loaded = '1';
// Gọn giao diện khi nằm trong Toolkit: ẩn dòng thương hiệu + nút ngôn ngữ riêng (dùng ngôn ngữ chung của Toolkit), bỏ header dính.
const fix = '';
const lang = (window.AppState && AppState.lang) || (typeof currentLanguage !== 'undefined' ? currentLanguage : 'vi');
const bridge = '', () => inj + ''); }
tkWatchAutoFrame(fr, fitPdfFrame);
fr.srcdoc = html;
}
/* ===== MarkWord (Markdown → Word .docx) nhúng ===== */
function fitMarkwordFrame() {
if (currentTab !== 'markword') return;
tkFitAutoFrame(document.getElementById('markword-frame'), 560);
}
window.addEventListener('resize', fitMarkwordFrame);
function loadMarkwordFrame() {
const fr = document.getElementById('markword-frame');
const src = document.getElementById('markword-src');
if (!fr || !src || fr.dataset.loaded) return;
fr.dataset.loaded = '1';
// Gọn giao diện trong Toolkit: ẩn khối logo/thương hiệu, giữ nguyên các nút tác vụ; bỏ header dính.
const fix = '';
let html = src.textContent.replace(/<\\\/script/g, '', () => inj + ''); }
tkWatchAutoFrame(fr, fitMarkwordFrame);
fr.srcdoc = html;
}
/* ===== Phân tích Video thí nghiệm Vật lí (nhúng nguyên bản) ===== */
function fitVideoanFrame() {
const fr = document.getElementById('videoan-frame');
if (!fr || currentTab !== 'videoan') return;
const top = fr.getBoundingClientRect().top + window.scrollY;
fr.style.height = Math.max(560, window.innerHeight - top - 24) + 'px';
}
window.addEventListener('resize', fitVideoanFrame);
/* Từ điển EN riêng cho Phân tích Video: khớp NGUYÊN VĂN (không thay chuỗi con) để không dịch lẫn;
câu có số liệu thay đổi dùng mẫu RegExp (áp dụng theo thứ tự, cụ thể trước – chung sau). */
const VIDEOAN_EX = [
/* Đầu trang & thanh công cụ */
['Phần mềm phân tích video thí nghiệm Vật lí · Trần Huy','Physics experiment video analysis · Trần Huy'],
['Mở video (Ctrl+O)','Open video (Ctrl+O)'],['Mở video','Open video'],['Hướng dẫn & phím tắt (?)','Guide & shortcuts (?)'],
['Chế độ thao tác','Interaction mode'],['Đánh dấu vật (Esc)','Mark object (Esc)'],['Đánh dấu','Mark'],
['Hiệu chuẩn thước đo (C)','Calibrate scale (C)'],['Hiệu chuẩn','Calibrate'],['Đặt gốc tọa độ O (O)','Set origin O (O)'],['Gốc O','Origin O'],
['Bật/tắt lưới (G)','Toggle grid (G)'],['Bật/tắt kính lúp (L)','Toggle magnifier (L)'],['Hoàn tác (Ctrl+Z)','Undo (Ctrl+Z)'],
['Mở một video (MP4, WebM, MOV) để bắt đầu.','Open a video (MP4, WebM, MOV) to get started.'],
/* Khung video */
['Kéo thả video thí nghiệm vào đây','Drag and drop your experiment video here'],
['Hỗ trợ MP4 (H.264), WebM, MOV… Video quay chậm (slow-motion) cũng dùng được — nhập hệ số làm chậm trong mục Thiết lập.','Supports MP4 (H.264), WebM, MOV… Slow-motion videos work too — enter the slow-motion factor under Setup.'],
['Chọn video từ máy','Choose a video from your device'],['Thả video vào đây','Drop the video here'],
['Đang tải video…','Loading video…'],['Không đọc được video này','Unable to read this video'],
['Trình duyệt không giải mã được định dạng (thường gặp với HEVC/H.265 từ iPhone). Hãy chuyển sang MP4 (H.264) hoặc mở bằng Chrome/Edge mới nhất.','Your browser cannot decode this format (common with HEVC/H.265 from iPhones). Convert it to MP4 (H.264) or open it in the latest Chrome/Edge.'],
/* Điều khiển phát */
['Thanh tua theo khung hình','Frame-by-frame seek bar'],['Về khung đầu (Home)','First frame (Home)'],
['Lùi 1 khung (← hoặc ,) — giữ Shift: 10 khung','Back 1 frame (← or ,) — hold Shift: 10 frames'],
['Phát / Tạm dừng (Space)','Play / Pause (Space)'],
['Tới 1 khung (→ hoặc .) — giữ Shift: 10 khung','Forward 1 frame (→ or .) — hold Shift: 10 frames'],
['Tới khung cuối (End)','Last frame (End)'],['Khung','Frame'],['Tốc độ phát','Playback speed'],['Tốc độ','Speed'],
['Click lên video để đánh dấu · Cuộn chuột để chuyển khung','Click the video to mark · Scroll to change frames'],
/* Bảng bên phải */
['Thiết lập','Setup'],['Dữ liệu','Data'],['Đồ thị','Graph'],
['Tệp','File'],['Kích thước','Dimensions'],['Thời lượng','Duration'],
['Số khung/giây (fps)','Frames per second (fps)'],['Tự dò lại fps của video','Re-detect the video frame rate'],['Dò','Detect'],
['Hệ số quay chậm','Slow-motion factor'],
['fps được tự dò khi mở video. Với video slow-motion, hệ số = fps khi quay ÷ fps khi phát (vd: quay 240 fps, phát 30 fps → 8).','fps is detected automatically when a video is opened. For slow-motion video, factor = capture fps ÷ playback fps (e.g. captured at 240 fps, played at 30 fps → 8).'],
['Hiệu chuẩn thước đo','Scale calibration'],['Tỉ lệ','Scale'],['Chưa hiệu chuẩn','Not calibrated'],['Đoạn chuẩn','Reference length'],
['Hiệu chuẩn (C)','Calibrate (C)'],
['Click 2 đầu của một vật có chiều dài đã biết (thước, vạch chia…) rồi nhập chiều dài thật.','Click both ends of an object of known length (ruler, scale marks…) and enter its real length.'],
['Hệ trục tọa độ Oxy','Oxy coordinate system'],['Gốc O (px)','Origin O (px)'],['Đặt gốc O (O)','Set origin O (O)'],
['Đưa gốc O về góc dưới-trái','Move origin O to the bottom-left corner'],['Góc dưới-trái','Bottom-left'],
['Chiều dương Ox','Positive Ox direction'],['Chiều dương Oy','Positive Oy direction'],
['→ Sang phải','→ Right'],['← Sang trái','← Left'],['↑ Hướng lên','↑ Up'],['↓ Hướng xuống','↓ Down'],
['Tự chuyển khung sau khi đánh dấu','Advance frame automatically after marking'],['Mỗi lần nhảy','Step size'],['khung','frames'],
['Lấy t = 0 tại điểm đánh dấu đầu tiên','Set t = 0 at the first marked point'],
['Click lên video để đánh dấu vị trí vật ở khung hiện tại. Đánh dấu lại ở cùng khung sẽ thay thế điểm cũ.','Click the video to mark the object position in the current frame. Marking the same frame again replaces the previous point.'],
['Lưu dự án','Save project'],['Mở dự án','Open project'],
['Xóa điểm đang chọn (Del)','Delete selected point (Del)'],['Xóa tất cả điểm','Delete all points'],
['Click một dòng để chọn điểm và tua video tới khung đó. Vận tốc tính bằng sai phân trung tâm.','Click a row to select a point and jump to that frame. Velocity is computed using central differences.'],
['Chưa có điểm nào. Click lên vật trong video để đánh dấu.','No points yet. Click the object in the video to mark it.'],
['x theo t','x vs t'],['y theo t','y vs t'],['Quỹ đạo y theo x','Trajectory y vs x'],['vx theo t','vx vs t'],['vy theo t','vy vs t'],['Tốc độ v theo t','Speed v vs t'],
['Đường khớp','Curve fit'],['Không','None'],['Bậc nhất (thẳng)','Linear (straight line)'],['Bậc hai (parabol)','Quadratic (parabola)'],
['Chọn kiểu đường khớp để tìm phương trình và các đại lượng vật lí.','Choose a curve fit to find the equation and physical quantities.'],
['Tải ảnh đồ thị (PNG)','Download graph image (PNG)'],
['Vận tốc trung bình ≈','Average velocity ≈'],['Gia tốc ≈','Acceleration ≈'],['Gia tốc a = 2A ≈','Acceleration a = 2A ≈'],
['· Vận tốc đầu v₀ ≈','· Initial velocity v₀ ≈'],['Chưa hiệu chuẩn — đơn vị đang là pixel.','Not calibrated — units are in pixels.'],
/* Hộp thoại */
['Nhập chiều dài thật','Enter the real length'],['Đoạn vừa chọn dài','The selected segment is'],
['pixel trên video. Nhập chiều dài thật của đoạn này:','pixels long in the video. Enter its real length:'],
['Ví dụ: 50','e.g. 50'],['Hủy','Cancel'],['Xác nhận','Confirm'],
['Hướng dẫn nhanh','Quick guide'],
['1. Mở video → 2. Hiệu chuẩn bằng vật có chiều dài đã biết → 3. Đặt gốc O và chiều trục → 4. Click lên vật ở từng khung hình → 5. Xem bảng số liệu, đồ thị và khớp hàm.','1. Open a video → 2. Calibrate with an object of known length → 3. Set origin O and axis directions → 4. Click the object in each frame → 5. Review the data table, graphs and curve fits.'],
['Phát / tạm dừng','Play / pause'],['Tới / lùi 1 khung (giữ','Forward / back 1 frame (hold'],[': 10 khung)',': 10 frames)'],
['Cuộn chuột trên video','Mouse wheel over video'],['Chuyển khung','Change frame'],['Khung đầu / khung cuối','First / last frame'],
['Click /','Click /'],['Đánh dấu vị trí tại con trỏ','Mark position at cursor'],['Hiệu chuẩn · Đặt gốc O','Calibrate · Set origin O'],
['Lưới · Kính lúp','Grid · Magnifier'],['Hoàn tác','Undo'],['Xóa điểm đang chọn','Delete selected point'],
['Hủy thao tác, về chế độ đánh dấu','Cancel action, return to marking mode'],['Đã hiểu','Got it'],
/* Thông báo trạng thái (cố định) */
['Tệp đã chọn không phải video.','The selected file is not a video.'],
['Đang dò số khung hình/giây…','Detecting frame rate…'],
['Video sẵn sàng. Không tự dò được fps — đang dùng 30 fps, hãy kiểm tra lại.','Video ready. Frame rate could not be detected — using 30 fps, please verify.'],
['Lỗi giải mã video.','Video decoding error.'],['Đang phát… (Space để dừng)','Playing… (Space to pause)'],
['Chế độ đánh dấu: click lên vật ở mỗi khung hình.','Marking mode: click the object in each frame.'],
['Hiệu chuẩn: click điểm ĐẦU của đoạn có chiều dài đã biết.','Calibration: click the START of a segment of known length.'],
['Hiệu chuẩn: click điểm CUỐI của đoạn.','Calibration: click the END of the segment.'],
['Đặt gốc O: click vào vị trí muốn làm gốc tọa độ.','Set origin O: click where you want the coordinate origin.'],
['Hãy mở video trước.','Please open a video first.'],['Đã tới khung cuối của video.','Reached the last frame of the video.'],
['Không còn thao tác để hoàn tác.','Nothing left to undo.'],['Đã hoàn tác.','Undone.'],
['Chưa chọn điểm nào (click một dòng trong bảng).','No point selected (click a row in the table).'],
['Đã xóa tất cả điểm (Ctrl+Z để hoàn tác).','All points deleted (Ctrl+Z to undo).'],
['Hai điểm hiệu chuẩn quá gần nhau, hãy chọn lại điểm đầu.','The two calibration points are too close — pick the start point again.'],
['Đã hủy hiệu chuẩn — giữ nguyên tỉ lệ cũ.','Calibration cancelled — previous scale kept.'],
['Chưa có dữ liệu để xuất.','No data to export yet.'],['Đã xuất tệp CSV.','CSV file exported.'],['Đã lưu dự án (JSON).','Project saved (JSON).'],
['Đã mở dự án, nhưng kích thước video khác video hiện tại — kiểm tra lại.','Project opened, but its video size differs from the current video — please check.'],
['Đưa chuột vào vị trí vật trên video rồi nhấn M.','Hover over the object in the video, then press M.'],
['Trình duyệt không hỗ trợ tự dò fps — hãy nhập tay.','This browser cannot detect the frame rate — please enter it manually.'],
['Gốc O đã về góc dưới-trái.','Origin O moved to the bottom-left corner.']
];
const VIDEOAN_RX = [
['Video sẵn sàng \\((.+?) fps, (\\d+) khung\\)\\. Hãy hiệu chuẩn thước đo\\.', '', 'Video ready ($1 fps, $2 frames). Now calibrate the scale.'],
['Đã đặt gốc O tại \\((.+?)\\) px\\.', '', 'Origin O set at ($1) px.'],
['Đã cập nhật điểm ở khung (\\d+) \\(tổng (\\d+) điểm\\)\\.', '', 'Updated the point at frame $1 ($2 points total).'],
['Đã đánh dấu điểm ở khung (\\d+) \\(tổng (\\d+) điểm\\)\\.', '', 'Marked a point at frame $1 ($2 points total).'],
['Đã xóa điểm ở khung (\\d+)\\.', '', 'Deleted the point at frame $1.'],
['Hiệu chuẩn xong: 1 px = (\\S+) m\\. Tiếp theo: đặt gốc O\\.', '', 'Calibration done: 1 px = $1 m. Next: set origin O.'],
['Cần ít nhất (\\d+) điểm để khớp hàm bậc (\\d+)\\.', '', 'At least $1 points are needed for a degree-$2 fit.'],
['Đã mở dự án \\((\\d+) điểm\\)\\.', '', 'Project opened ($1 points).'],
['Không đọc được tệp dự án: thiếu dữ liệu điểm', '', 'Unable to read the project file: point data is missing'],
['Không đọc được tệp dự án: ', '', 'Unable to read the project file: '],
['Đã dò được (\\S+) fps\\.', '', 'Detected $1 fps.'],
['Đã đặt (\\S+) fps\\.', '', 'Frame rate set to $1 fps.'],
['(\\d+) điểm', 'g', '$1 points']
];
function videoanI18nSnippet() {
const j = o => JSON.stringify(o).replace(/(function(){var boot=' + __i18nBoot.toString() + ';var E=boot([],' + j(VIDEOAN_RX) + ',' + j(VIDEOAN_EX) + ',document);' +
'function set(l){l=l==="en"?"en":"vi";document.documentElement.lang=l;E.setLang(l);}set(' + j(lang) + ');' +
'window.addEventListener("message",function(e){if(e.data&&e.data.i18n)set(e.data.i18n);});})();<\/script>';
}
/* Từ điển EN riêng cho MarkWord: chỉ dịch giao diện (nút, nhãn, thông báo) – nội dung người dùng
trong ô soạn & trang xem trước được giữ nguyên (data-no-i18n). */
const MARKWORD_EX = [
['Chuyển đổi Markdown sang MS Word .DOCX - Công thức toán Office Equation gốc','Convert Markdown to MS Word .DOCX - native Office Equation math'],
['Dán nội dung','Paste content'],['Dán văn bản trực tiếp từ bộ nhớ tạm','Paste text straight from the clipboard'],
['Mẫu Tự luận','Essay sample'],['Nạp bảng tự luận & hướng dẫn chấm Vật lí 11','Load the Physics 11 essay table & marking guide'],
['Mẫu Véc-tơ','Vector sample'],['Nạp bài tập Véc-tơ đơn và đa ký tự chuẩn','Load vector exercises (single- and multi-letter vectors)'],
['Mẫu Trắc nghiệm','Multiple-choice sample'],['Nạp đề kiểm tra trắc nghiệm','Load a multiple-choice test'],
['Xóa','Clear'],['Xóa trắng nội dung','Clear all content'],
['Chép sang Word','Copy to Word'],['Sao chép toàn bộ để dán trực tiếp Ctrl+V vào Word thành công thức toán','Copy everything, then press Ctrl+V in Word to paste it as real equations'],
['Tải file .DOCX','Download .DOCX'],
['Phông chữ xuất Word:','Word export font:'],['Times New Roman (Chuẩn quy phạm)','Times New Roman (official standard)'],
['Cỡ chữ:','Font size:'],['13 pt (Nghị định 30)','13 pt (Decree 30)'],['14 pt (Chuẩn đề thi/giáo án)','14 pt (tests / lesson plans)'],['12 pt (Chuẩn bài báo/tài liệu)','12 pt (articles / documents)'],
['Markdown Input (Hỗ trợ đầy đủ $...$, $$...$$, \\(...\\), \\\\(...\\\\))','Markdown input (supports $...$, $$...$$, \\(...\\), \\\\(...\\\\))'],
['Dán hoặc nhập nội dung Markdown tại đây...','Paste or type Markdown here...'],
['Live Preview (Trực quan như MS Word)','Live preview (looks like MS Word)'],['Khổ giấy A4 (Lề 2.0cm)','A4 paper (2.0 cm margins)'],
['Bản xem trước tài liệu Word sẽ hiển thị tại đây','The Word document preview will appear here'],
['Bấm nút "Mẫu Véc-tơ" để nạp nội dung mẫu kiểm tra','Click "Vector sample" to load sample content'],
['Đã thực hiện thành công','Done'],
['Đã dán nội dung từ clipboard!','Pasted from the clipboard!'],['Vui lòng nhấn Ctrl + V để dán trực tiếp!','Please press Ctrl + V to paste directly!'],
['Đã nạp bảng tự luận & hướng dẫn chấm!','Essay table & marking guide loaded!'],['Đã nạp đề bài Véc-tơ (chứa các công thức thực tế)!','Vector exercises loaded (with real equations)!'],
['Đã nạp đề trắc nghiệm!','Multiple-choice test loaded!'],['Đã xóa trắng nội dung','Content cleared'],
['Không có nội dung để sao chép!','Nothing to copy!'],['Đã sao chép! Dán (Ctrl+V) vào Word ngay!','Copied! Paste (Ctrl+V) into Word now!'],
['Không thể tự động sao chép!','Could not copy automatically!'],['Vui lòng nhập nội dung trước khi xuất Word!','Please enter some content before exporting to Word!'],
['Đang tạo file .DOCX chuẩn Word Equation...','Creating a .DOCX file with Word equations...'],['Đã tải file .DOCX thành công!','.DOCX file downloaded!']
];
const MARKWORD_RX = [
['^([\\d.,]+) ký tự$', '', '$1 characters'],
['^([\\d.,]+) từ$', '', '$1 words'],
['^([\\d.,]+) công thức$', '', '$1 equations'],
['^Có lỗi xảy ra khi tạo file: ', '', 'Could not create the file: ']
];
function markwordI18nSnippet() {
const j = o => JSON.stringify(o).replace(/(function(){var boot=' + __i18nBoot.toString() + ';var E=null;' +
'function set(l){l=l==="en"?"en":"vi";document.documentElement.lang=l;if(E)E.setLang(l);else window.__mwLang=l;}' +
'function init(){E=boot([],' + j(MARKWORD_RX) + ',' + j(MARKWORD_EX) + ',document);E.setLang(window.__mwLang||' + j(lang) + ');}' +
'window.__mwLang=' + j(lang) + ';if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",init);else init();' +
'window.addEventListener("message",function(e){if(e.data&&e.data.i18n)set(e.data.i18n);});})();<\/script>';
}
function loadVideoanFrame() {
const fr = document.getElementById('videoan-frame');
const src = document.getElementById('videoan-src');
if (!fr || !src || fr.dataset.loaded) return;
fr.dataset.loaded = '1';
// Phần mềm này dùng biến màu riêng (:root) → chế độ Tối chỉ cần đổi bộ biến; video/đồ thị giữ nền gốc.
const fix = '';
let html = src.textContent.replace(/<\\\/script/g, '', () => inj + ''); }
fr.srcdoc = html;
}
function scrollNav(amount) {
const nav = document.getElementById('main-nav-container');
if (nav) nav.scrollBy({ left: amount, behavior: 'smooth' });
}
const TAB_BTN_BASE = "tab-btn nav-tile ";
const TAB_BTN_ON = TAB_BTN_BASE + "bg-brand-teal";
const TAB_BTN_OFF = TAB_BTN_BASE.trim();
/* Đồng bộ trạng thái nút menu với tab đang mở */
function syncNavActive(tabId) {
const nav = document.getElementById('main-nav-container');
document.querySelectorAll('.tab-btn').forEach(btn => {
const on = btn.getAttribute('data-tab') === tabId;
btn.className = on ? TAB_BTN_ON : TAB_BTN_OFF;
btn.setAttribute('aria-selected', on ? 'true' : 'false');
btn.tabIndex = on ? 0 : -1;
// Chỉ cuộn ngang trong thanh menu (màn hình hẹp) – không làm trang nhảy
if (on && nav && nav.scrollWidth > nav.clientWidth + 2) {
const nr = nav.getBoundingClientRect(), br = btn.getBoundingClientRect();
const left = nav.scrollLeft + (br.left - nr.left) - (nr.width - br.width) / 2;
nav.scrollTo({ left: Math.max(0, left), behavior: 'smooth' });
}
});
}
function switchTab(tabId) {
if (!document.getElementById(`module-${tabId}`)) return;
const changed = currentTab !== tabId;
currentTab = tabId;
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
const activeContent = document.getElementById(`module-${tabId}`);
activeContent.classList.remove('hidden');
syncNavActive(tabId);
// Đang cuộn sâu xuống trang → đưa đầu chức năng mới vào tầm nhìn
if (changed) {
const band = document.querySelector('.app-nav-band');
const hdr = document.querySelector('.app-header');
if (band) {
const top = band.getBoundingClientRect().top + window.scrollY - (hdr ? hdr.offsetHeight : 0);
if (window.scrollY > top + 4) window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
}
if (tabId === 'picker') {
setTimeout(() => {
resizeWheelCanvas();
drawWheel();
renderInteractiveStudents();
}, 50);
} else if (tabId === 'groups') {
gpRefresh();
} else if (tabId === 'noise') {
setTimeout(oscRender, 30);
} else if (tabId === 'scoreboard') {
renderLeaderboard();
renderScoreboard();
} else if (tabId === 'quicktools') {
setTimeout(initScratchpad, 50);
} else if (tabId === 'questions') {
renderQuestionsBankList();
} else if (tabId === 'seating') {
loadSeatingFrame();
setTimeout(fitSeatingFrame, 0);
} else if (tabId === 'pdf') {
loadPdfFrame();
setTimeout(fitPdfFrame, 0);
} else if (tabId === 'markword') {
loadMarkwordFrame();
setTimeout(fitMarkwordFrame, 0);
} else if (tabId === 'videoan') {
loadVideoanFrame();
setTimeout(fitVideoanFrame, 0);
}
}
function toggleShortcutsModal() {
document.getElementById('modal-shortcuts').classList.toggle('hidden');
}
function toggleFullscreen(elemId) {
const elem = document.getElementById(elemId);
if (!document.fullscreenElement) {
if (elem.requestFullscreen) elem.requestFullscreen();
} else {
if (document.exitFullscreen) document.exitFullscreen();
}
}
setInterval(() => {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
document.getElementById('realtime-clock').textContent = `${h}:${m}:${s}`;
}, 1000);
/* ===== ĐỒNG HỒ ĐẾM NGƯỢC / BẤM GIỜ (nhiều kiểu hiển thị, chỉnh tới từng giây) ===== */
let timerMode = 'countdown';
let timerRunning = false;
let timerInterval = null;
let totalSeconds = 300;
let initialTotalSeconds = 300;
let stopwatchMs = 0;
let laps = []; // mỗi phần tử: tổng ms tại thời điểm ghi mốc (mới nhất ở đầu)
let cdEndAt = 0; // mốc kết thúc (ms) khi đang đếm ngược
let cdRemainMs = null; // thời gian còn lại chính xác khi tạm dừng
let timerFinished = false;
const TK_MAX_SEC = 180 * 60 + 59;
const TK_RING_LEN = 603.19; // chu vi vòng tròn r = 96
const TK_STYLES = ['digital', 'ring', 'led', 'flip', 'hourglass'];
const TK_UI_KEY = 'classroom_toolkit_timer_ui';
const tkUI = { style: 'digital', beep: true };
try { Object.assign(tkUI, JSON.parse(localStorage.getItem(TK_UI_KEY) || '{}')); } catch (e) {}
function tkSaveUI() { try { localStorage.setItem(TK_UI_KEY, JSON.stringify(tkUI)); } catch (e) {} }
const tkEl = id => document.getElementById(id);
const tkPad = n => String(n).padStart(2, '0');
function tkFmt(sec) {
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
return h > 0 ? `${h}:${tkPad(m)}:${tkPad(s)}` : `${tkPad(m)}:${tkPad(s)}`;
}
function tkFmtMs(ms) {
const m = Math.floor(ms / 60000), s = Math.floor((ms % 60000) / 1000), c = Math.floor((ms % 1000) / 10);
return `${tkPad(m)}:${tkPad(s)}.${tkPad(c)}`;
}
function setClockStyle(style) {
if (!TK_STYLES.includes(style)) style = 'digital';
tkUI.style = style; tkSaveUI();
const stage = tkEl('timer-display-card');
if (stage) stage.dataset.style = style;
document.querySelectorAll('[data-clock-style]').forEach(b => b.setAttribute('aria-pressed', b.dataset.clockStyle === style ? 'true' : 'false'));
const flip = tkEl('tk-flip'); if (flip) flip.dataset.sig = '';
refreshTimerFace();
}
function toggleTimerBeep(on) { tkUI.beep = !!on; tkSaveUI(); }
function refreshTimerFace() { if (timerMode === 'countdown') updateTimerDisplay(); else updateStopwatchDisplay(); }
let tkFlipPrev = [], tkFlipAt = [];
function tkRenderFlip(text) {
const box = tkEl('tk-flip'); if (!box) return;
const groups = text.split(/[:.]/), seps = text.match(/[:.]/g) || [];
const labels = timerMode === 'stopwatch' ? ['Phút', 'Giây', '1/100 giây']
: (groups.length === 3 ? ['Giờ', 'Phút', 'Giây'] : ['Phút', 'Giây']);
const sig = timerMode + groups.length;
if (box.dataset.sig !== sig) {
box.dataset.sig = sig;
box.innerHTML = groups.map((g, i) => (i ? `${seps[i - 1]}` : '') +
`${g}${labels[i] || ''}`).join('');
tkFlipPrev = groups.slice(); tkFlipAt = [];
return;
}
const now = Date.now();
box.querySelectorAll('.tk-card').forEach((card, i) => {
if (tkFlipPrev[i] === groups[i]) return;
card.firstChild.textContent = groups[i];
if (!tkFlipAt[i] || now - tkFlipAt[i] > 450) { card.classList.remove('tk-flipping'); void card.offsetWidth; card.classList.add('tk-flipping'); }
tkFlipAt[i] = now;
});
tkFlipPrev = groups.slice();
}
// Vẽ mặt đồng hồ: text = chuỗi thời gian, frac = tỉ lệ 0..1 cho vòng/thanh, sand = tỉ lệ cát còn ở bầu trên
function tkRender(text, frac, sand) {
const stage = tkEl('timer-display-card');
const digits = tkEl('timer-digits');
if (digits && digits.textContent !== text) { digits.textContent = text; digits.dataset.ghost = text.replace(/\d/g, '8'); }
if (stage) { const lg = text.length > 5 ? '1' : '0'; if (stage.dataset.long !== lg) stage.dataset.long = lg; }
const f = Math.max(0, Math.min(1, frac || 0));
const bar = tkEl('timer-progress-bar'); if (bar) bar.style.width = (f * 100).toFixed(2) + '%';
const ring = tkEl('tk-ring-prog'); if (ring) ring.style.strokeDashoffset = (TK_RING_LEN * (1 - f)).toFixed(2);
const s = Math.max(0, Math.min(1, sand == null ? f : sand));
const top = tkEl('tk-hg-top'), bot = tkEl('tk-hg-bot');
if (top && bot) {
const ty = 30 + (1 - s) * 68; top.setAttribute('y', ty.toFixed(2)); top.setAttribute('height', Math.max(0, 98 - ty).toFixed(2));
const bh = (1 - s) * 66; bot.setAttribute('y', (186 - bh).toFixed(2)); bot.setAttribute('height', bh.toFixed(2));
}
if (stage && stage.dataset.style === 'flip') tkRenderFlip(text);
}
function tkSyncRunUI() {
const stage = tkEl('timer-display-card'), badge = tkEl('timer-status-badge');
const started = timerMode === 'countdown' ? (totalSeconds > 0 && (totalSeconds < initialTotalSeconds || cdRemainMs != null)) : stopwatchMs > 0;
let txt, label, kind;
if (timerRunning) { txt = 'TẠM DỪNG'; label = 'Đang chạy'; kind = 'run'; }
else if (timerMode === 'countdown' && timerFinished && totalSeconds <= 0) { txt = 'BẮT ĐẦU'; label = 'HẾT GIỜ!'; kind = 'done'; }
else if (started) { txt = 'TIẾP TỤC'; label = 'Tạm dừng'; kind = 'pause'; }
else { txt = 'BẮT ĐẦU'; label = 'Sẵn sàng'; kind = 'idle'; }
tkEl('btn-timer-text').textContent = txt;
tkEl('btn-timer-icon').innerHTML = timerRunning
? ''
: '';
if (badge && (badge.dataset.s !== kind || badge.textContent !== label)) { badge.textContent = label; badge.dataset.s = kind; }
if (stage) stage.dataset.run = timerRunning ? '1' : '0';
}
function tkSyncSetup() {
const mi = tkEl('custom-minutes'), si = tkEl('custom-seconds');
if (mi && document.activeElement !== mi) mi.value = Math.floor(initialTotalSeconds / 60);
if (si && document.activeElement !== si) si.value = initialTotalSeconds % 60;
document.querySelectorAll('[data-preset-sec]').forEach(b => b.setAttribute('aria-pressed', +b.dataset.presetSec === initialTotalSeconds ? 'true' : 'false'));
}
function switchTimerMode(mode) {
if (timerRunning) toggleTimer();
timerMode = mode;
const isCd = mode === 'countdown';
tkEl('subtab-countdown-btn').setAttribute('aria-pressed', isCd ? 'true' : 'false');
tkEl('subtab-stopwatch-btn').setAttribute('aria-pressed', isCd ? 'false' : 'true');
tkEl('countdown-presets').classList.toggle('hidden', !isCd);
tkEl('btn-stopwatch-lap').classList.toggle('hidden', isCd);
tkEl('stopwatch-laps-container').classList.toggle('hidden', isCd);
const stage = tkEl('timer-display-card'); stage.dataset.mode = mode;
tkEl('tk-sub-label').textContent = isCd ? 'Tổng thời gian' : 'Số mốc đã ghi';
tkEl('tk-flip').dataset.sig = '';
refreshTimerFace();
tkSyncRunUI();
}
function updateTimerDisplay() {
const stage = tkEl('timer-display-card');
const frac = initialTotalSeconds > 0 ? totalSeconds / initialTotalSeconds : 0;
tkRender(tkFmt(totalSeconds), frac, frac);
const warnAt = Math.min(60, Math.ceil(initialTotalSeconds * 0.25));
const dangerAt = Math.min(10, Math.ceil(initialTotalSeconds * 0.15));
let st = 'normal';
if (timerFinished && totalSeconds <= 0) st = 'done';
else if (totalSeconds < initialTotalSeconds && totalSeconds > 0) st = totalSeconds <= dangerAt ? 'danger' : (totalSeconds <= warnAt ? 'warn' : 'normal');
if (stage && stage.dataset.mode === 'countdown' && stage.dataset.state !== st) stage.dataset.state = st;
const sv = tkEl('tk-sub-val'); if (sv) sv.textContent = tkFmt(initialTotalSeconds);
}
function updateStopwatchDisplay() {
const stage = tkEl('timer-display-card');
const inMin = (stopwatchMs % 60000) / 60000;
tkRender(tkFmtMs(stopwatchMs), inMin, 1 - inMin);
if (stage && stage.dataset.state !== 'normal') stage.dataset.state = 'normal';
const sv = tkEl('tk-sub-val'); if (sv) sv.textContent = String(laps.length);
}
function toggleTimer() {
audioPlayer.init();
if (!timerRunning) {
if (timerMode === 'countdown') {
if (totalSeconds <= 0) { totalSeconds = initialTotalSeconds; cdRemainMs = null; }
if (totalSeconds <= 0) return;
timerFinished = false;
timerRunning = true;
cdEndAt = Date.now() + (cdRemainMs != null ? cdRemainMs : totalSeconds * 1000);
cdRemainMs = null;
let lastBeep = -1;
timerInterval = setInterval(() => {
const left = Math.max(0, Math.ceil((cdEndAt - Date.now()) / 1000));
if (left !== totalSeconds) {
totalSeconds = left;
if (tkUI.beep && left > 0 && left <= 5 && left !== lastBeep) { lastBeep = left; try { audioPlayer.playTick(); } catch (e) {} }
}
updateTimerDisplay();
if (left <= 0) {
clearInterval(timerInterval);
timerRunning = false;
timerFinished = true;
audioPlayer.playChime();
updateTimerDisplay();
tkSyncRunUI();
}
}, 100);
} else {
timerRunning = true;
let lastTime = Date.now();
timerInterval = setInterval(() => {
const now = Date.now();
stopwatchMs += (now - lastTime);
lastTime = now;
updateStopwatchDisplay();
}, 30);
}
} else {
timerRunning = false;
clearInterval(timerInterval);
if (timerMode === 'countdown') cdRemainMs = Math.max(0, cdEndAt - Date.now());
}
tkSyncRunUI();
}
function resetTimer() {
if (timerRunning) toggleTimer();
if (timerMode === 'countdown') {
totalSeconds = initialTotalSeconds;
timerFinished = false;
cdRemainMs = null;
updateTimerDisplay();
} else {
stopwatchMs = 0;
laps = [];
updateStopwatchDisplay();
renderLaps();
}
tkSyncRunUI();
}
function setTimerPreset(sec) {
if (timerRunning) toggleTimer();
sec = Math.max(1, Math.min(TK_MAX_SEC, Math.round(sec) || 1));
initialTotalSeconds = sec;
totalSeconds = sec;
timerFinished = false;
cdRemainMs = null;
if (timerMode !== 'countdown') switchTimerMode('countdown');
updateTimerDisplay();
tkSyncSetup();
tkSyncRunUI();
}
function applyCustomTimer() {
const m = parseInt(tkEl('custom-minutes').value, 10) || 0;
const s = parseInt(tkEl('custom-seconds').value, 10) || 0;
const sec = m * 60 + s;
if (sec <= 0) { showToast('Vui lòng nhập thời gian lớn hơn 0 giây!', 'warning'); return; }
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
setTimerPreset(sec);
}
// Cộng / trừ thời gian nhanh — dùng được cả khi đang chạy
function adjustTimer(delta) {
if (timerMode !== 'countdown') return;
const fresh = !timerRunning && (timerFinished || (totalSeconds === initialTotalSeconds && cdRemainMs == null));
if (fresh) { setTimerPreset(initialTotalSeconds + delta); return; }
const newLeft = Math.max(1, Math.min(TK_MAX_SEC, totalSeconds + delta));
const applied = newLeft - totalSeconds;
if (!applied) return;
if (timerRunning) cdEndAt += applied * 1000; else cdRemainMs = null;
totalSeconds = newLeft;
initialTotalSeconds = Math.max(totalSeconds, Math.min(TK_MAX_SEC, initialTotalSeconds + applied));
updateTimerDisplay();
tkSyncSetup();
tkSyncRunUI();
}
function recordLap() {
if (timerMode !== 'stopwatch' || stopwatchMs === 0) return;
laps.unshift(stopwatchMs);
renderLaps();
updateStopwatchDisplay();
}
function renderLaps() {
const list = tkEl('laps-list');
if (laps.length === 0) {
list.innerHTML = '
${L2('Chưa có tên nào — dán danh sách vào ô bên trái.', 'No names yet — paste a list into the box.')}
`;
const f = list.filter(p => p.gender === 'Nữ').length, cls = classesData[currentClassId], old = getStudentList().length;
const target = gpPasteMode === 'new' ? L2('lớp mới', 'a new class') : `“${gpEsc(cls ? cls.name : '')}”`;
const act = gpPasteMode === 'replace' ? L2(`thay ${old} HS hiện có của ${target}`, `replace the ${old} students of ${target}`)
: gpPasteMode === 'append' ? L2(`thêm vào cuối ${target} (đang có ${old} HS)`, `append to ${target} (${old} students now)`) : L2('tạo lớp mới', 'create a new class');
info.innerHTML = list.length ? L2(`${list.length} học sinh (${list.length - f} nam, ${f} nữ) → ${act}`, `${list.length} students (${list.length - f} boys, ${f} girls) → ${act}`) : '';
}
async function gpPasteFromClipboard() {
try {
const t = await navigator.clipboard.readText();
if (!t || !t.trim()) return showToast(L2('Bộ nhớ tạm đang trống', 'The clipboard is empty'), 'warning');
const ta = document.getElementById('gp-paste-text');
ta.value = (ta.value.trim() ? ta.value.replace(/\s*$/, '\n') : '') + t;
gpPastePreview();
} catch (e) {
showToast(L2('Trình duyệt không cho đọc bộ nhớ tạm — hãy bấm vào ô và nhấn Ctrl+V', 'The browser blocked clipboard access — click the box and press Ctrl+V'), 'info', 3000);
document.getElementById('gp-paste-text').focus();
}
}
function gpPasteClear() { const ta = document.getElementById('gp-paste-text'); ta.value = ''; gpPastePreview(); ta.focus(); }
async function gpApplyPaste() {
const list = gpParseRoster(document.getElementById('gp-paste-text').value);
if (!list.length) return showToast(L2('Chưa nhận diện được tên học sinh nào', 'No student names recognised'), 'warning');
const mode = gpPasteMode;
let id = currentClassId;
if (mode === 'new') {
const nm = document.getElementById('gp-paste-cls').value.trim();
if (!nm) { showToast(L2('Hãy nhập tên lớp mới', 'Please enter the new class name'), 'warning'); document.getElementById('gp-paste-cls').focus(); return; }
id = 'class_' + Date.now();
classesData[id] = { name: nm.slice(0, 40), students: [] };
}
const old = (classesData[id] && classesData[id].students) || [];
if (mode === 'replace' && old.length) {
const res = await Swal.fire({ title: L2('Thay danh sách lớp?', 'Replace the class list?'), icon: 'question',
html: L2(`Thay ${old.length} học sinh hiện có của “${gpEsc(classesData[id].name)}” bằng ${list.length} học sinh mới. Học sinh trùng tên được giữ nguyên điểm, ⭐ và lịch sử.`,
`Replace the ${old.length} students of “${gpEsc(classesData[id].name)}” with ${list.length} new students. Students with the same name keep their scores, ⭐ and history.`),
showCancelButton: true, confirmButtonText: L2('Thay danh sách', 'Replace'), cancelButtonText: L2('Hủy', 'Cancel'), confirmButtonColor: '#168A45' });
if (!res.isConfirmed) return;
}
const now = Date.now(), key = s => String(s).toLowerCase().normalize('NFC').replace(/\s+/g, ' ').trim();
const pool = {};
if (mode === 'replace') old.forEach(s => { (pool[key(s.name)] = pool[key(s.name)] || []).push(s); });
let kept = 0;
const made = list.map((p, i) => {
const m = pool[key(p.name)] && pool[key(p.name)].shift();
if (m) { kept++; return Object.assign({}, m, { name: p.name, gender: p.guessed ? m.gender : p.gender, special: p.special ? 1 : m.special }); }
return { id: 'stu_' + i + '_' + now + '_' + Math.random().toString(36).slice(2, 6), stt: 0, name: p.name, gender: p.gender, dob: '', mail: '', special: p.special, score: 0, attendance: 'present', streak: 1 };
});
const students = mode === 'append' ? old.concat(made) : made;
students.forEach((s, i) => { s.stt = i + 1; });
classesData[id].students = students;
saveClassesToStorage();
currentClassId = id;
updateClassSelector();
loadClassData(id);
/* Dọn dữ liệu chia nhóm cũ tham chiếu tới HS không còn trong lớp */
const c = gpC(), ids = new Set(students.map(s => s.id));
Object.keys(c.pins).forEach(k => { if (!ids.has(k)) delete c.pins[k]; });
c.off = c.off.filter(k => ids.has(k));
c.cons = c.cons.filter(cn => ids.has(cn.a) && ids.has(cn.b));
c.prev = null; c.result = null; gpResult = null; gpUndoStack = [];
gpSave();
document.getElementById('gp-paste-text').value = ''; document.getElementById('gp-paste-cls').value = '';
gpOpenPaste(false);
generateRandomGroups();
showToast(mode === 'new' ? L2(`Đã tạo lớp “${classesData[id].name}” với ${made.length} học sinh`, `Created “${classesData[id].name}” with ${made.length} students`)
: mode === 'append' ? L2(`Đã thêm ${made.length} học sinh vào lớp`, `Added ${made.length} students to the class`)
: L2(`Đã cập nhật danh sách: ${made.length} học sinh${kept ? ` (giữ dữ liệu ${kept} bạn trùng tên)` : ''}`, `List updated: ${made.length} students${kept ? ` (kept data for ${kept} matching names)` : ''}`), 'success', 2600);
}
/* ---------- Ràng buộc ---------- */
function gpAddConstraint() {
const a = document.getElementById('gp-con-a').value, b = document.getElementById('gp-con-b').value, type = document.getElementById('gp-con-type').value;
if (!a || !b || a === b) return showToast(L2('Hãy chọn 2 học sinh khác nhau', 'Please choose two different students'), 'warning');
const c = gpC();
c.cons = c.cons.filter(x => !((x.a === a && x.b === b) || (x.a === b && x.b === a)));
c.cons.push({ a, b, type });
gpSave(); gpRenderCons(); gpRenderSummary();
showToast(type === 'apart'
? L2('Đã thêm: không cùng nhóm — áp dụng ở lần chia tiếp theo', 'Added: never in the same group — applies from the next grouping')
: L2('Đã thêm: luôn cùng nhóm — áp dụng ở lần chia tiếp theo', 'Added: always together — applies from the next grouping'), 'success', 2200);
}
function gpDelConstraint(i) { gpC().cons.splice(i, 1); gpSave(); gpRenderCons(); gpRenderSummary(); }
function gpConViolated(cn) {
const ga = gpGroupOf(cn.a), gb = gpGroupOf(cn.b);
if (ga < 0 || gb < 0) return false;
return cn.type === 'apart' ? ga === gb : ga !== gb;
}
/* ---------- Render ---------- */
function gpRenderAll(reveal, flashId) {
gpSyncControls(); gpRenderStats(); gpRenderGroups(reveal, flashId); gpRenderSummary(); gpRenderCons(); gpRenderRoster();
const u = document.getElementById('gp-undo'); if (u) u.disabled = !gpUndoStack.length;
}
function gpSyncControls() {
const s = gpS();
const setSeg = (id, v) => document.querySelectorAll(`#${id} > button`).forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
setSeg('gp-mode', s.mode); setSeg('gp-strategy', s.strategy); setSeg('gp-filter', gpRosterFilter);
const t = document.getElementById('gp-target'); if (t && document.activeElement !== t) t.value = s.target;
document.getElementById('gp-theme').value = s.theme;
document.getElementById('gp-opt-roles').setAttribute('aria-pressed', String(!!s.roles));
document.getElementById('gp-opt-anim').setAttribute('aria-pressed', String(!!s.anim));
document.querySelectorAll('#gp-factors .gp-chip').forEach(b => b.setAttribute('aria-pressed', String(!!s.f[b.dataset.f])));
const n = gpPool().length, k = gpNumGroups(n);
const pv = document.getElementById('gp-preview');
if (pv) {
if (n < 2) pv.textContent = L2('Chưa đủ học sinh', 'Not enough students');
else {
const lo = Math.floor(n / k), hi = Math.ceil(n / k), sz = lo === hi ? lo : lo + '–' + hi;
pv.innerHTML = L2(`→ ${k} nhóm · ${sz} bạn/nhóm · ${n} HS tham gia`, `→ ${k} groups · ${sz} per group · ${n} students joining`);
}
}
}
function gpRenderStats() {
const all = getStudentList(), pool = gpPool();
const el = document.getElementById('gp-stats'); if (!el) return;
const st = (ic, v, l) => `