2026-04-19 16:40:48 +00:00
|
|
|
|
import { DRILLS } from './data/drills.js';
|
|
|
|
|
|
import { CATEGORIES } from './data/categories.js';
|
|
|
|
|
|
|
2026-04-20 07:46:18 +00:00
|
|
|
|
const API_BASE = 'https://api.soar-enrich.com/brain/api';
|
|
|
|
|
|
const APP_ID = 'posimai-sc';
|
|
|
|
|
|
const SYNC_KEY_LS = 'posimai_api_key';
|
|
|
|
|
|
|
2026-04-20 04:49:03 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 16:40:48 +00:00
|
|
|
|
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
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 02:02:24 +00:00
|
|
|
|
const sanitizeStoredHtml = sanitizeTrustedHtml;
|
2026-04-19 16:40:48 +00:00
|
|
|
|
|
|
|
|
|
|
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'],
|
2026-04-20 02:02:24 +00:00
|
|
|
|
// concept fold — open by default so diagrams are immediately visible
|
|
|
|
|
|
conceptExpanded:true,
|
2026-04-19 16:40:48 +00:00
|
|
|
|
// 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,
|
|
|
|
|
|
|
2026-04-20 04:49:03 +00:00
|
|
|
|
glossaryRows:[],
|
|
|
|
|
|
glossaryViewActive:false,
|
|
|
|
|
|
glossarySearch:'',
|
|
|
|
|
|
|
|
|
|
|
|
examActive:false,
|
|
|
|
|
|
examPhase:'setup',
|
|
|
|
|
|
examQuestions:[],
|
|
|
|
|
|
examIdx:0,
|
|
|
|
|
|
examQuizState:{},
|
2026-04-20 07:46:18 +00:00
|
|
|
|
syncToken:'',
|
|
|
|
|
|
syncEnabled:false,
|
|
|
|
|
|
syncBusy:false,
|
|
|
|
|
|
syncStatus:'未同期',
|
|
|
|
|
|
syncLastError:'',
|
|
|
|
|
|
syncStateAt:0,
|
|
|
|
|
|
syncRemoteAt:0,
|
|
|
|
|
|
syncTimer:null,
|
2026-04-20 04:49:03 +00:00
|
|
|
|
|
2026-04-19 16:40:48 +00:00
|
|
|
|
init(){
|
|
|
|
|
|
this.categories = CATEGORIES.map(cat=>({
|
|
|
|
|
|
...cat,
|
|
|
|
|
|
units: cat.units.map(u=>({...u, catLabel:cat.label}))
|
|
|
|
|
|
}));
|
2026-04-20 04:49:03 +00:00
|
|
|
|
this.glossaryRows = buildGlossaryRows(this.categories);
|
2026-04-19 16:40:48 +00:00
|
|
|
|
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 = {}; }
|
2026-04-20 07:46:18 +00:00
|
|
|
|
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;
|
2026-04-19 16:40:48 +00:00
|
|
|
|
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(); });
|
|
|
|
|
|
});
|
2026-04-20 07:46:18 +00:00
|
|
|
|
if(this.syncEnabled){
|
|
|
|
|
|
this.pullCloudState().catch(()=>{});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
this.syncStatus = '同期キー未設定';
|
|
|
|
|
|
}
|
2026-04-19 16:40:48 +00:00
|
|
|
|
if('serviceWorker' in navigator){
|
|
|
|
|
|
navigator.serviceWorker.register('/sw.js').catch(()=>{});
|
|
|
|
|
|
}
|
2026-04-20 04:39:31 +00:00
|
|
|
|
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();
|
2026-04-20 04:49:03 +00:00
|
|
|
|
const gs=document.querySelector('.glossary-search-input');
|
|
|
|
|
|
if(this.glossaryViewActive&&gs){ gs.focus(); return; }
|
2026-04-20 04:39:31 +00:00
|
|
|
|
const si=document.querySelector('.search-input');
|
|
|
|
|
|
if(si) si.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-04-19 16:40:48 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
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){}
|
|
|
|
|
|
},
|
2026-04-20 07:46:18 +00:00
|
|
|
|
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();
|
|
|
|
|
|
},
|
2026-04-19 16:40:48 +00:00
|
|
|
|
|
|
|
|
|
|
goHome(){
|
|
|
|
|
|
this.currentUnit=null;
|
|
|
|
|
|
this.quizState={};
|
|
|
|
|
|
this.conceptExpanded=false;
|
|
|
|
|
|
this.stepMode=false;
|
|
|
|
|
|
this.weakDrillActive=false;
|
2026-04-20 04:49:03 +00:00
|
|
|
|
this.glossaryViewActive=false;
|
|
|
|
|
|
this.glossarySearch='';
|
|
|
|
|
|
this.examActive=false;
|
|
|
|
|
|
this.examPhase='setup';
|
|
|
|
|
|
this.examQuestions=[];
|
|
|
|
|
|
this.examIdx=0;
|
|
|
|
|
|
this.examQuizState={};
|
2026-04-20 07:46:18 +00:00
|
|
|
|
this.markStateChanged();
|
2026-04-19 16:40:48 +00:00
|
|
|
|
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);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-20 04:49:03 +00:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-19 16:40:48 +00:00
|
|
|
|
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;
|
|
|
|
|
|
},
|
2026-04-20 02:02:24 +00:00
|
|
|
|
// 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);
|
|
|
|
|
|
},
|
2026-04-19 16:40:48 +00:00
|
|
|
|
|
|
|
|
|
|
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));
|
2026-04-20 07:46:18 +00:00
|
|
|
|
this.markStateChanged();
|
2026-04-19 16:40:48 +00:00
|
|
|
|
},
|
|
|
|
|
|
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));
|
2026-04-20 07:46:18 +00:00
|
|
|
|
this.markStateChanged();
|
2026-04-19 16:40:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
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){
|
2026-04-20 04:49:03 +00:00
|
|
|
|
this.glossaryViewActive=false;
|
|
|
|
|
|
this.examActive=false;
|
2026-04-19 16:40:48 +00:00
|
|
|
|
this.currentUnit=unit;
|
|
|
|
|
|
this.quizState={};
|
2026-04-20 02:02:24 +00:00
|
|
|
|
this.conceptExpanded=true;
|
2026-04-19 16:40:48 +00:00
|
|
|
|
this.stepMode=false;
|
2026-04-20 07:46:18 +00:00
|
|
|
|
this.markStateChanged();
|
2026-04-19 16:40:48 +00:00
|
|
|
|
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];
|
2026-04-20 02:02:24 +00:00
|
|
|
|
// 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){}
|
2026-04-20 07:46:18 +00:00
|
|
|
|
this.markStateChanged();
|
2026-04-20 02:02:24 +00:00
|
|
|
|
}
|
2026-04-19 16:40:48 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
this.wrongUnits[uid]=(this.wrongUnits[uid]||0)+1;
|
|
|
|
|
|
}
|
2026-04-20 02:02:24 +00:00
|
|
|
|
try{ localStorage.setItem('posimai-sc-wrong',JSON.stringify(this.wrongUnits)); }catch(e){}
|
2026-04-19 16:40:48 +00:00
|
|
|
|
this.wrongUnits={...this.wrongUnits};
|
2026-04-20 07:46:18 +00:00
|
|
|
|
this.markStateChanged();
|
2026-04-19 16:40:48 +00:00
|
|
|
|
},
|
|
|
|
|
|
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);
|
2026-04-20 02:02:24 +00:00
|
|
|
|
try{ localStorage.setItem('posimai-sc-theme',val); }catch(e){}
|
2026-04-19 16:40:48 +00:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 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);
|
|
|
|
|
|
},
|
2026-04-20 04:49:03 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-04-19 16:40:48 +00:00
|
|
|
|
startWeakDrill(){
|
2026-04-20 04:49:03 +00:00
|
|
|
|
this.glossaryViewActive=false;
|
|
|
|
|
|
this.examActive=false;
|
2026-04-19 16:40:48 +00:00
|
|
|
|
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};
|
2026-04-20 07:46:18 +00:00
|
|
|
|
this.markStateChanged();
|
2026-04-19 16:40:48 +00:00
|
|
|
|
}
|
|
|
|
|
|
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(){
|
|
|
|
|
|
this.stepMode=false;
|
|
|
|
|
|
this.$nextTick(()=>{
|
|
|
|
|
|
if(window.lucide) lucide.createIcons();
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
scrollToComprehension(){
|
2026-04-20 11:02:27 +00:00
|
|
|
|
if(document.activeElement && typeof document.activeElement.blur === 'function'){
|
|
|
|
|
|
document.activeElement.blur();
|
|
|
|
|
|
}
|
2026-04-19 16:40:48 +00:00
|
|
|
|
this.$nextTick(()=>{
|
2026-04-20 09:43:38 +00:00
|
|
|
|
requestAnimationFrame(()=>{
|
2026-04-20 11:07:06 +00:00
|
|
|
|
requestAnimationFrame(()=>{
|
|
|
|
|
|
this.scrollMainToComprehension('auto');
|
|
|
|
|
|
setTimeout(()=>{ this.scrollMainToComprehension('auto'); }, 140);
|
|
|
|
|
|
});
|
2026-04-20 09:43:38 +00:00
|
|
|
|
});
|
2026-04-19 16:40:48 +00:00
|
|
|
|
if(window.lucide) lucide.createIcons();
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-04-20 11:07:06 +00:00
|
|
|
|
scrollMainToComprehension(behavior='auto'){
|
2026-04-20 09:43:38 +00:00
|
|
|
|
const m=document.getElementById('main');
|
|
|
|
|
|
const el=document.getElementById('comprehension-quiz');
|
|
|
|
|
|
if(!m||!el) return;
|
2026-04-20 11:02:27 +00:00
|
|
|
|
try{ el.focus({ preventScroll:true }); }catch(e){}
|
2026-04-20 11:07:06 +00:00
|
|
|
|
const firstQ=el.querySelector('.q-card');
|
|
|
|
|
|
const target=firstQ||el;
|
|
|
|
|
|
// main内の実座標で算出して、カード先頭(Q1)へ安定して合わせる
|
2026-04-20 09:43:38 +00:00
|
|
|
|
const offset=8;
|
2026-04-20 11:07:06 +00:00
|
|
|
|
const top=(target.getBoundingClientRect().top - m.getBoundingClientRect().top) + m.scrollTop - offset;
|
|
|
|
|
|
m.scrollTo({ top:Math.max(0,top), behavior });
|
2026-04-20 09:43:38 +00:00
|
|
|
|
},
|
2026-04-19 16:40:48 +00:00
|
|
|
|
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'});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}));
|
|
|
|
|
|
});
|