/** * 生时校正 UI 渲染层 v2.0 * 引擎逻辑在 rectification-engine.js */ import { SIGNS, PLANET_CN, SIGN_LORDS } from './jyotish-engine.js'; import { EVENT_CATEGORIES, EVENT_COLLECTION_GUIDE, VARGA_SENSITIVITY, runRectification, buildRectificationInterviewQuestions, buildRecommendedRectificationQuestions, rectificationInterviewAnswersToEvents, getHouseLord, fmtTime, dateToJD } from './rectification-engine.js'; import { t, getLang, signName, planetName } from './i18n.js'; import { escapeHtml, escapeAttr } from './security.js'; function fmtOffset(m) { return m === 0 ? t('rect.baseline') : `${m > 0 ? '+' : ''}${m}min`; } let rectEvents = []; let rectInterviewAnswers = {}; let rectRecommendedEvents = []; let activeRectificationQuestionnaire = null; let activeRectificationAnswers = {}; let activeRectificationScore = null; export function renderRectificationTab(container) { const lang = getLang(); container.innerHTML = `

${t('rect.title')}

${t('rect.subtitle')}

${t('rect.method.info')}

${t('rect.config')}

${t('rect.sensitivity.info')} ${Object.entries(VARGA_SENSITIVITY).map(([k, v]) => `` ).join('')}
${t('rect.varga.col')}${t('rect.time.window')}
${k} (${lang === 'en' ? v.en : v.cn})~${v.min} ${t('rect.min.unit')}

主动问询式校时

系统先按出生时间误差生成高信息量选择题;你只需点选答案,再进入下一轮收敛。

active_rectification_questions · candidate_cluster_scoring

快速事件访谈

只回答是/否;选“是”时补一个大概日期,系统会自动转成生命事件。

guided_rectification_interview · recommended_events
${renderRectificationInterview(lang)}

${t('rect.events')} 0 ${t('rect.event.count')}

${t('rect.events.hint')}

${EVENT_COLLECTION_GUIDE.map(group => `${lang === 'en' ? escapeHtml(group.en) : escapeHtml(group.cn)}` ).join('')}
`; bindEvents(container); } function pctStyle(value) { const num = Number(value); const pct = Number.isFinite(num) ? Math.max(0, Math.min(100, Math.round(num))) : 0; return `${pct}%`; } function bindEvents(container) { const q = s => container.querySelector(s); bindInterviewEvents(container); bindActiveRectificationWizard(container); q('#rect-add-btn').addEventListener('click', () => { const dEl = q('#rect-event-date'), cEl = q('#rect-event-cat'), descEl = q('#rect-event-desc'); if (!dEl.value) { dEl.focus(); return; } rectEvents.push({ date: dEl.value, category: cEl.value, desc: descEl.value || EVENT_CATEGORIES[cEl.value].cn }); dEl.value = ''; descEl.value = ''; renderEventList(container); }); q('#rect-run-btn').addEventListener('click', function () { handleRun(container, this); }); } function renderInterviewQuestion(question, lang) { const prompt = lang === 'en' ? question.question_en : question.question_cn; const label = lang === 'en' ? question.label_en : question.label_cn; const examples = (question.examples_cn || []).join(' / '); return `
${escapeHtml(prompt)} ${escapeHtml(label)} · ${escapeHtml(question.varga)}${examples ? ` · ${escapeHtml(examples)}` : ''}
`; } function bindInterviewEvents(container) { container.querySelectorAll('.rect-interview-item').forEach(item => { item.querySelectorAll('[data-rect-answer]').forEach(btn => { btn.addEventListener('click', () => { const answer = btn.dataset.rectAnswer; item.querySelectorAll('[data-rect-answer]').forEach(other => other.classList.toggle('active', other === btn)); item.querySelector('.rect-answer-detail')?.classList.toggle('hidden', answer !== 'yes' && answer !== 'other'); rectInterviewAnswers[item.dataset.questionId] = { answer, category: item.dataset.category, }; }); }); }); container.querySelector('#rect-import-interview')?.addEventListener('click', () => { const events = collectInterviewEvents(container); if (!events.length) { const status = container.querySelector('#rect-interview-status'); if (status) status.textContent = '还没有可导入的“是”回答。'; return; } rectEvents = [...rectEvents, ...events]; renderEventList(container); const status = container.querySelector('#rect-interview-status'); if (status) status.textContent = `已加入 ${events.length} 个事件,正在准备校正。`; container.querySelector('#rect-run-btn')?.click(); }); } function toApiBirthTime(value) { return value ? value.replace('T', ' ').slice(0, 16) : ''; } function renderActiveRectificationQuestions(container) { const target = container.querySelector('#rect-active-questions'); if (!target || !activeRectificationQuestionnaire) return; target.innerHTML = (activeRectificationQuestionnaire.questions || []).map(question => `
${escapeHtml(question.prompt)} ${escapeHtml((question.sensitivity || []).join(' / '))} · ${escapeHtml(question.window || '')}
${(question.options || []).map(option => ` `).join('')}
`).join(''); target.querySelectorAll('[data-active-answer]').forEach(button => { button.addEventListener('click', () => { const item = button.closest('[data-active-question-id]'); if (!item) return; activeRectificationAnswers[item.dataset.activeQuestionId] = button.dataset.activeAnswer; item.querySelectorAll('[data-active-answer]').forEach(btn => btn.classList.remove('selected')); button.classList.add('selected'); }); }); } function renderActiveRectificationScore(container) { const target = container.querySelector('#rect-active-score-result'); if (!target || !activeRectificationScore) return; const rankings = activeRectificationScore.candidate_cluster_rankings || []; const nextQuestions = activeRectificationScore.next_round_questions || []; target.innerHTML = `
候选时间簇
${rankings.map(row => `
${escapeHtml(row.cluster)}${escapeHtml(String(row.score))}
`).join('') || '暂无评分'}
下一轮优先问题
${nextQuestions.map(q => `
${escapeHtml(q.domain || q.id)}${escapeHtml(q.prompt || '')}
`).join('') || '暂无下一轮问题'}
`; } function bindActiveRectificationWizard(container) { const status = container.querySelector('#rect-active-status'); container.querySelector('#rect-active-load')?.addEventListener('click', async () => { const birthTime = toApiBirthTime(container.querySelector('#rect-active-birth-time')?.value || ''); if (!birthTime) { if (status) status.textContent = '请先填写出生时间中心。'; return; } if (!window.JyotishAPI?.computeActiveRectificationQuestions) { if (status) status.textContent = '本地 API 尚未加载主动问询能力。'; return; } try { if (status) status.textContent = '正在生成高信息量问题…'; activeRectificationQuestionnaire = await window.JyotishAPI.computeActiveRectificationQuestions({ birth_time: birthTime, uncertainty_minutes: Number(container.querySelector('#rect-active-uncertainty')?.value || 30), step_minutes: Number(container.querySelector('#rect-active-step')?.value || 1), }); activeRectificationAnswers = {}; activeRectificationScore = null; renderActiveRectificationQuestions(container); renderActiveRectificationScore(container); if (status) status.textContent = `已生成 ${(activeRectificationQuestionnaire.questions || []).length} 个选择题。`; } catch (error) { if (status) status.textContent = error.message || '主动问询生成失败'; } }); container.querySelector('#rect-active-score')?.addEventListener('click', async () => { if (!activeRectificationQuestionnaire) { if (status) status.textContent = '请先生成问题。'; return; } if (!window.JyotishAPI?.computeActiveRectificationScore) { if (status) status.textContent = '本地 API 尚未加载主动问询评分能力。'; return; } try { if (status) status.textContent = '正在评分并收敛候选…'; activeRectificationScore = await window.JyotishAPI.computeActiveRectificationScore({ questionnaire: activeRectificationQuestionnaire, answers: activeRectificationAnswers, }); renderActiveRectificationScore(container); if (status) status.textContent = `已评分 ${activeRectificationScore.answered_count || 0} 个回答。`; } catch (error) { if (status) status.textContent = error.message || '主动问询评分失败'; } }); } function collectInterviewEvents(container) { const answers = [...container.querySelectorAll('.rect-interview-item')].map(item => { const saved = rectInterviewAnswers[item.dataset.questionId] || {}; return { ...saved, category: saved.category || item.dataset.category, date: item.querySelector('.rect-answer-date')?.value || '', note: item.querySelector('.rect-answer-note')?.value || '', }; }); return rectificationInterviewAnswersToEvents(answers); } function renderEventList(container) { const listEl = container.querySelector('#rect-event-list'); const countEl = container.querySelector('#rect-event-count'); const lang = getLang(); countEl.textContent = `${rectEvents.length} ${t('rect.event.count')}`; if (!rectEvents.length) { listEl.innerHTML = `

${t('rect.no.events')}

`; return; } listEl.innerHTML = rectEvents.map((evt, i) => { const cat = EVENT_CATEGORIES[evt.category]; return `
${cat.icon} ${escapeHtml(evt.date)} ${escapeHtml(lang === 'en' ? cat.en : cat.cn)} (${escapeHtml(cat.varga)}) ${escapeHtml(evt.desc)}
`; }).join(''); listEl.querySelectorAll('.rect-evt-del').forEach(b => { b.addEventListener('click', () => { rectEvents.splice(+b.dataset.idx, 1); renderEventList(container); }); }); } async function handleRun(container, runBtn) { if (!rectEvents.length) { alert(t('rect.alert.no.events')); return; } if (!window.__jyotishBirth) { alert(t('rect.alert.no.chart')); return; } const q = s => container.querySelector(s); const progressEl = q('#rect-progress'), fillEl = q('#rect-progress-fill'); const textEl = q('#rect-progress-text'), resultsEl = q('#rect-results'); const rangeMin = +q('#rect-range').value, stepMin = +q('#rect-step').value; progressEl.classList.remove('hidden'); resultsEl.classList.add('hidden'); runBtn.querySelector('.btn-text').classList.add('hidden'); runBtn.querySelector('.btn-loading').classList.remove('hidden'); runBtn.disabled = true; try { const result = await runRectification(window.__jyotishBirth, rectEvents, { rangeMin, stepMin, onProgress(cur, total) { const pct = Math.round(cur / total * 100); fillEl.style.width = pct + '%'; textEl.textContent = `${t('rect.calculating')} ${cur}/${total} (${pct}%)`; }, }); if (result.error) { textEl.textContent = result.error; return; } renderResults(container, result); } catch (e) { textEl.textContent = `Error: ${e.message}`; console.error('[Rect]', e); } finally { runBtn.querySelector('.btn-text').classList.remove('hidden'); runBtn.querySelector('.btn-loading').classList.add('hidden'); runBtn.disabled = false; setTimeout(() => progressEl.classList.add('hidden'), 2000); } } function renderResults(container, result) { const el = container.querySelector('#rect-results'); el.classList.remove('hidden'); const lang = getLang(); const { bestMatch: bm, confidence: conf, baseChartInfo: base, results: all } = result; const audit = result.audit || {}; const decisionPlan = result.decisionPlan || {}; const confClr = { '高': '#22c55e', '中': '#f59e0b', '低': '#ef4444', '不确定': '#9ca3af' }; const correctedBirth = buildCorrectedBirth(result.birth, bm.offsetMin); const reportText = buildRectificationReportText(result, correctedBirth); // 分盘变化 const vcHtml = bm.vargaChanges.length > 0 ? bm.vargaChanges.map(c => `${c.varga}${signName(c.from)}${signName(c.to)}`).join('') : `${t('rect.no.change')}`; // 评分条 const weights = { dasha:'40%', varga:'35%', house:'15%', nak:'10%' }; const scoreBars = Object.keys(weights).map(k => { const s = bm.scores[k]; return `
${t('rect.scoring.'+k)} (${weights[k]})
${escapeHtml(s.pct)}%
`; }).join(''); const auditCards = renderAuditCards(audit, conf, lang); const warningHtml = (audit.warnings || []).length ? `
${audit.warnings.map(w => `

${escapeHtml(w)}

`).join('')}
` : ''; // 事件详情 const evtRows = bm.eventScores.map(es => { const cat = EVENT_CATEGORIES[es.event.category]; const rel = getRelevanceText(es.dasha, es.event.category, bm.ascSign); return ` ${cat.icon} ${escapeHtml(lang==='en'?cat.en:cat.cn)} (${escapeHtml(cat.varga)}) ${escapeHtml(es.event.date)} ${escapeHtml(es.dasha ? planetName(es.dasha.mahadasha) : '—')} ${escapeHtml(es.dasha?.antardasha ? planetName(es.dasha.antardasha) : '—')} ${escapeHtml(es.dashaScore.toFixed(1))} ${escapeHtml(es.vargaScore.toFixed(1))} ${escapeHtml(rel)}`; }).join(''); el.innerHTML = `

${t('rect.result')}

${t('rect.original.time')}${escapeHtml(base.time)}
${t('rect.rec.time')}${escapeHtml(bm.time)} (${escapeHtml(fmtOffset(bm.offsetMin))})
${t('rect.confidence')}${conf.level} (${conf.bestPct}%)
${conf.recommendation.map(r => `

${escapeHtml(r)}

`).join('')}
${warningHtml}

证据闭环

${auditCards}

分盘调用顺序

${escapeHtml(decisionPlan.principle || 'Dasha 定框,核心分盘先行,专项分盘后置。')}

    ${(decisionPlan.ordered_layers || []).map(layer => `
  1. ${escapeHtml(layer.label)} ${escapeHtml(layer.role)}

    ${escapeHtml(layer.reason)}

  2. `).join('')}
${(decisionPlan.selected_theme_vargas || []).length ? `

本轮优先专项分盘:${escapeHtml(decisionPlan.selected_theme_vargas.join(' / '))}

` : ''} ${(decisionPlan.warnings || []).length ? `
${decisionPlan.warnings.map(w => `

${escapeHtml(w)}

`).join('')}
` : ''}

${t('rect.scoring.title')}

${scoreBars}
${t('rect.total.score')} ${bm.totalScore}%

${t('rect.varga.changes')}

${vcHtml}
${t('rect.varga.col')}${t('rect.from.col')}${t('rect.to.col')}

${t('rect.top.candidates')}

${all.slice(0,10).map((r,i) => { const d9=r.vargaLagnas?.D9?.sign||r.ascSign, d10=r.vargaLagnas?.D10?.sign||r.ascSign; const d9chg=r.vargaChanges?.some(v=>v.varga==='D9')?' ⚠️':''; const d10chg=r.vargaChanges?.some(v=>v.varga==='D10')?' ⚠️':''; return ``; }).join('')}
#${t('rect.time')}${t('rect.offset')}${t('rect.asc')}D9D10${t('rect.score')}${t('rect.match')}
${i+1}${escapeHtml(r.time)}${escapeHtml(fmtOffset(r.offsetMin))} ${escapeHtml(signName(r.ascSign))}${escapeHtml(signName(d9))}${d9chg}${escapeHtml(signName(d10))}${d10chg} ${escapeHtml(r.totalScore)}%
${escapeHtml(r.totalScore)}%

${t('rect.event.detail')}

${evtRows}
${t('rect.event.col')}${t('rect.date.col')}${t('rect.maha.col')}${t('rect.antar.col')}${t('rect.score.dasha')}${t('rect.score.varga')}${t('rect.rel.col')}
`; el.querySelector('#rect-apply-time')?.addEventListener('click', () => { document.dispatchEvent(new CustomEvent('jyotish:apply-rectified-birth', { detail: { birth: correctedBirth, rectification: result }, })); }); el.querySelector('#rect-copy-report')?.addEventListener('click', async () => { await copyRectificationReport(reportText); const btn = el.querySelector('#rect-copy-report'); if (btn) { const oldText = btn.textContent; btn.textContent = '已复制'; setTimeout(() => { btn.textContent = oldText; }, 1200); } }); el.querySelectorAll('.rect-top-results tr[data-offset]').forEach(tr => { tr.style.cursor = 'pointer'; tr.addEventListener('click', () => { const off = parseFloat(tr.dataset.offset); const r = all.find(x => x.offsetMin === off); if (r) showOffsetDetail(el, r, base); }); }); } function buildCorrectedBirth(birth, offsetMin) { const date = new Date(birth.year, birth.month - 1, birth.day, birth.hour, birth.minute || 0, 0); date.setMinutes(date.getMinutes() + offsetMin); return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), hour: date.getHours(), minute: date.getMinutes(), lat: birth.lat, lon: birth.lon, tz: birth.tz, }; } function buildRectificationReportText(result, correctedBirth) { const bm = result.bestMatch || {}; const conf = result.confidence || {}; const audit = result.audit || {}; const lines = [ 'Janma Samaya Shuddhi 生时校正摘要', `原始时间:${result.baseChartInfo?.time || '-'}`, `推荐时间:${fmtTime(correctedBirth.hour, correctedBirth.minute)} (${fmtOffset(bm.offsetMin || 0)})`, `推荐日期:${correctedBirth.year}-${String(correctedBirth.month).padStart(2, '0')}-${String(correctedBirth.day).padStart(2, '0')}`, `置信度:${conf.level || '不确定'} / ${conf.bestPct || 0}%`, `事件覆盖:${audit.coverage?.event_count || 0} 个事件,${audit.coverage?.group_count || 0} 类主题,跨度 ${audit.coverage?.year_span || 0} 年`, `候选差距:领先 ${audit.score_gap ?? 0} 分;同分簇 ${audit.top_cluster?.count || 0} 个`, `使用边界:${conf.recommendation?.join(';') || '建议补充事件后复核。'}`, ]; if (audit.warnings?.length) { lines.push(`警告:${audit.warnings.join(';')}`); } return lines.join('\n'); } async function copyRectificationReport(text) { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } function renderAuditCards(audit, conf, lang) { const coverage = audit.coverage || {}; const evidence = audit.evidence || {}; const cluster = audit.top_cluster || {}; const missing = coverage.missing_groups?.length ? coverage.missing_groups.join('、') : '暂不需要'; const runner = audit.runner_up ? `${audit.runner_up.time} (${fmtOffset(audit.runner_up.offsetMin)}, ${audit.runner_up.totalScore}%)` : '无'; const confidenceMeaning = { '高': '可把推荐时间作为主候选,但仍建议保留原始记录。', '中': '可用于D1/D9/D10观察,高敏感分盘需谨慎。', '低': '只能作为探索候选,不建议覆盖出生证明/家人记录。', '不确定': '证据不足,应继续收集事件。', }[conf?.level] || '证据不足,应继续收集事件。'; const cards = [ ['事件覆盖', `${coverage.event_count || 0}个事件 · ${coverage.group_count || 0}类主题`, `质量 ${coverage.quality_score || 0}%;年份跨度 ${coverage.year_span || 0} 年。`], ['命中证据', `${evidence.matched_events || 0}个匹配 · ${evidence.match_rate || 0}%`, `强证据 ${evidence.strong_events || 0} 个;敏感分盘 ${evidence.sensitive_vargas?.join('/') || '无变化'}。`], ['候选差距', `领先 ${audit.score_gap ?? 0} 分`, `第二名:${runner};同分簇 ${cluster.count || 0} 个,范围 ${fmtOffset(cluster.min || 0)} 到 ${fmtOffset(cluster.max || 0)}。`], ['使用边界', conf?.level || '不确定', confidenceMeaning], ['补充建议', missing, '优先补充不同年龄段、不同主题、日期明确的事件。'], ]; return cards.map(([label, value, note]) => `
${escapeHtml(label)} ${escapeHtml(value)}

${escapeHtml(note)}

`).join(''); } function getRelevanceText(di, category, ascSign) { if (!di) return '—'; const cat = EVENT_CATEGORIES[category]; if (!cat) return '—'; const ai = SIGNS.indexOf(ascSign), hl = cat.houses.map(h => getHouseLord(ai, h)); const mdR = cat.planets.includes(di.mahadasha) || hl.includes(di.mahadasha); const adR = di.antardasha && (cat.planets.includes(di.antardasha) || hl.includes(di.antardasha)); if (mdR && adR) return t('rect.strong.match'); if (mdR) return t('rect.maha.match'); if (adR) return t('rect.antar.match'); return t('rect.no.match'); } function showOffsetDetail(container, r, base) { let det = container.querySelector('.rect-offset-detail'); if (!det) { det = document.createElement('div'); det.className = 'rect-offset-detail card'; container.appendChild(det); } const vlHtml = Object.entries(r.vargaLagnas || {}).map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(v ? signName(v.sign) : '—')}` ).join(''); const hcHtml = r.houseChanges.length > 0 ? r.houseChanges.map(c => `${escapeHtml(planetName(c.planet))}: H${escapeHtml(c.from)}→H${escapeHtml(c.to)}`).join('') : escapeHtml(t('rect.no.change')); det.innerHTML = `

${escapeHtml(t('rect.offset.detail').replace('{0}',fmtOffset(r.offsetMin)).replace('{1}',r.time))}

${t('rect.asc.label')}${escapeHtml(signName(r.ascSign))} ${escapeHtml(r.ascDeg?.toFixed(2) || '-')}°
${t('rect.moon.nak')}${escapeHtml(r.moonNak?.nakName||'—')} Pada ${escapeHtml(r.moonNak?.pada||'?')}
${t('rect.total.score')}${escapeHtml(r.totalScore)}%
${t('rect.varga.changes')}
${vlHtml}
${t('rect.house.changes')}
${hcHtml}
`; det.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } export function initRectification() { rectEvents = []; rectInterviewAnswers = {}; } export function setRectificationRecommendedEvents(recommendedEvents = []) { rectRecommendedEvents = Array.isArray(recommendedEvents) ? recommendedEvents : []; } function renderRectificationInterview(lang) { const recommended = buildRecommendedRectificationQuestions(rectRecommendedEvents); const fallback = recommended.length ? [] : buildRectificationInterviewQuestions(); return [...recommended, ...fallback].slice(0, recommended.length ? recommended.length : 3) .map(question => renderInterviewQuestion(question, lang)) .join(''); }