/**
* 生时校正 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.config')}
${t('rect.sensitivity.info')}
| ${t('rect.varga.col')} | ${t('rect.time.window')} |
${Object.entries(VARGA_SENSITIVITY).map(([k, v]) =>
`| ${k} (${lang === 'en' ? v.en : v.cn}) | ~${v.min} ${t('rect.min.unit')} |
`
).join('')}
主动问询式校时
系统先按出生时间误差生成高信息量选择题;你只需点选答案,再进入下一轮收敛。
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 ``;
}
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}
分盘调用顺序
${escapeHtml(decisionPlan.principle || 'Dasha 定框,核心分盘先行,专项分盘后置。')}
${(decisionPlan.ordered_layers || []).map(layer => `
-
${escapeHtml(layer.label)}
${escapeHtml(layer.role)}
${escapeHtml(layer.reason)}
`).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')}
| ${t('rect.varga.col')} | ${t('rect.from.col')} | ${t('rect.to.col')} |
${vcHtml}
${t('rect.top.candidates')}
| # | ${t('rect.time')} | ${t('rect.offset')} | ${t('rect.asc')} | D9 | D10 | ${t('rect.score')} | ${t('rect.match')} |
${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 `
| ${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)}% |
`;
}).join('')}
${t('rect.event.detail')}
| ${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')} |
${evtRows}
`;
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('');
}