posimai-root/posimai-sc/js/app.js

983 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DRILLS } from './data/drills.js';
import { CATEGORIES } from './data/categories.js';
const API_BASE = 'https://api.soar-enrich.com/brain/api';
const APP_ID = 'posimai-sc';
const SYNC_KEY_LS = 'posimai_api_key';
function shuffleInPlace(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function extractTermsFromBeginnerBox(box) {
const clone = box.cloneNode(true);
clone.querySelector('.formula-label')?.remove();
const inner = clone.innerHTML.trim();
const parts = inner.split(/(?=<strong>)/i).map(s => s.trim()).filter(s => /^<strong>/i.test(s));
const terms = [];
for (const p of parts) {
const doc = new DOMParser().parseFromString('<div class="seg">'+p+'</div>', 'text/html');
const root = doc.querySelector('.seg');
const st = root && root.querySelector('strong');
if (!st) continue;
const term = st.textContent.trim();
let hint = '';
let n = st.nextSibling;
while (n) {
if (n.nodeType === 3) hint += n.textContent;
else if (n.nodeType === 1) hint += n.textContent;
n = n.nextSibling;
}
hint = hint.replace(/^[:\s]+/, '').trim();
if (term) terms.push({ term, hint });
}
return terms;
}
function parseConceptForGlossary(conceptHtml) {
if (!conceptHtml) return [];
const doc = new DOMParser().parseFromString('<div class="groot">'+conceptHtml+'</div>', 'text/html');
const root = doc.querySelector('.groot');
if (!root) return [];
const boxes = root.querySelectorAll('.formula-box');
for (const box of boxes) {
const lab = box.querySelector(':scope > .formula-label');
if (!lab || lab.textContent.indexOf('初学者向け') === -1) continue;
return extractTermsFromBeginnerBox(box);
}
return [];
}
function buildGlossaryRows(categories) {
const rows = [];
for (const cat of categories) {
for (const u of cat.units) {
const terms = parseConceptForGlossary(u.concept);
for (const t of terms) {
rows.push({
term: t.term,
hint: t.hint,
unitId: u.id,
num: u.num,
title: u.title,
catLabel: cat.label
});
}
}
}
rows.sort((a, b) => a.term.localeCompare(b.term, 'ja'));
return rows;
}
function sanitizeTrustedHtml(dirty) {
if (dirty == null || dirty === '') return '';
const s = String(dirty);
if (typeof window.DOMPurify === 'undefined') {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
return window.DOMPurify.sanitize(s, {
USE_PROFILES: { html: true, svg: true, svgFilters: true },
ALLOW_UNKNOWN_PROTOCOLS: false
});
}
const sanitizeStoredHtml = sanitizeTrustedHtml;
document.addEventListener('alpine:init', () => {
Alpine.data('scApp', () => ({
safeHtml(s) {
return sanitizeTrustedHtml(s);
},
categories:[],
currentUnit:null,
quizState:{},
search:'',
sidebarOpen:false,
isDark:true,
progress:{},
scores:{},
wrongUnits:{},
keys:['A','B','C','D','E'],
// concept fold — open by default so diagrams are immediately visible
conceptExpanded:true,
// step mode
stepMode:false,
stepStep:1,
stepKpIdx:0,
stepFlashRevealed:false,
stepDrillIdx:0,
stepDrillAnswered:false,
stepDrillSelected:-1,
// weak drill
weakDrillActive:false,
weakDrillUnits:[],
weakDrillUnitIdx:0,
weakDrillQuizState:{},
weakDrillResults:[],
weakDrillDone:false,
glossaryRows:[],
glossaryViewActive:false,
glossarySearch:'',
examActive:false,
examPhase:'setup',
examQuestions:[],
examIdx:0,
examQuizState:{},
syncToken:'',
syncEnabled:false,
syncBusy:false,
syncStatus:'未同期',
syncLastError:'',
syncStateAt:0,
syncRemoteAt:0,
syncTimer:null,
init(){
this.categories = CATEGORIES.map(cat=>({
...cat,
units: cat.units.map(u=>({...u, catLabel:cat.label}))
}));
this.glossaryRows = buildGlossaryRows(this.categories);
try{ this.progress = JSON.parse(localStorage.getItem('posimai-sc-progress')||'{}'); }
catch{ this.progress = {}; }
try{ this.scores = JSON.parse(localStorage.getItem('posimai-sc-scores')||'{}'); }
catch{ this.scores = {}; }
try{ this.wrongUnits = JSON.parse(localStorage.getItem('posimai-sc-wrong')||'{}'); }
catch{ this.wrongUnits = {}; }
this.syncStateAt = parseInt(localStorage.getItem('posimai-sc-state-at') || '0', 10) || 0;
const jwtToken = (localStorage.getItem('posimai_token') || '').trim();
const apiKey = (localStorage.getItem(SYNC_KEY_LS) || '').trim();
this.syncToken = jwtToken || apiKey;
this.syncEnabled = !!this.syncToken;
const t = localStorage.getItem('posimai-sc-theme')||'system';
this.isDark = t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);
const uid=(new URLSearchParams(location.search).get('unit')||'').trim().toLowerCase();
if(uid){
const u=this.allUnits().find(x=>x.id===uid);
if(u){ this.currentUnit=u; this.quizState={}; }
else{ try{ const url=new URL(window.location.href); url.searchParams.delete('unit'); const q=url.searchParams.toString(); history.replaceState(null,'',url.pathname+(q?'?'+q:'')+url.hash); }catch(e){} }
}
this.syncUnitToUrl();
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
this.$watch('search', ()=>{
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
});
if(this.syncEnabled){
this.pullCloudState().catch(()=>{});
} else {
this.syncStatus = '同期キー未設定';
}
if('serviceWorker' in navigator){
navigator.serviceWorker.register('/sw.js').catch(()=>{});
}
document.addEventListener('keydown',(e)=>{
const t=e.target;
if(t&&(t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.isContentEditable)) return;
if(e.key==='/'&&!e.ctrlKey&&!e.metaKey&&!e.altKey){
e.preventDefault();
const gs=document.querySelector('.glossary-search-input');
if(this.glossaryViewActive&&gs){ gs.focus(); return; }
const si=document.querySelector('.search-input');
if(si) si.focus();
}
});
},
syncUnitToUrl(){
try{
const url=new URL(window.location.href);
if(this.currentUnit&&this.currentUnit.id) url.searchParams.set('unit',this.currentUnit.id);
else url.searchParams.delete('unit');
const q=url.searchParams.toString();
const path=url.pathname+(q?'?'+q:'')+url.hash;
if(path!==window.location.pathname+window.location.search+window.location.hash)
history.replaceState(null,'',path);
}catch(e){}
},
nowTs(){
return Date.now();
},
get localStateBlob(){
return {
progress: this.progress || {},
scores: this.scores || {},
wrongUnits: this.wrongUnits || {},
currentUnitId: this.currentUnit?.id || '',
stateAt: this.syncStateAt || this.nowTs()
};
},
applyStateBlob(blob){
const nextProgress = (blob && typeof blob.progress === 'object') ? blob.progress : {};
const nextScores = (blob && typeof blob.scores === 'object') ? blob.scores : {};
const nextWrong = (blob && typeof blob.wrongUnits === 'object') ? blob.wrongUnits : {};
this.progress = { ...nextProgress };
this.scores = { ...nextScores };
this.wrongUnits = { ...nextWrong };
try{ localStorage.setItem('posimai-sc-progress', JSON.stringify(this.progress)); }catch(e){}
try{ localStorage.setItem('posimai-sc-scores', JSON.stringify(this.scores)); }catch(e){}
try{ localStorage.setItem('posimai-sc-wrong', JSON.stringify(this.wrongUnits)); }catch(e){}
if(blob && blob.currentUnitId){
const u = this.allUnits().find(x => x.id === blob.currentUnitId);
if(u) this.currentUnit = u;
}
this.syncStateAt = parseInt(String(blob?.stateAt || this.nowTs()), 10) || this.nowTs();
try{ localStorage.setItem('posimai-sc-state-at', String(this.syncStateAt)); }catch(e){}
this.syncUnitToUrl();
},
markStateChanged(){
this.syncStateAt = this.nowTs();
try{ localStorage.setItem('posimai-sc-state-at', String(this.syncStateAt)); }catch(e){}
if(this.syncEnabled) this.scheduleCloudPush();
},
get syncHeader(){
const token = (localStorage.getItem('posimai_token') || '').trim() || (this.syncToken || '').trim();
return token ? { Authorization: 'Bearer ' + token } : null;
},
async pullCloudState(){
const hdr = this.syncHeader;
if(!hdr){
this.syncEnabled = false;
this.syncStatus = '同期キー未設定';
return;
}
this.syncBusy = true;
this.syncStatus = '同期を確認中...';
this.syncLastError = '';
try{
const res = await fetch(`${API_BASE}/app-state/${APP_ID}`, { headers: hdr });
if(res.status===404){
this.syncEnabled = true;
this.syncStatus = 'クラウド初期化前(この端末の状態を保存できます)';
return;
}
if(!res.ok) throw new Error('HTTP '+res.status);
const data = await res.json();
const remoteState = data?.state || {};
const remoteAt = parseInt(String(remoteState?.stateAt || 0), 10) || 0;
this.syncRemoteAt = remoteAt;
if(remoteAt > (this.syncStateAt || 0)){
this.applyStateBlob(remoteState);
this.syncStatus = 'クラウドから最新状態を復元';
} else if(remoteAt > 0){
this.syncStatus = 'この端末の状態が最新';
} else {
this.syncStatus = 'クラウド初期化前(この端末の状態を保存できます)';
}
}catch(e){
this.syncLastError = String(e?.message || e || '同期失敗');
this.syncStatus = '同期エラー';
}finally{
this.syncBusy = false;
}
},
async pushCloudState(){
const hdr = this.syncHeader;
if(!hdr || !this.syncEnabled) return;
this.syncBusy = true;
this.syncStatus = 'クラウドへ保存中...';
this.syncLastError = '';
try{
const payload = this.localStateBlob;
const res = await fetch(`${API_BASE}/app-state/${APP_ID}`, {
method:'PUT',
headers:{ 'Content-Type':'application/json', ...hdr },
body: JSON.stringify({ state: payload })
});
if(!res.ok) throw new Error('HTTP '+res.status);
this.syncRemoteAt = payload.stateAt;
this.syncStatus = 'クラウド同期済み';
}catch(e){
this.syncLastError = String(e?.message || e || '同期失敗');
this.syncStatus = '同期エラー';
}finally{
this.syncBusy = false;
}
},
scheduleCloudPush(){
if(this.syncTimer) clearTimeout(this.syncTimer);
this.syncTimer = setTimeout(()=>{ this.pushCloudState().catch(()=>{}); }, 800);
},
enableSyncWithInput(){
const token = (this.syncToken || '').trim();
if(!token){
this.syncStatus = '同期キー未設定';
return;
}
localStorage.setItem(SYNC_KEY_LS, token);
this.syncEnabled = true;
this.pullCloudState().catch(()=>{});
},
async syncNow(){
if(!this.syncEnabled){
this.syncStatus = '同期キー未設定';
return;
}
await this.pushCloudState();
await this.pullCloudState();
},
goHome(){
this.currentUnit=null;
this.quizState={};
this.conceptExpanded=false;
this.stepMode=false;
this.weakDrillActive=false;
this.glossaryViewActive=false;
this.glossarySearch='';
this.examActive=false;
this.examPhase='setup';
this.examQuestions=[];
this.examIdx=0;
this.examQuizState={};
this.markStateChanged();
this.syncUnitToUrl();
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
get filteredCats(){
if(!this.search) return this.categories;
const q=this.search.toLowerCase();
return this.categories.map(cat=>({
...cat,
units:cat.units.filter(u=>u.title.toLowerCase().includes(q)||u.num.toLowerCase().includes(q))
})).filter(cat=>cat.units.length>0);
},
get filteredGlossary(){
const q=(this.glossarySearch||'').trim().toLowerCase();
if(!q) return this.glossaryRows;
return this.glossaryRows.filter(r=>{
return r.term.toLowerCase().includes(q)||r.hint.toLowerCase().includes(q)
||r.num.toLowerCase().includes(q)||r.title.toLowerCase().includes(q)
||r.catLabel.toLowerCase().includes(q);
});
},
get totalCount(){ return this.categories.reduce((s,c)=>s+c.units.length,0); },
get doneCount(){ return Object.keys(this.progress).length; },
get progressPct(){ return this.totalCount ? Math.round(this.doneCount/this.totalCount*100) : 0; },
get quizAnswered(){
let n=0;
Object.values(this.quizState).forEach(s=>{ if(s) n++; });
return n;
},
get quizCorrect(){
let n=0;
Object.values(this.quizState).forEach(s=>{ if(s?.correct) n++; });
return n;
},
// Persistent totals shown on home screen (from saved scores)
get totalBestCorrect(){
return Object.values(this.scores).reduce((s,v)=>s+(v?.best||0),0);
},
get totalBestPossible(){
return Object.values(this.scores).reduce((s,v)=>s+(v?.total||0),0);
},
catPct(cat){
if(!cat.units.length) return 0;
return Math.round(cat.units.filter(u=>this.progress[u.id]).length/cat.units.length*100);
},
allUnits(){ return this.categories.flatMap(c=>c.units); },
isDone(id){ return !!this.progress[id]; },
markDone(){
if(!this.currentUnit) return;
this.progress[this.currentUnit.id]=true;
localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress));
this.markStateChanged();
},
bestScore(id){
const s=this.scores[id];
if(!s) return '';
return s.best+'/'+s.total;
},
scoreChipCls(id){
const s=this.scores[id];
if(!s) return 'zero';
if(s.best===s.total) return '';
if(s.best>=s.total*0.6) return 'partial';
return 'zero';
},
saveScore(id,correct,total){
const prev=this.scores[id];
if(!prev||correct>prev.best){
this.scores[id]={best:correct,total};
localStorage.setItem('posimai-sc-scores',JSON.stringify(this.scores));
this.markStateChanged();
}
},
get freqBadgeCls(){
const f=this.currentUnit?.freq;
if(f==='high') return 'badge badge-high';
if(f==='mid') return 'badge badge-mid';
return 'badge badge-base';
},
get freqLabel(){
const f=this.currentUnit?.freq;
if(f==='high') return '頻出';
if(f==='mid') return '重要';
return '基礎';
},
get diffLabel(){
const d=this.currentUnit?.diff||1;
return '難易度 '+'★'.repeat(d)+'☆'.repeat(3-d);
},
openUnit(unit){
this.glossaryViewActive=false;
this.examActive=false;
this.currentUnit=unit;
this.quizState={};
this.conceptExpanded=true;
this.stepMode=false;
this.markStateChanged();
this.syncUnitToUrl();
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:0,behavior:'smooth'});
});
},
// Weak unit tracking
hasWrong(id){ return !!this.wrongUnits[id]; },
_onAllAnswered(){
if(!this.currentUnit) return;
const uid=this.currentUnit.id;
const total=this.currentUnit.quiz?.length||0;
const correct=Object.values(this.quizState).filter(s=>s?.correct).length;
this.saveScore(uid,correct,total);
if(correct>=total){
delete this.wrongUnits[uid];
// auto-mark done on perfect score
if(!this.progress[uid]){
this.progress[uid]=true;
try{ localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress)); }catch(e){}
this.markStateChanged();
}
} else {
this.wrongUnits[uid]=(this.wrongUnits[uid]||0)+1;
}
try{ localStorage.setItem('posimai-sc-wrong',JSON.stringify(this.wrongUnits)); }catch(e){}
this.wrongUnits={...this.wrongUnits};
this.markStateChanged();
},
get weakUnits(){
return this.allUnits().filter(u=>this.wrongUnits[u.id]>0)
.sort((a,b)=>(this.wrongUnits[b.id]||0)-(this.wrongUnits[a.id]||0));
},
get todayUnits(){
const result=[];
const weak=this.weakUnits;
if(weak.length>0) result.push({...weak[0], todayTag:'苦手'});
const all=this.allUnits();
const weakId=result[0]?.id;
const review=all.find(u=>!this.wrongUnits[u.id] && this.scores[u.id] && u.id!==weakId && !this.progress[u.id]);
if(review) result.push({...review, todayTag:'復習'});
if(result.length<2){
const next=all.find(u=>!this.progress[u.id] && u.id!==weakId && !result.find(r=>r.id===u.id));
if(next) result.push({...next, todayTag:'未学習'});
}
return result;
},
// Quiz
answered(qi){ return !!this.quizState[qi]; },
doAnswer(qi,ci,correct,exp){
if(this.quizState[qi]) return;
this.quizState[qi]={selected:ci,correct:ci===correct,exp:sanitizeStoredHtml(exp)};
this.quizState={...this.quizState};
const total=this.currentUnit?.quiz?.length||0;
if(total>0 && Object.keys(this.quizState).length>=total){
this._onAllAnswered();
}
},
qCardCls(qi){
const s=this.quizState[qi];
if(!s) return '';
return s.correct?'correct':'wrong';
},
choiceCls(qi,ci,correct){
const s=this.quizState[qi];
if(!s) return '';
if(ci===s.selected && s.correct) return 'sel-ok';
if(ci===s.selected && !s.correct) return 'sel-ng';
if(ci===correct && !s.correct) return 'rev-ok';
return '';
},
get scoreLabel(){
const total=this.currentUnit?.quiz?.length||0;
const answered=Object.keys(this.quizState).length;
const correct=Object.values(this.quizState).filter(s=>s?.correct).length;
if(!answered) return total+'問';
return correct+'/'+answered+'問正解';
},
get allAnswered(){
const total=this.currentUnit?.quiz?.length||0;
return total>0 && Object.keys(this.quizState).length>=total;
},
get resultScore(){
const correct=Object.values(this.quizState).filter(s=>s?.correct).length;
const total=this.currentUnit?.quiz?.length||0;
return correct+' / '+total;
},
get resultMsg(){
const correct=Object.values(this.quizState).filter(s=>s?.correct).length;
const total=this.currentUnit?.quiz?.length||0;
if(correct===total) return '完璧です!次の単元へ進みましょう。';
if(correct>=total*0.8) return 'よくできました。あと少しで完璧です。';
if(correct>=total*0.6) return '合格ラインです。間違えた問題の解説を確認しましょう。';
return '解説をよく読んで、もう一度チャレンジしてみましょう。';
},
resetQuiz(){ this.quizState={}; },
// Navigation
currentIdx(){ return this.allUnits().findIndex(u=>u.id===this.currentUnit?.id); },
get hasPrev(){ return this.currentIdx()>0; },
get hasNext(){ return this.currentIdx()<this.allUnits().length-1; },
prevUnit(){
const i=this.currentIdx();
if(i>0) this.openUnit(this.allUnits()[i-1]);
},
nextUnit(){
const i=this.currentIdx();
const all=this.allUnits();
if(i<all.length-1) this.openUnit(all[i+1]);
},
toggleTheme(){
this.isDark=!this.isDark;
const val=this.isDark?'dark':'light';
document.documentElement.setAttribute('data-theme',val);
try{ localStorage.setItem('posimai-sc-theme',val); }catch(e){}
},
// ---- Concept expand ----
get conceptPreview(){
const c=this.currentUnit?.concept||'';
const i=c.indexOf('</p>');
return i===-1 ? c : c.substring(0,i+4);
},
get conceptRest(){
const c=this.currentUnit?.concept||'';
const i=c.indexOf('</p>');
return i===-1 ? '' : c.substring(i+4);
},
// ---- Weak drill ----
get weakDrillCandidates(){
return this.allUnits()
.filter(u=>this.hasWrong(u.id))
.sort((a,b)=>{
const sa=this.scores[a.id]?.best||0;
const sb=this.scores[b.id]?.best||0;
return sa-sb;
})
.slice(0,5);
},
openGlossary(){
this.examActive=false;
this.glossaryViewActive=true;
this.glossarySearch='';
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const g=document.querySelector('.glossary-search-input');
if(g) g.focus();
});
},
closeGlossary(){
this.glossaryViewActive=false;
this.glossarySearch='';
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
openUnitFromGlossary(uid){
const u=this.allUnits().find(x=>x.id===uid);
if(u){
this.glossaryViewActive=false;
this.openUnit(u);
}
},
openExam(){
this.glossaryViewActive=false;
this.examActive=true;
this.examPhase='setup';
this.examQuestions=[];
this.examIdx=0;
this.examQuizState={};
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
exitExam(){
this.examActive=false;
this.examPhase='setup';
this.examQuestions=[];
this.examIdx=0;
this.examQuizState={};
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
fillExamQuestions(){
const pool=[];
const pushFreq=f=>{
for(const u of this.allUnits()){
if(u.freq!==f) continue;
(u.quiz||[]).forEach((q,qi)=>{ pool.push({ unitId:u.id, qi, unit:u, q }); });
}
};
pushFreq('high');
if(pool.length<10) pushFreq('mid');
if(pool.length<10){
for(const u of this.allUnits()){
if(u.freq==='high'||u.freq==='mid') continue;
(u.quiz||[]).forEach((q,qi)=>{ pool.push({ unitId:u.id, qi, unit:u, q }); });
}
}
shuffleInPlace(pool);
this.examQuestions=pool.slice(0, Math.min(10, pool.length));
},
startExamRun(){
this.fillExamQuestions();
if(!this.examQuestions.length) return;
this.examPhase='run';
this.examIdx=0;
this.examQuizState={};
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({ top:0, behavior:'auto' });
});
},
get examCurrentEntry(){ return this.examQuestions[this.examIdx]||null; },
get examTotal(){ return this.examQuestions.length; },
exAnswered(){
return !!this.examQuizState[this.examIdx];
},
exQCardCls(){
const s=this.examQuizState[this.examIdx];
if(!s) return '';
return s.correct?'correct':'wrong';
},
exChoiceCls(ci,correct){
const s=this.examQuizState[this.examIdx];
if(!s) return '';
if(ci===s.selected&&s.correct) return 'sel-ok';
if(ci===s.selected&&!s.correct) return 'sel-ng';
if(ci===correct&&!s.correct) return 'rev-ok';
return '';
},
doExamAnswer(ci,correct,exp){
if(this.examQuizState[this.examIdx]) return;
this.examQuizState[this.examIdx]={
selected:ci,
correct:ci===correct,
exp:sanitizeStoredHtml(exp)
};
this.examQuizState={...this.examQuizState};
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
nextExamQ(){
if(this.examIdx<this.examQuestions.length-1){
this.examIdx++;
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({ top:0, behavior:'smooth' });
});
} else {
this.examPhase='result';
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({ top:0, behavior:'smooth' });
});
}
},
get examCorrectCount(){
let n=0;
for(let i=0;i<this.examQuestions.length;i++){
if(this.examQuizState[i]?.correct) n++;
}
return n;
},
get examWrongItems(){
const out=[];
for(let i=0;i<this.examQuestions.length;i++){
const s=this.examQuizState[i];
if(s&&!s.correct){
const e=this.examQuestions[i];
out.push({ idx:i, unitId:e.unitId, num:e.unit.num, title:e.unit.title, q:e.q.q });
}
}
return out;
},
openUnitFromExam(uid){
const u=this.allUnits().find(x=>x.id===uid);
if(u){
this.exitExam();
this.openUnit(u);
}
},
startWeakDrill(){
this.glossaryViewActive=false;
this.examActive=false;
this.weakDrillUnits=[...this.weakDrillCandidates];
this.weakDrillUnitIdx=0;
this.weakDrillQuizState={};
this.weakDrillResults=[];
this.weakDrillDone=false;
this.weakDrillActive=true;
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
exitWeakDrill(){
this.weakDrillActive=false;
this.weakDrillDone=false;
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
get weakDrillCurrentUnit(){
return this.weakDrillUnits[this.weakDrillUnitIdx]||null;
},
wdAnswered(qi){ return !!this.weakDrillQuizState[qi]; },
wdQCardCls(qi){
const s=this.weakDrillQuizState[qi];
if(!s) return '';
return s.correct?'correct':'wrong';
},
wdChoiceCls(qi,ci,correct){
const s=this.weakDrillQuizState[qi];
if(!s) return '';
if(ci===s.selected&&s.correct) return 'sel-ok';
if(ci===s.selected&&!s.correct) return 'sel-ng';
if(ci===correct&&!s.correct) return 'rev-ok';
return '';
},
doWeakDrillAnswer(qi,ci,correct,exp){
if(this.weakDrillQuizState[qi]) return;
this.weakDrillQuizState[qi]={selected:ci,correct:ci===correct,exp:sanitizeStoredHtml(exp)};
this.weakDrillQuizState={...this.weakDrillQuizState};
},
get weakDrillAllAnswered(){
const total=this.weakDrillCurrentUnit?.quiz?.length||0;
return total>0 && Object.keys(this.weakDrillQuizState).length>=total;
},
get weakDrillUnitScore(){
const correct=Object.values(this.weakDrillQuizState).filter(s=>s?.correct).length;
const total=this.weakDrillCurrentUnit?.quiz?.length||0;
return correct+' / '+total;
},
nextWeakDrillUnit(){
const unit=this.weakDrillCurrentUnit;
if(!unit) return;
const correct=Object.values(this.weakDrillQuizState).filter(s=>s?.correct).length;
const total=unit.quiz?.length||0;
this.saveScore(unit.id,correct,total);
this.weakDrillResults.push({unitId:unit.id,num:unit.num,unitTitle:unit.title,correct,total});
if(correct>=total){
delete this.wrongUnits[unit.id];
localStorage.setItem('posimai-sc-wrong',JSON.stringify(this.wrongUnits));
this.wrongUnits={...this.wrongUnits};
this.markStateChanged();
}
if(this.weakDrillUnitIdx<this.weakDrillUnits.length-1){
this.weakDrillUnitIdx++;
this.weakDrillQuizState={};
} else {
this.weakDrillDone=true;
}
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
get weakDrillClearCount(){
return this.weakDrillResults.filter(r=>r.correct>=r.total).length;
},
// ---- Step mode ----
startStepMode(){
this.stepMode=true;
this.stepStep=1;
this.stepKpIdx=0;
this.resetFlashRevealState();
this.stepDrillIdx=0;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:0,behavior:'smooth'});
});
},
startStepModeClearQuiz(){
this.quizState={};
this.startStepMode();
},
exitStepMode(){
this.stepMode=false;
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
finishStepMode(){
if(document.activeElement && typeof document.activeElement.blur === 'function'){
document.activeElement.blur();
}
this.stepMode=false;
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
requestAnimationFrame(()=>{
requestAnimationFrame(()=>{ this.scrollMainToComprehension(); });
});
});
},
scrollToComprehension(){
if(document.activeElement && typeof document.activeElement.blur === 'function'){
document.activeElement.blur();
}
this.$nextTick(()=>{
requestAnimationFrame(()=>{
requestAnimationFrame(()=>{ this.scrollMainToComprehension(); });
});
if(window.lucide) lucide.createIcons();
});
},
scrollMainToComprehension(){
const m=document.getElementById('main');
const el=document.getElementById('comprehension-quiz');
if(!m||!el) return;
try{ el.focus({ preventScroll:true }); }catch(e){}
// main内の実座標で算出して、カード先頭へ安定して合わせる
const offset=8;
const top=(el.getBoundingClientRect().top - m.getBoundingClientRect().top) + m.scrollTop - offset;
m.scrollTo({ top:Math.max(0,top), behavior:'smooth' });
},
stepGoBack(){
if(!this.stepMode) return;
if(this.stepStep===1){
if(this.stepKpIdx>0){
this.stepKpIdx--;
this.resetFlashRevealState();
} else {
this.exitStepMode();
}
} else if(this.stepStep===2){
if(this.stepDrillIdx>0){
this.stepDrillIdx--;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
} else {
const kps=this.currentUnit?.keypoints||[];
this.stepStep=1;
this.stepKpIdx=Math.max(0,kps.length-1);
this.resetFlashRevealState();
}
} else if(this.stepStep===3){
const drills=this.unitDrills;
if(drills.length>0){
this.stepStep=2;
this.stepDrillIdx=drills.length-1;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
} else {
const kps=this.currentUnit?.keypoints||[];
this.stepStep=1;
this.stepKpIdx=Math.max(0,kps.length-1);
this.resetFlashRevealState();
}
}
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:0,behavior:'smooth'});
});
},
stepPipCls(n){
if(this.stepStep===n) return 's-active';
if(this.stepStep>n) return 's-done';
return '';
},
_kpDelimiterIndex(kp){
if(!kp||typeof kp!=='string') return -1;
const ja=kp.indexOf('');
if(ja>=0) return ja;
let depth=0;
for(let i=0;i<kp.length;i++){
const ch=kp[i];
if(ch==='<') depth++;
else if(ch==='>') depth=Math.max(0,depth-1);
else if(ch===':'&&depth===0) return i;
}
return -1;
},
keypointHasFlip(idx){
const kp=(this.currentUnit?.keypoints||[])[idx];
const i=this._kpDelimiterIndex(kp);
return i>0;
},
keypointTerm(idx){
const kp=(this.currentUnit?.keypoints||[])[idx];
const i=this._kpDelimiterIndex(kp);
return i>0 ? kp.slice(0,i).trim() : '';
},
resetFlashRevealState(){
this.stepFlashRevealed=!this.keypointHasFlip(this.stepKpIdx);
},
advanceStep1Card(){
if(this.stepStep!==1) return;
if(this.keypointHasFlip(this.stepKpIdx)&&!this.stepFlashRevealed){
this.stepFlashRevealed=true;
return;
}
this.nextStepFlash();
},
nextStepFlash(){
const kps=this.currentUnit?.keypoints||[];
if(this.stepKpIdx<kps.length-1){
this.stepKpIdx++;
this.resetFlashRevealState();
} else {
const drills=DRILLS[this.currentUnit?.id]||[];
this.stepStep=drills.length>0 ? 2 : 3;
this.stepDrillIdx=0;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
if(this.stepStep===3){
this.$nextTick(()=>{
const m=document.getElementById('main');
if(m) m.scrollTo({top:m.scrollHeight,behavior:'smooth'});
});
}
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
}
},
get unitDrills(){
return DRILLS[this.currentUnit?.id]||[];
},
doStepDrill(ci){
if(this.stepDrillAnswered) return;
this.stepDrillSelected=ci;
this.stepDrillAnswered=true;
},
nextStepDrill(){
const drills=this.unitDrills;
if(this.stepDrillIdx<drills.length-1){
this.stepDrillIdx++;
this.stepDrillAnswered=false;
this.stepDrillSelected=-1;
} else {
this.stepStep=3;
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
const m=document.getElementById('main');
if(m) m.scrollTo({top:m.scrollHeight,behavior:'smooth'});
});
}
}
}));
});