Files
Jyotisha/jyotish-app/main.js
T
2026-07-17 11:26:00 +08:00

8622 lines
380 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.
/**
* Jyotish App — 主入口 v3.0
* 整合: 排盘、宫位、相位、Yoga、Karaka、分盘、Ashtakavarga、Shadbala、Dasha三级、Transit
*/
import {
initEngine, computeChart, detectYogas, detectExtendedYogas,
computeAspects, computeHouseAnalysis, searchCities,
SIGNS, SIGNS_CN, PLANET_CN, PLANET_SYMBOLS, SIGN_LORDS,
} from './jyotish-engine.js';
import {
computeCombustion, computeKaraka, computeArudha, computeAllVargas,
computeAshtakavarga, computeShadbala, computeTithiYoga, computeDashaWithPratyantar,
} from './jyotish-advanced.js';
import { renderSouthIndianChart, renderNorthIndianChart, setChartStyle, getChartStyle } from './chart-renderer.js';
import {
HOUSE_MEANINGS, PLANET_IN_HOUSE, NAKSHATRA_DATA,
YOGA_DEFINITIONS, ASPECT_DESC, HOUSE_GROUPS, TRANSIT_EFFECTS,
DASHA_THEMES, ASCENDANT_TABLE, PLANET_DIGNITY, NEECHA_BHANGA_RULES,
} from './interpretation.js';
import { YOGA_EXTENDED_A } from './yoga-extended.js';
import { YOGA_EXTENDED_B } from './yoga-extended-b.js';
import { YOGA_DETAILS_A } from './yoga-details-a.js';
import { YOGA_DETAILS_B } from './yoga-details-b.js';
import { PIH_PART1 } from './planet-house-details-a.js';
import { PIH_PART2 } from './planet-house-details-b.js';
import { PIH_PART3 } from './planet-house-details-c.js';
const YOGA_DETAILS = { ...YOGA_DETAILS_A, ...YOGA_DETAILS_B };
const PLANET_HOUSE_DETAIL = { ...PIH_PART1, ...PIH_PART2, ...PIH_PART3 };
import {
computeTransit, computeTransitOverlay, computeDoubleTransit, checkSadeSati,
computeTransitAVScore, computeTransitHouseImpact,
computeDoubleTransitPAC, computeTransitLL7L, computePlanetaryCongregation, computeVivahSaham,
} from './transit.js';
import {
renderTithiInfo, renderArudhaQuick, renderKaraka, renderVargas, renderArudha,
renderAshtakavarga, renderShadbala, renderDasha3Level, updatePlanetsTable,
} from './renderers.js';
import {
computePACDARES, computeHouseInfluence, detectVargottama, computeFrequency,
computeTriangle, computeRamanHouseScore, computeTrikonaKendra,
} from './analysis-deep.js';
import {
renderFunctionalNature, renderPACDARES, renderHouseInfluence, renderVargottama,
renderFrequency, renderTriangle, renderRamanGrades, renderTrikonaKendra, renderArgala,
} from './analysis-renderers.js';
import {
computeBhavaBala, computeVimsopaka, computeVaiseshikamsas,
computePlanetActivity, computePlanetAge, computePlanetAlertness, computePlanetMood,
computeAVPinda, computeAllExtraDasas,
} from './jyotish-extended.js';
import {
renderBhavaBala, renderVimsopakaVaiseshikamsa, renderPlanetStates, renderAVPinda, renderExtraDasas,
} from './renderers.js';
import { computeArgala } from './argala.js';
import { computeTajika } from './tajika.js';
import { computeNakshatraAdvanced, computeCharaDasha, computeKarakamsha } from './jyotish-advanced.js';
import { computeValidation, computeAudit, computeActionableContext } from './jyotish-export-modules.js';
import { initTooltip, bindTerms, setGlossaryTerminologyMode } from './glossary.js';
import { initAIChat, aiChatSetChartData, openAIChatWithPrompt } from './ai-chat.js';
import { initAuth } from './auth.js';
import { initSubscription } from './subscription.js';
import { renderRectificationTab, initRectification, setRectificationRecommendedEvents } from './rectification.js';
import { t, getLang, initI18N, onLangChange, signName, planetName, statusName, houseLabel, houseAreaName, yearsLabel } from './i18n.js';
import { escapeHtml, escapeAttr, safeNumber } from './security.js';
import { renderSkillCoverage } from './skill-map.js';
import { initChartImport } from './import-chart.js';
import { buildMEVGAudit, renderMEVGAudit } from './mevg-audit.js';
import {
buildProfessionalReadingPayload,
renderProfessionalReadingPanel,
} from './professional-reading.js';
// 合并所有 Yoga 定义
const ALL_YOGA_DEFS = [...YOGA_DEFINITIONS, ...YOGA_EXTENDED_A, ...YOGA_EXTENDED_B];
const D9_MALEFICS = ['Sun', 'Mars', 'Saturn', 'Rahu', 'Ketu'];
const D9_GREEN_HOUSES = [1, 3, 4, 5, 7, 9, 10];
const D9_HARD_HOUSES = [6, 8, 12];
const NODE_DIGNITY = {
Rahu: { exaltation: { sign: 'Taurus' }, debilitation: { sign: 'Scorpio' }, ownSigns: [] },
Ketu: { exaltation: { sign: 'Scorpio' }, debilitation: { sign: 'Taurus' }, ownSigns: [] },
};
const PANCHANGA_ACTIVITIES = [
['all', '全部活动'],
['marriage', '婚礼'],
['business', '开业/签约'],
['travel', '出行'],
['medical', '医疗'],
['education', '学习/入学'],
];
const PANCHANGA_CONDITIONS = [
['all', '全部条件'],
['has_vrata', 'Vrata/节日标签'],
['festival_candidate', '节日候选'],
['spiritual_practice', '修行/祈福'],
['auspicious_activity', '适合所选活动'],
['avoid_new_start', '不宜新开始'],
['good_choghadiya', '有吉利 Choghadiya'],
];
let professionalReadingState = {};
const PANCHANGA_CONDITION_GUIDE = {
all: '显示范围内所有日期。',
has_vrata: '当天存在 vrata、lunar observance 或节日候选标签。',
festival_candidate: '节日名称仍需 lunar masa 或传统规则确认,当前只作为候选窗口。',
spiritual_practice: '适合斋戒、祈福、修行或月相观察的保守标签。',
auspicious_activity: '所选活动或当日综合质量给出推荐倾向。',
avoid_new_start: '含不宜新开始、活动避开或质量偏弱的提醒。',
good_choghadiya: '当天存在吉利 Choghadiya 子时段,可作为日内择时入口。',
};
const PANCHANGA_CONDITION_MODES = [
['all', '满足全部'],
['any', '满足任一'],
];
const DEMO_BIRTH = {
year: 1990,
month: 1,
day: 1,
hour: 12,
minute: 0,
lat: 39.9,
lon: 116.4,
tz: 8,
city: '北京',
};
const CASE_GROUP_PRESETS = ['全部分组', '家庭', '伴侣', '客户', '研究', '朋友', '未分组'];
const CASE_RELATION_PRESETS = [
['all', '全部关系'],
['self', '本人'],
['partner', '伴侣/婚恋'],
['family', '家庭'],
['client', '客户'],
['research', '研究'],
['friend', '朋友'],
['unknown', '未标注'],
];
const $ = id => document.getElementById(id);
const PRODUCT_GAP_DOC = '<repo>/docs/research/product_gap_matrix_2026_06_22.md';
const CHART_LIBRARY_KEY = 'jyotish_chart_library';
const SYNASTRY_PAIR_LIBRARY_KEY = 'jyotish_synastry_pair_library';
const PRASHNA_CASE_LIBRARY_KEY = 'jyotish_prashna_case_library';
const CALCULATION_SETTINGS_KEY = 'jyotish_calculation_settings';
const TERMINOLOGY_MODE_KEY = 'jyotish_terminology_mode';
const TRUST_CENTER_STORAGE_KEYS = [
CHART_LIBRARY_KEY,
SYNASTRY_PAIR_LIBRARY_KEY,
PRASHNA_CASE_LIBRARY_KEY,
CALCULATION_SETTINGS_KEY,
TERMINOLOGY_MODE_KEY,
'jyotish_ai_messages',
'jyotish_user_profile',
'jyotish_subscription_state',
];
const DEFAULT_CALCULATION_SETTINGS = {
ayanamsa: 'lahiri',
nodeMode: 'mean',
houseSystem: 'whole_sign',
sunrisePolicy: 'auto_solar',
geocoderPolicy: 'local_city_db',
ephemerisBackend: 'swisseph_local',
terminologyMode: 'balanced',
yogaVariant: 'bvr_structured',
jaiminiKarakaVariant: 'seven_and_eight',
kpSignificatorVariant: 'abcd_weighted',
ashtakavargaVariant: 'bphs_pav_sodhita',
shadbalaVariant: 'relative_internal',
dashaReference: 'birth_moon',
};
const TERMINOLOGY_MODE_OPTIONS = {
balanced: {
label: '平衡模式',
shortLabel: 'Balanced',
note: '同时显示中文、Sanskrit、English 和短解释。',
},
beginner: {
label: '入门模式',
shortLabel: 'Beginner',
note: '优先现代生活语言,减少缩写负担。',
},
professional: {
label: '专业模式',
shortLabel: 'Pro',
note: '保留中文、英文和 Sanskrit 对照。',
},
};
const CALCULATION_SETTING_OPTIONS = {
ayanamsa: [
['lahiri', 'Lahiri / Chitrapaksha'],
['raman', 'Raman'],
['kp', 'KP / Krishnamurti'],
],
nodeMode: [
['mean', 'Mean node / 平均罗喉'],
['true', 'True node / 真罗喉'],
],
houseSystem: [
['whole_sign', 'Whole Sign / Rashi'],
['sripati', 'Sripati Bhava Chalit(专项接口)'],
['equal', 'Equal House(专项接口)'],
['placidus', 'Placidus(专项接口)'],
],
sunrisePolicy: [
['auto_solar', '按地点计算日出/日落'],
['manual_time', '使用面板手填日出/日落'],
],
geocoderPolicy: [
['local_city_db', '本地城市库'],
['manual_coordinates', '手动经纬度优先'],
],
ephemerisBackend: [
['swisseph_local', 'Swiss Ephemeris / 本地 API 主路径'],
['swisseph_wasm', 'SwissEph WASM / 浏览器降级'],
['xalen_spike', 'xalen-ephemeris Apache-2.0 可行性记录'],
],
terminologyMode: [
['balanced', '平衡:术语 + 短解释'],
['beginner', '初学:现代生活语言优先'],
['professional', '专业:Sanskrit / English / 技法名优先'],
],
yogaVariant: [
['bvr_structured', 'B.V. Raman/PyJHora 对齐规则(当前)'],
['conservative_classic', '保守经典规则(后续切换)'],
['functional_plus_classic', '功能性 + 经典 Yoga(后续切换)'],
],
jaiminiKarakaVariant: [
['seven_and_eight', '7/8 Karaka 双口径展示(当前)'],
['seven_karaka', '7 Karaka 固定口径(后续切换)'],
['eight_karaka_rahu', '8 Karaka 含 Rahu(后续切换)'],
],
kpSignificatorVariant: [
['abcd_weighted', 'ABCD Significator 加权(当前)'],
['traditional_sub_lord', '传统 Star/Sub/Sub-Sub(后续切换)'],
['question_domain_focus', '按问题域重点宫位(后续切换)'],
],
ashtakavargaVariant: [
['bphs_pav_sodhita', 'BPHS BAV/SAV + PAV/Sodhita(当前)'],
['sav_bav_only', '仅 SAV/BAV 快速层(后续切换)'],
['transit_av_weighted', 'Transit AV 加权(后续切换)'],
],
shadbalaVariant: [
['relative_internal', '内部相对强弱(当前)'],
['absolute_benchmark', '外部绝对值校准(后续切换)'],
['ishta_kashta_weighted', 'Ishta/Kashta 加权(后续切换)'],
],
dashaReference: [
['birth_moon', '出生月亮星宿起算(当前)'],
['query_date_snapshot', '按查询日期快照(后续切换)'],
['multi_system_convergence', '多 Dasha 共振(后续切换)'],
],
};
// 全局状态
let chartData = null;
let _lastChartSummary = null;
let _lastSynastryPairRecord = null;
let _lastPrashnaCaseRecord = null;
let _caseWorkspaceSelection = new Set();
let _caseWorkspacePreview = null;
let _exportInProgress = false;
// ============================================================================
// Tab 切换
// ============================================================================
document.addEventListener('DOMContentLoaded', () => {
const tabs = $('section-tabs');
if (!tabs) return;
tabs.addEventListener('click', e => {
const btn = e.target.closest('.tab-btn');
if (!btn || !btn.dataset.tab) return;
tabs.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
const panel = $(`tab-${btn.dataset.tab}`);
if (panel) {
panel.classList.add('active');
// Tab 切换后重新绑定术语
setTimeout(() => bindTerms(panel), 100);
setTimeout(() => bindTerms(panel), 500);
}
// 自动将当前 Tab 滚入视区
btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
});
// Tab 栏滚动提示箭头
const hint = document.createElement('div');
hint.className = 'tab-scroll-hint';
hint.innerHTML = '';
hint.title = 'Scroll →';
tabs.parentNode.style.position = 'relative';
tabs.parentNode.appendChild(hint);
function checkScrollHint() {
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 10;
hint.classList.toggle('visible', !atEnd && tabs.scrollWidth > tabs.clientWidth);
}
tabs.addEventListener('scroll', checkScrollHint);
// 全局可调用,切换页面后触发
window.__checkTabScrollHint = checkScrollHint;
window.addEventListener('resize', checkScrollHint);
hint.addEventListener('click', () => {
tabs.scrollBy({ left: 200, behavior: 'smooth' });
});
});
function switchToTab(tabName) {
const tabs = $('section-tabs');
if (!tabs || !tabName) return;
const btn = [...tabs.querySelectorAll('.tab-btn')].find(item => item.dataset.tab === tabName);
if (btn) btn.click();
}
// ============================================================================
// 城市搜索
// ============================================================================
let debounceTimer = null;
function setupCitySearch() {
const cityInput = $('birth-city'), suggestions = $('city-suggestions');
const latInput = $('birth-lat'), lonInput = $('birth-lon'), tzSelect = $('birth-tz');
cityInput.addEventListener('input', () => {
latInput.value = '';
lonInput.value = '';
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const q = cityInput.value.trim();
if (q.length < 1) { suggestions.classList.add('hidden'); return; }
const results = searchCities(q);
if (results.length === 0) { suggestions.classList.add('hidden'); return; }
suggestions.innerHTML = results.map(c => {
const lat = safeNumber(c.lat);
const lon = safeNumber(c.lon);
const tz = safeNumber(c.tz);
const label = c.en ? `${c.name} (${c.en})` : c.name;
return `<div class="suggestion-item" data-lat="${escapeAttr(lat)}" data-lon="${escapeAttr(lon)}" data-tz="${escapeAttr(tz)}">${escapeHtml(label)} <small>${lat.toFixed(1)}°, ${lon.toFixed(1)}°</small></div>`;
}).join('');
suggestions.classList.remove('hidden');
suggestions.querySelectorAll('.suggestion-item').forEach(el => {
el.addEventListener('click', () => {
const text = el.textContent.trim();
cityInput.value = text.split(/\s+\(/)[0]; // 取中文名部分
latInput.value = el.dataset.lat; lonInput.value = el.dataset.lon; tzSelect.value = el.dataset.tz;
suggestions.classList.add('hidden');
});
});
}, 200);
});
document.addEventListener('click', e => {
if (!cityInput.contains(e.target) && !suggestions.contains(e.target)) suggestions.classList.add('hidden');
});
}
function normalizeCityText(value) {
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ');
}
function applyCityToBirthForm(city) {
if (!city) return false;
const lat = safeNumber(city.lat);
const lon = safeNumber(city.lon);
const tz = safeNumber(city.tz);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
const label = city.en ? `${city.name} (${city.en})` : city.name;
$('birth-city').value = city.name || city.en || label;
$('birth-lat').value = lat;
$('birth-lon').value = lon;
if (Number.isFinite(tz)) $('birth-tz').value = String(tz);
const suggestions = $('city-suggestions');
if (suggestions) suggestions.classList.add('hidden');
return true;
}
function resolveTypedBirthCity() {
const lat = parseFloat($('birth-lat').value);
const lon = parseFloat($('birth-lon').value);
if (Number.isFinite(lat) && Number.isFinite(lon)) return true;
const q = $('birth-city').value.trim();
if (!q) return false;
const results = searchCities(q);
if (!results.length) return false;
const target = normalizeCityText(q);
const exact = results.find(city => {
const names = [city.name, city.en, city.en ? `${city.name} (${city.en})` : city.name];
return names.some(name => normalizeCityText(name) === target);
});
return applyCityToBirthForm(exact || (results.length === 1 ? results[0] : null));
}
function getBrowserTimezoneOffset() {
const tz = -new Date().getTimezoneOffset() / 60;
return Number.isFinite(tz) ? tz : 0;
}
function resolveTimezoneValue(value) {
const raw = String(value ?? '').trim();
if (!raw || raw === 'auto') return getBrowserTimezoneOffset();
const utc = raw.match(/^UTC\s*([+-]?\d{1,2})(?::?(\d{2}))?/i);
if (utc) {
const hours = Number(utc[1]);
const minutes = Number(utc[2] || 0);
if (Number.isFinite(hours) && Number.isFinite(minutes)) {
return hours + (hours >= 0 ? minutes / 60 : -minutes / 60);
}
}
const tz = Number(raw);
return Number.isFinite(tz) ? tz : getBrowserTimezoneOffset();
}
// ============================================================================
// 日期下拉框初始化
// ============================================================================
function initDateSelects() {
const yearEl = $('birth-year'), monthEl = $('birth-month'), dayEl = $('birth-day');
if (!yearEl) return;
const thisYear = new Date().getFullYear();
for (let y = thisYear; y >= 1940; y--) {
const opt = document.createElement('option');
opt.value = y; opt.textContent = y;
yearEl.appendChild(opt);
}
yearEl.value = '1997';
for (let m = 1; m <= 12; m++) {
const opt = document.createElement('option');
opt.value = m; opt.textContent = m;
monthEl.appendChild(opt);
}
function updateDays() {
const y = parseInt(yearEl.value) || 2000;
const m = parseInt(monthEl.value) || 1;
const maxDay = new Date(y, m, 0).getDate();
const curDay = parseInt(dayEl.value) || 0;
dayEl.innerHTML = '';
const ph = document.createElement('option');
ph.value = ''; ph.disabled = true; ph.textContent = t('date.day');
dayEl.appendChild(ph);
for (let d = 1; d <= maxDay; d++) {
const opt = document.createElement('option');
opt.value = d; opt.textContent = d;
dayEl.appendChild(opt);
}
if (curDay >= 1 && curDay <= maxDay) dayEl.value = curDay;
}
yearEl.addEventListener('change', updateDays);
monthEl.addEventListener('change', updateDays);
updateDays();
// 更新 placeholder option 文本
function updatePlaceholders() {
yearEl.options[0].textContent = t('date.year');
monthEl.options[0].textContent = t('date.month');
if (dayEl.options[0]) dayEl.options[0].textContent = t('date.day');
}
updatePlaceholders();
onLangChange(updatePlaceholders);
}
// ============================================================================
// 表单提交
// ============================================================================
async function computeChartForBirth(birth) {
const { year, month, day, hour, minute, second = 0, lat, lon, tz } = birth;
const settings = readCalculationSettings();
const payload = applyCalculationSettingsToPayload({ year, month, day, hour, minute, second, lat, lon, tz });
if (!payload.ayanamsa) payload.ayanamsa = settings.ayanamsa;
const apiChart = await window.JyotishAPI?.computeWithPython(payload);
if (apiChart?.success) {
console.log('[Jyotish] ✅ 本地 API 服务 —', apiChart.dasha_count, 'Dasha');
return attachCalculationSettings(apiChart, settings);
}
await initEngine();
const fallbackChart = await computeChart({ year, month, day, hour, minute, second, lat, lon, tz });
fallbackChart._fallback = true;
fallbackChart._calculation_boundary = {
ayanamsa: settings.ayanamsa,
appliedAyanamsa: 'lahiri',
note: settings.ayanamsa === 'lahiri'
? 'Browser fallback uses Lahiri-compatible SwissEph WASM path.'
: `Browser fallback cannot apply ${settings.ayanamsa}; start the local API service to calculate this ayanamsa.`,
};
attachCalculationSettings(fallbackChart, settings);
console.log('[Jyotish] ⚠️ JS引擎计算完成');
return fallbackChart;
}
async function computeConsultationForBirth(birth, options = {}) {
const entryMode = options.entryMode || 'direct_chart';
const payload = {
...birth,
entry_mode: entryMode,
question: options.question || '',
question_text: options.questionText || options.question || '',
theme: options.theme || ['career', 'marriage', 'wealth'],
};
if (Array.isArray(options.events)) payload.events = options.events;
try {
const workflow = await window.JyotishAPI?.computeConsultationWorkflow?.(payload);
const chart = workflow?.chart;
if (workflow?.success && chart?.success) {
chart._consultationWorkflow = workflow;
return chart;
}
} catch (error) {
console.warn('[Jyotish] consultation workflow unavailable, fallback to chart:', error?.message || error);
}
const fallbackChart = await computeChartForBirth(birth);
fallbackChart._consultationWorkflow = {
success: false,
endpoint: 'consultation_workflow',
entry_mode: entryMode,
boundary: 'consultation_workflow_blocked_fallback_to_local_chart',
};
return fallbackChart;
}
function buildChartComputeRecoveryMessage(error) {
const message = error?.message || error || '本地 API 未连接';
return `计算失败:${message}。请到 Trust Center 运行健康检查;如本地 API 未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。`;
}
function buildInteractiveAPIRecoveryMessage(label, error) {
const message = error?.message || error || '本地 API 未连接';
return `${label}${message}。请到 Trust Center 运行健康检查;如本地 API 未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。`;
}
function renderInlineAPIError(className, label, error) {
return `<p class="${escapeAttr(className)}">${escapeHtml(buildInteractiveAPIRecoveryMessage(label, error))}</p>`;
}
function setChartComputeStatus(message = '', tone = 'warn') {
const status = $('chart-compute-status');
if (!status) return;
status.textContent = message;
status.dataset.tone = tone;
status.classList.toggle('hidden', !message);
}
function setupForm() {
const form = $('birth-form'), btn = $('btn-calculate');
const btnText = btn.querySelector('.btn-text'), btnLoading = btn.querySelector('.btn-loading');
let selectedEntryMode = 'direct_chart';
const entryButtons = {
direct_chart: $('entry-direct-chart'),
rectification: $('entry-rectification'),
prashna: $('entry-prashna'),
};
const prashnaQuestionGroup = $('prashna-question-group');
const prashnaQuestionInput = $('prashna-question-text');
const syncEntryModeUI = () => {
Object.entries(entryButtons).forEach(([mode, el]) => {
if (!el) return;
el.classList.toggle('active', mode === selectedEntryMode);
});
if (prashnaQuestionGroup) {
prashnaQuestionGroup.style.display = selectedEntryMode === 'prashna' ? '' : 'none';
}
};
Object.entries(entryButtons).forEach(([mode, el]) => {
if (!el) return;
el.addEventListener('click', () => {
selectedEntryMode = mode;
syncEntryModeUI();
});
});
$('entry-prashna')?.addEventListener('dblclick', () => computeConsultationForBirth(window.__jyotishBirth || {}, { entryMode: 'prashna' }));
syncEntryModeUI();
form.addEventListener('submit', async (e) => {
e.preventDefault();
const year = parseInt($('birth-year').value);
const month = parseInt($('birth-month').value);
const day = parseInt($('birth-day').value);
const timeVal = $('birth-time').value;
resolveTypedBirthCity();
const lat = parseFloat($('birth-lat').value), lon = parseFloat($('birth-lon').value);
const tz = resolveTimezoneValue($('birth-tz').value);
if (!year || !month || !day || !timeVal) { alert(t('alert.date')); return; }
if (isNaN(lat) || isNaN(lon)) { alert(t('alert.city')); return; }
if (selectedEntryMode === 'prashna' && !(prashnaQuestionInput?.value || '').trim()) {
alert('请输入问事问题');
return;
}
const [hour, minute, second = 0] = timeVal.split(':').map(Number);
btnText.classList.add('hidden'); btnLoading.classList.remove('hidden'); btn.disabled = true;
setChartComputeStatus('正在计算星盘...', 'warn');
try {
// v6.9.4: 计算层 — 优先本地 API 服务, 回退JS引擎
chartData = await computeConsultationForBirth(
{ year, month, day, hour, minute, second, lat, lon, tz },
{
entryMode: selectedEntryMode,
question: selectedEntryMode === 'prashna' ? (prashnaQuestionInput?.value || '').trim() : '',
questionText: selectedEntryMode === 'prashna' ? (prashnaQuestionInput?.value || '').trim() : '',
},
);
} catch (e) {
console.error('[Jyotish] 计算失败:', e);
setChartComputeStatus(buildChartComputeRecoveryMessage(e), 'error');
btnText.classList.remove('hidden'); btnLoading.classList.add('hidden'); btn.disabled = false;
return;
}
try {
window.__jyotishBirth = { year, month, day, hour, minute, second, lat, lon, tz };
renderAll();
showPage('chart');
aiChatSetChartData(chartData);
// v6.9.4: 触发 AI 解读
triggerAIReading(chartData);
setChartComputeStatus(chartData?._fallback ? '已使用浏览器本地引擎生成星盘;如需完整本地 API 服务能力,请到 Trust Center 运行健康检查。' : '星盘已生成。', 'ok');
} catch (err) {
console.error('[Jyotish] Error:', err);
setChartComputeStatus(buildChartComputeRecoveryMessage(err), 'error');
} finally {
btnText.classList.remove('hidden'); btnLoading.classList.add('hidden'); btn.disabled = false;
}
});
}
function fillBirthFormFromData(birth) {
const yearEl = $('birth-year');
const monthEl = $('birth-month');
const dayEl = $('birth-day');
const cityEl = $('birth-city');
yearEl.value = String(birth.year);
monthEl.value = String(birth.month);
monthEl.dispatchEvent(new Event('change'));
dayEl.value = String(birth.day);
const second = Number.isFinite(Number(birth.second)) ? Number(birth.second) : 0;
$('birth-time').value = second
? `${String(birth.hour).padStart(2, '0')}:${String(birth.minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`
: `${String(birth.hour).padStart(2, '0')}:${String(birth.minute).padStart(2, '0')}`;
$('birth-lat').value = birth.lat;
$('birth-lon').value = birth.lon;
$('birth-tz').value = String(birth.tz);
if (cityEl && birth.city) cityEl.value = birth.city;
}
function setFirstUseStatus(message, tone = 'neutral') {
const status = $('first-use-status');
if (!status) return;
status.textContent = message || '';
status.dataset.tone = tone;
}
function fillDemoBirth() {
fillBirthFormFromData(DEMO_BIRTH);
setFirstUseStatus('示例盘已填入。确认信息后点击“生成星盘”进入完整解盘。', 'ok');
$('btn-calculate')?.focus();
}
async function runFirstUseHealthCheck() {
setFirstUseStatus('正在检查本地 API 与技法目录...', 'pending');
try {
const apiHealth = await window.JyotishAPI?.getAPIHealth?.();
const audit = await window.JyotishAPI?.getCapabilityAudit?.();
const apiOnline = Boolean(apiHealth?.success || apiHealth?.status === 'ok' || apiHealth?.ok);
const techniqueCount = audit?.registry?.technique_count || audit?.registry_count || audit?.technique_count || 0;
const endpointCount = audit?.surfaces?.api_endpoint_count || audit?.api_endpoint_count || 0;
window.__jyotishRuntimeHealth = {
status: apiOnline ? 'ok' : 'warn',
checkedAt: new Date().toISOString(),
apiOnline,
apiBase: apiHealth?.base,
version: apiHealth?.version,
latencyMs: apiHealth?.latencyMs,
techniqueCount,
endpointCount,
apiHealth,
audit,
details: [
['PWA 安装壳', getPWAStatus().label, getPWAStatus().note],
['本地 API 服务', apiHealth?.base || (apiOnline ? 'online' : '未连接'), apiOnline ? `health ok · v${apiHealth?.version || '-'} · ${apiHealth?.latencyMs ?? '-'}ms` : '本地 API 未通过 health 检查'],
['Technique catalog', `${techniqueCount || 0} techniques`, `${endpointCount || 0} API endpoints 可被前端发现`],
],
};
if (apiOnline) {
setFirstUseStatus(`本地 API 可用;技法目录 ${techniqueCount || 0} 项、API ${endpointCount || 0} 个端点已返回。可以继续生成星盘。`, 'ok');
return;
}
setFirstUseStatus('本地 API 未连接;仍可先用浏览器 fallback 生成基础星盘,后端报告/PDF/高级技法需启动本地 API 服务。', 'warn');
} catch (error) {
window.__jyotishRuntimeHealth = {
status: 'warn',
checkedAt: new Date().toISOString(),
apiOnline: false,
error: error?.message || String(error),
details: [
['PWA 安装壳', getPWAStatus().label, getPWAStatus().note],
['本地 API 服务', '未连接', error?.message || '请确认本地 API 服务正在 127.0.0.1:5200 运行'],
['启动路径', '普通用户启动路径', '按 README 先启动网页服务,再启动本地 API 服务。'],
],
};
setFirstUseStatus('本地 API 未连接;请按普通用户启动路径启动网页服务和本地 API 服务后再检查。', 'warn');
}
}
function focusFirstUseImport() {
const importPanel = $('chart-import-panel');
const importText = $('chart-import-text');
if (importPanel) importPanel.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (importText) importText.focus();
setFirstUseStatus('已定位到星盘导入区。粘贴出生资料或上传文本层 PDF 后点击“识别信息”。', 'neutral');
}
function setupFirstUsePanel() {
const panel = $('first-use-panel');
if (!panel || panel.dataset.bound) return;
panel.dataset.bound = 'true';
$('first-use-demo')?.addEventListener('click', fillDemoBirth);
$('first-use-health')?.addEventListener('click', runFirstUseHealthCheck);
$('first-use-import')?.addEventListener('click', focusFirstUseImport);
}
function getAscendantLord(ascendant = {}) {
return ascendant.lord || SIGN_LORDS[ascendant.sign] || '';
}
function normalizePlanetRecord(planet = {}) {
return planet || {};
}
function formatMoonNakshatra(moonP) {
const moon = normalizePlanetRecord(moonP);
const nakshatra = moon.nakshatra || moon.nakshatra_name || '';
const pada = moon.nakshatra_pada || moon.pada || '';
if (!nakshatra) return '';
return `${nakshatra}${pada ? ` Pada ${pada}` : ''}`;
}
async function applyRectifiedBirth(birth) {
if (!birth) return;
try {
fillBirthFormFromData(birth);
chartData = await computeConsultationForBirth(birth, { entryMode: 'rectification' });
window.__jyotishBirth = birth;
renderAll();
showPage('chart');
aiChatSetChartData(chartData);
triggerAIReading(chartData);
const rectPanel = $('rect-panel');
const rectOverlay = $('rect-overlay');
if (rectPanel) rectPanel.classList.add('hidden');
if (rectOverlay) rectOverlay.classList.add('hidden');
document.body.style.overflow = '';
} catch (error) {
console.error('[Rect] apply corrected birth failed:', error);
setChartComputeStatus(buildInteractiveAPIRecoveryMessage('校正时间应用暂不可用', error), 'error');
}
}
// ============================================================================
// 全部渲染
// ============================================================================
function renderAll() {
if (!chartData) return;
const { ascendant = {}, planets = {}, birth_info = {} } = chartData;
if (!birth_info.date) birth_info.date = '2000-01-01';
if (!birth_info.time) birth_info.time = '12:00';
if (!birth_info.tz) birth_info.tz = 'UTC+8';
const moonP = normalizePlanetRecord(planets.Moon);
const ascendantLord = ascendant.lord || getAscendantLord(ascendant);
const moonNakshatra = formatMoonNakshatra(moonP);
// 高级计算
computeCombustion(planets);
const karaka = computeKaraka(planets);
const arudha = computeArudha(planets, ascendant.sign);
const allV = computeAllVargas(planets);
const av = computeAshtakavarga(planets, ascendant.sign);
const sb = computeShadbala(planets, ascendant.sign, birth_info, allV);
const ty = computeTithiYoga(planets, birth_info);
let dashaData = null;
if (moonP) dashaData = computeDashaWithPratyantar(moonP.degree, birth_info.date);
// Banner
$('asc-sign').textContent = `${ascendant.sign} · ${signName(ascendant.sign)}`;
$('asc-degree').textContent = `${((ascendant.degree_in_sign ?? ascendant.degree) ?? 0).toFixed(2)}° | ${t('asc.lord')}: ${planetName(ascendantLord) || ascendantLord || '-'}`;
$('asc-nakshatra').textContent = moonNakshatra ? `${t('asc.nakshatra')}: ${moonNakshatra}` : '';
$('asc-lord-badge').textContent = ascendantLord ? `${t('asc.lord')}: ${planetName(ascendantLord) || ascendantLord} (${ascendantLord})` : `${t('asc.lord')}: -`;
// 生时校正引导卡片:显示/绑定
const rectPrompt = $('rect-prompt');
const rectPanel = $('rect-panel');
const rectOverlay = $('rect-overlay');
if (rectPrompt && rectPanel) {
rectPrompt.classList.remove('hidden');
if (!rectPrompt.dataset.bound) {
rectPrompt.dataset.bound = 'true';
$('rect-prompt-btn').addEventListener('click', () => {
selectedEntryMode = 'rectification';
syncEntryModeUI();
rectPanel.classList.remove('hidden');
if (rectOverlay) rectOverlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
initRectification();
setRectificationRecommendedEvents(getRectificationRecommendedEvents(chartData));
renderRectificationTab($('rect-content'));
});
$('rect-panel-close').addEventListener('click', () => {
rectPanel.classList.add('hidden');
if (rectOverlay) rectOverlay.classList.add('hidden');
document.body.style.overflow = '';
});
if (rectOverlay) rectOverlay.addEventListener('click', () => {
rectPanel.classList.add('hidden');
rectOverlay.classList.add('hidden');
document.body.style.overflow = '';
});
}
}
// 提前计算 Yogas(供星盘中心区域和 Yoga Tab 共用)
const baseYogas = detectYogas(planets, ascendant.sign);
let allYogas = [...baseYogas.yogas];
for (const yd of ALL_YOGA_DEFS) {
try {
const combo = yd.check(planets, ascendant.sign);
if (combo) allYogas.push({ id: yd.id, name: yd.name, name_cn: yd.name_cn, combination: combo, effects: yd.effects, strength: yd.strength, negative: yd.negative || false, category: yd.category });
} catch (e) {}
}
// 构建星盘中心摘要
const chartSummary = buildChartSummary(ascendant, moonP, dashaData, karaka, allYogas);
_lastChartSummary = chartSummary;
// 本命盘 Tab
const renderFn = getChartStyle() === 'north' ? renderNorthIndianChart : renderSouthIndianChart;
renderFn($('rasi-chart'), chartData, { summary: chartSummary });
updatePlanetsTable(planets, allV.D9);
renderTithiInfo(ty);
renderArudhaQuick(arudha);
// Karaka Tab
renderKaraka(karaka);
// 宫位分析 Tab
renderHouseAnalysis(planets, ascendant.sign);
// 相位 Tab
renderAspects(planets);
// Yoga Tab(使用预计算结果)
renderYogasWith(allYogas);
// 分盘 Tab
renderVargas(allV, planets, ascendant);
renderD9MarriageReport(allV, planets, ascendant, karaka);
renderArudha(arudha);
// Ashtakavarga Tab
renderAshtakavarga(av, ascendant.sign);
// Shadbala Tab
renderShadbala(sb);
// Dasha Tab
renderDashaSystemOverview(chartData);
if (dashaData) renderDasha3Level(dashaData);
// Transit Tab(异步)
renderTransit(planets, ascendant.sign, av, ascendant.degree);
// 深度分析 Tab
const pacdares = computePACDARES(planets, ascendant.sign);
const houseInfluence = computeHouseInfluence(planets, ascendant.sign);
const vargottama = detectVargottama(planets);
const frequency = computeFrequency(planets);
const triangle = computeTriangle(planets);
const ramanGrades = computeRamanHouseScore(planets, ascendant.sign);
const argala = computeArgala(buildPlanetSignIndex(planets), SIGNS.indexOf(ascendant.sign));
renderFunctionalNature(ascendant.sign);
renderPACDARES(pacdares);
renderArgala(argala);
renderRamanGrades(ramanGrades);
renderHouseInfluence(houseInfluence);
renderVargottama(vargottama);
renderTriangle(triangle);
renderFrequency(frequency);
// 三方四正映射分析
const tkData = computeTrikonaKendra(planets, ascendant.sign);
renderTrikonaKendra(tkData);
// 扩展分析 Tab
const bb = computeBhavaBala(planets, ascendant.sign, sb);
const vim = computeVimsopaka(planets);
const vais = computeVaiseshikamsas(vim);
const activity = computePlanetActivity(planets);
const pAge = computePlanetAge(planets);
const alertness = computePlanetAlertness(planets);
const mood = computePlanetMood(planets);
const pinda = computeAVPinda(av);
const extraDasas = computeAllExtraDasas(planets, ascendant.sign, birth_info.date);
renderBhavaBala(bb);
renderVimsopakaVaiseshikamsa(vim, vais);
renderPlanetStates(activity, pAge, alertness, mood);
renderAVPinda(pinda);
renderExtraDasas(extraDasas);
const validation = computeValidation(planets, ascendant, av, dashaData);
const audit = computeAudit(planets, ascendant, av, validation);
const actionableCtx = computeActionableContext(planets, ascendant);
const provenance = buildCalculationProvenance(chartData, ty, {
av,
shadbala: sb,
validation,
audit,
chartStyle: getChartStyle(),
});
chartData._client_audit = { validation, audit, actionableContext: actionableCtx, provenance };
renderSpecialLagnaReport(arudha, ascendant, birth_info, chartData.special_lagnas);
renderCompleteReadingTab({ ascendant, moonP, allYogas, dashaData, extraDasas, chartData, validation, audit });
renderAIPromptPackPanel(chartData);
renderWorkflowSummaryPanel(chartData);
renderProvenancePanel({
chartData,
panchanga: ty,
provenance,
validation,
audit,
allYogas,
dashaData,
extraDasas,
});
// 🔥 v6.7.2: API数据渲染 Recovery & KP tabs
renderRemediesTab(chartData);
renderSynastryTab(chartData);
renderPrashnaTab(chartData);
renderKPTab(chartData);
// v6.9.0: 验证 & 过境 Tab
renderVerifyTab(chartData);
renderTransitCompareTab(chartData);
updateSaveChartButton();
renderSavedChartPanel();
// 绑定术语 Tooltip(延迟确保所有异步渲染完成)
setTimeout(() => bindTerms(document.querySelector('#page-chart')), 200);
setTimeout(() => bindTerms(document.querySelector('#page-chart')), 800);
}
function getConsultationWorkflow(chartRecord) {
return chartRecord?._consultationWorkflow && typeof chartRecord._consultationWorkflow === 'object'
? chartRecord._consultationWorkflow
: {};
}
function getRuntimePlanner(chartRecord) {
const workflow = getConsultationWorkflow(chartRecord);
return workflow?.runtime_planner && typeof workflow.runtime_planner === 'object'
? workflow.runtime_planner
: {};
}
function formatWorkflowEntryMode(value) {
if (value === 'rectification') return '生时校正';
if (value === 'direct_chart') return '直接排盘';
return value || '-';
}
function formatWorkflowRouteLabel(route = {}) {
const mapping = {
career: '事业',
relationship: '婚恋',
finance: '财富',
timing: '时间应期',
general: '综合',
};
return mapping[route?.question_type] || route?.question_type || '-';
}
function renderRuntimePlannerPills(runtimePlanner = {}) {
const syncSteps = Array.isArray(runtimePlanner.sync_steps) ? runtimePlanner.sync_steps : [];
const asyncCandidates = Array.isArray(runtimePlanner.async_candidates) ? runtimePlanner.async_candidates : [];
const sourcePriority = runtimePlanner.source_priority?.priority || [];
return `
<div class="workflow-summary-grid">
<div class="workflow-summary-pill">
<strong>入口</strong>
<span>${escapeHtml(formatWorkflowEntryMode(runtimePlanner.entry_mode))}</span>
</div>
<div class="workflow-summary-pill">
<strong>主问题路由</strong>
<span>${escapeHtml(formatWorkflowRouteLabel(runtimePlanner.route))}</span>
</div>
<div class="workflow-summary-pill">
<strong>证据优先级</strong>
<span>${escapeHtml(sourcePriority.length ? 'VedAstro official -> local supplemental -> fallback' : 'fallback')}</span>
</div>
<div class="workflow-summary-pill">
<strong>同步主链</strong>
<span>${escapeHtml(syncSteps.join(' -> ') || '-')}</span>
</div>
<div class="workflow-summary-pill">
<strong>异步候选</strong>
<span>${escapeHtml(asyncCandidates.join(' / ') || '-')}</span>
</div>
<div class="workflow-summary-pill">
<strong>Planner</strong>
<span>${escapeHtml(runtimePlanner.planner_name || 'UnifiedConsultationRuntimePlanner')}</span>
</div>
</div>
`;
}
function renderWorkflowSummaryCard(workflow = {}) {
const runtimePlanner = workflow?.runtime_planner || {};
const unified = workflow?.unified_orchestrator || {};
const sourcePriority = workflow?.source_priority?.priority || unified?.source_priority?.priority || [];
const muhurtaSidecar = workflow?.muhurta_panchanga;
const compactSource = sourcePriority.length
? sourcePriority.join(' -> ')
: 'VedAstro official / local supplemental / fallback';
return `
<section class="workflow-summary-card">
<div class="provenance-head">
<span>本次解读工作流</span>
<strong>${escapeHtml(formatWorkflowRouteLabel(runtimePlanner.route || workflow?.routing || {}))}</strong>
</div>
<p class="workflow-summary-copy">${escapeHtml(`入口 ${formatWorkflowEntryMode(workflow?.entry_mode || runtimePlanner.entry_mode)};主链优先读取 VedAstro official 证据,再由 local supplemental 补充,最后才 fallback。`)}</p>
${renderRuntimePlannerPills({
...runtimePlanner,
entry_mode: runtimePlanner.entry_mode || workflow?.entry_mode,
route: runtimePlanner.route || workflow?.routing || {},
source_priority: runtimePlanner.source_priority || workflow?.source_priority || unified?.source_priority || {},
})}
<div class="workflow-summary-pill" style="margin-top:8px">
<strong>source_priority</strong>
<span>${escapeHtml(compactSource)}</span>
</div>
<div class="workflow-summary-pill" style="margin-top:8px">
<strong>timing_sidecar</strong>
<span>${escapeHtml(muhurtaSidecar?.status === 'ok'
? `run_muhurta_panchanga · ${muhurtaSidecar.activity || 'business'} · ${muhurtaSidecar.panchanga?.query_date || '-'}`
: 'not_used')}</span>
</div>
${renderWorkflowSidecarsSummary(workflow)}
</section>
`;
}
function renderWorkflowSidecarsSummary(workflow = {}) {
const muhurta = workflow?.muhurta_panchanga || {};
const prashna = workflow?.prashna || {};
const remedies = workflow?.audited_remedies || {};
const muhurtaSummary = muhurta?.panchanga?.summary || {};
const muhurtaWindows = Array.isArray(muhurta?.best_windows) ? muhurta.best_windows : [];
const topWindow = muhurtaWindows[0] || null;
const prashnaConclusion = prashna?.summary?.conclusion || prashna?.kp_answer_v2?.kp_answer || prashna?.kp_answer?.kp_answer || '';
const prashnaConfidence = prashna?.summary?.confidence || prashna?.kp_answer_v2?.confidence || prashna?.kp_answer?.confidence || '';
const remedyRecommendations = remedies?.raw?.recommendations || remedies?.raw || {};
const remedyCount = ['gems', 'mantras', 'donations', 'fasting', 'lifestyle', 'dosha_remedies']
.reduce((total, key) => total + (Array.isArray(remedyRecommendations?.[key]) ? remedyRecommendations[key].length : 0), 0);
const sidecars = [
`
<div class="provenance-card">
<div class="provenance-head">
<span>Muhurta / Panchanga</span>
<strong>${escapeHtml(muhurta?.status === 'ok' ? (muhurta.activity_label || muhurta.activity || '-') : '未启用')}</strong>
</div>
<p>${escapeHtml(muhurta?.status === 'ok'
? `${muhurta?.panchanga?.query_date || muhurta?.date || '-'} · ${muhurtaSummary?.quality || '待判定'}`
: '当前问题未调用时间择日侧链。')}</p>
<div class="workflow-summary-pill">
<strong>建议窗口</strong>
<span>${escapeHtml(topWindow ? `${topWindow.start || '-'}-${topWindow.end || '-'} · ${topWindow.quality || 'window'}` : '暂无明确吉时窗口')}</span>
</div>
<div class="workflow-summary-pill" style="margin-top:8px">
<strong>避开时段</strong>
<span>${escapeHtml([muhurta?.panchanga?.inauspicious_periods?.rahu_kala, muhurta?.panchanga?.inauspicious_periods?.yamaganda].filter(Boolean).join(' · ') || '未返回')}</span>
</div>
<button class="sidecar-jump-btn" data-open-tab="provenance" data-scroll-target="#provenance-panel" ${muhurta?.status === 'ok' ? '' : 'disabled'}>查看参数/日历证据</button>
</div>
`,
`
<div class="provenance-card">
<div class="provenance-head">
<span>Prashna 问事</span>
<strong>${escapeHtml(prashnaConclusion || '未启用')}</strong>
</div>
<p>${escapeHtml(workflow?.question || prashna?.question || '当前流程没有附带问事问题。')}</p>
<div class="workflow-summary-pill">
<strong>置信度</strong>
<span>${escapeHtml(prashnaConfidence || '未返回')}</span>
</div>
<div class="workflow-summary-pill" style="margin-top:8px">
<strong>问题宫 / 主星</strong>
<span>${escapeHtml(`${prashna?.summary?.primary_house || prashna?.kp_answer_v2?.primary_house || prashna?.kp_answer?.primary_house || '-'}宫 · ${prashna?.summary?.question_lord || prashna?.kp_answer_v2?.question_lord || prashna?.kp_answer?.question_lord || '-'}`)}</span>
</div>
<button class="sidecar-jump-btn" data-open-tab="prashna" data-scroll-target="#prashna-result" ${prashnaConclusion ? '' : 'disabled'}>查看问事细节</button>
</div>
`,
`
<div class="provenance-card">
<div class="provenance-head">
<span>补救建议</span>
<strong>${escapeHtml(remedies?.status === 'ok' ? (remedies?.topic || 'general') : '未启用')}</strong>
</div>
<p>${escapeHtml(remedies?.status === 'ok'
? `来源 ${remedies?.source || 'strict_audit_gate'} · 当前主运 ${remedies?.active_dasha_lord || '-'}`
: '当前流程没有生成严格门控后的补救建议。')}</p>
<div class="workflow-summary-pill">
<strong>行动数量</strong>
<span>${escapeHtml(remedies?.status === 'ok' ? `${remedyCount}` : '0 条')}</span>
</div>
<div class="workflow-summary-pill" style="margin-top:8px">
<strong>门控来源</strong>
<span>${escapeHtml(remedies?.status === 'ok' ? 'strict_audit_gate' : 'not_used')}</span>
</div>
<button class="sidecar-jump-btn" data-open-tab="remedies" data-scroll-target="#remedies-section" ${remedies?.status === 'ok' ? '' : 'disabled'}>查看补救细节</button>
</div>
`,
];
return `<div class="workflow-sidecars-summary provenance-grid">${sidecars.join('')}</div>`;
}
function bindWorkflowSummaryActions(host) {
if (!host || host.dataset.boundWorkflowSidecars === 'true') return;
host.dataset.boundWorkflowSidecars = 'true';
host.addEventListener('click', event => {
const btn = event.target.closest('[data-open-tab]');
if (!btn || btn.disabled) return;
const tabName = btn.dataset.openTab;
const scrollTarget = btn.dataset.scrollTarget;
if (tabName === 'prashna') switchToTab('prashna');
else if (tabName === 'remedies') switchToTab('remedies');
else if (tabName === 'provenance') switchToTab('provenance');
else switchToTab(tabName);
if (scrollTarget) {
setTimeout(() => {
const target = document.querySelector(scrollTarget);
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 120);
}
});
}
function renderWorkflowSummaryPanel(chartRecord) {
const host = $('workflow-summary-panel');
if (!host) return;
const workflow = getConsultationWorkflow(chartRecord);
if (!Object.keys(workflow).length) {
host.innerHTML = '';
return;
}
host.innerHTML = renderWorkflowSummaryCard(workflow);
bindWorkflowSummaryActions(host);
}
// ============================================================================
// 完整解盘 Tab:承接 Skill 全能力路线
// ============================================================================
function renderCompleteReadingTab({ ascendant, moonP, allYogas, dashaData, extraDasas, chartData, validation, audit }) {
const extraDashaCount = Object.keys(extraDasas || {}).length;
const apiDashaCount = chartData?._extended?.dasha_count || chartData?.available_dashas?.length || 0;
const dashaCount = Math.max(apiDashaCount, extraDashaCount, dashaData ? 1 : 0);
renderGuidedTopicDiscovery(chartData);
renderCareerQuickQuestions(chartData);
renderSkillCoverage($('skill-coverage-section'), {
ascendant: ascendant?.sign ? `${ascendant.sign} · ${signName(ascendant.sign)}` : '-',
moonNakshatra: formatMoonNakshatra(moonP) || '-',
yogaCount: allYogas?.length || 0,
dashaCount,
chartData,
birthInfo: chartData?.birth_info || {},
});
const mevg = buildMEVGAudit({ ascendant, moonP, allYogas, dashaData, chartData, validation, audit });
renderMEVGAudit($('mevg-audit-section'), mevg);
}
function getRectificationRecommendedEvents(chartRecord) {
const workflow = getConsultationWorkflow(chartRecord);
const rectification = workflow?.rectification || {};
const recommended = rectification?.summary?.recommended_events;
return Array.isArray(recommended) ? recommended : [];
}
function renderCareerQuickQuestions(chartRecord) {
const host = $('skill-coverage-section');
if (!host) return;
const topic = (((chartRecord?.modules || {}).guided_topics) || []).find(row => row?.id === 'career_direction');
const defaultQuestions = [
'我现在适合换方向还是继续深耕?',
'2026 年事业吉利在哪里,不利在哪里?',
'哪些月份适合推进项目、发布产品或谈合作?',
];
const questions = Array.isArray(topic?.suggested_questions) && topic.suggested_questions.length
? topic.suggested_questions.slice(0, 3)
: defaultQuestions;
const card = document.createElement('section');
card.className = 'career-quick-questions guided-topic-discovery';
card.innerHTML = `
<div class="guided-topic-discovery-head">
<div>
<span>事业互动问答</span>
<strong>事业先看这几个</strong>
</div>
<small>不需要用户自己组织问题,点一下就进入互动问答。</small>
</div>
<div class="career-quick-question-list guided-topic-question-list">
${questions.map(question => `
<button type="button" class="career-quick-question guided-topic-question" data-career-quick-question="${escapeAttr(question)}">
${escapeHtml(question)}
</button>
`).join('')}
</div>
`;
const old = host.querySelector('.career-quick-questions');
if (old) old.remove();
host.prepend(card);
card.querySelectorAll('[data-career-quick-question]').forEach(button => {
button.addEventListener('click', () => {
const question = button.getAttribute('data-career-quick-question') || '';
openAIChatWithPrompt(question);
});
});
}
function renderGuidedTopicDiscovery(chartData) {
const host = $('guided-topic-discovery-panel');
if (!host) return;
const topics = Array.isArray(chartData?.modules?.guided_topics)
? chartData.modules.guided_topics
: Array.isArray(chartData?.ai_prompt_pack?.evidence_snapshot?.guided_topics)
? chartData.ai_prompt_pack.evidence_snapshot.guided_topics
: [];
if (!topics.length) {
host.innerHTML = '';
return;
}
host.innerHTML = `
<section class="guided-topic-discovery">
<div class="guided-topic-discovery-head">
<div>
<span>继续深入</span>
<strong>系统根据真实证据建议的下一步主题</strong>
</div>
<small>不是随机建议,只读取 full-reading 已算出的 guided_topics</small>
</div>
<div class="guided-topic-grid">
${topics.map(topic => renderGuidedTopicCard(topic)).join('')}
</div>
</section>
`;
host.querySelectorAll('[data-guided-topic-question]').forEach(button => {
button.addEventListener('click', () => {
const question = button.getAttribute('data-guided-topic-question') || '';
const topicId = button.getAttribute('data-guided-topic-id') || '';
const topic = topics.find(item => item?.id === topicId) || null;
openAIChatWithPrompt(question, topic);
});
});
}
function renderGuidedTopicCard(topic) {
const evidence = Array.isArray(topic?.evidence) ? topic.evidence : [];
const questions = Array.isArray(topic?.suggested_questions) ? topic.suggested_questions : [];
const vedastro = topic?.vedastro || {};
const strictAdjudicationBundle = topic?.strict_adjudication_bundle || {};
const auditGate = strictAdjudicationBundle?.strict_audit_gate || topic?.strict_audit_gate || {};
const monthly_adjudication_summary = strictAdjudicationBundle?.monthly_adjudication_summary || topic?.monthly_adjudication_summary || {};
const official_day_signal_summary = strictAdjudicationBundle?.official_day_signal_summary || topic?.official_day_signal_summary || {};
const topOfficialDay = official_day_signal_summary?.top_day || null;
const primaryState = monthly_adjudication_summary?.primary_state?.value || null;
const manifestationMode = monthly_adjudication_summary?.manifestation_mode?.value || null;
const frictionSource = monthly_adjudication_summary?.friction_source?.value || null;
const timeConfidence = monthly_adjudication_summary?.time_confidence?.value || null;
const functionalGate = auditGate?.functional_benefic_malefic || {};
const vargaGate = auditGate?.relevant_vargas || {};
const dashaGate = auditGate?.vimshottari_narayana_crosscheck || {};
const confidenceLabel = topic?.confidence === 'high'
? '高'
: topic?.confidence === 'low'
? '低'
: '中';
const statusLabel = vedastro?.status === 'used'
? '已接入'
: vedastro?.status === 'blocked'
? '受阻'
: '未接入';
return `
<article class="guided-topic-card">
<div class="guided-topic-card-head">
<div>
<strong>${escapeHtml(topic?.title || '-')}</strong>
<p>${escapeHtml(topic?.reality_value || '')}</p>
</div>
<span class="guided-topic-confidence">置信度:${escapeHtml(confidenceLabel)}</span>
</div>
<div class="guided-topic-why">${escapeHtml(topic?.why_worth_exploring || '')}</div>
<div class="guided-topic-evidence">
<span class="guided-topic-subtitle">数据依据</span>
${evidence.slice(0, 4).map(row => `
<div class="guided-topic-evidence-row">
<strong>${escapeHtml(row?.label || '-')}</strong>
<span>${escapeHtml(row?.value || '-')}</span>
</div>
`).join('')}
${topOfficialDay ? `
<div class="guided-topic-evidence-row">
<strong>官方日期</strong>
<span>${escapeHtml(`${topOfficialDay.date || '-'} · ${topOfficialDay.summary || '-'} · ${topOfficialDay.confidence || '-'}`)}</span>
</div>
` : ''}
${primaryState ? `
<div class="guided-topic-evidence-row">
<strong>月度主状态</strong>
<span>${escapeHtml(primaryState)}</span>
</div>
` : ''}
${manifestationMode ? `
<div class="guided-topic-evidence-row">
<strong>落地形式</strong>
<span>${escapeHtml(manifestationMode)}</span>
</div>
` : ''}
${frictionSource ? `
<div class="guided-topic-evidence-row">
<strong>阻力来源</strong>
<span>${escapeHtml(frictionSource)}</span>
</div>
` : ''}
${timeConfidence ? `
<div class="guided-topic-evidence-row">
<strong>时间置信度</strong>
<span>${escapeHtml(timeConfidence)}</span>
</div>
` : ''}
</div>
<div class="guided-topic-evidence">
<span class="guided-topic-subtitle">严谨门槛</span>
<div class="guided-topic-evidence-row">
<strong>functional</strong>
<span>${escapeHtml(`${functionalGate.gate || 'none'} / ${functionalGate.used ? 'used' : 'blocked'}`)}</span>
</div>
<div class="guided-topic-evidence-row">
<strong>vargas</strong>
<span>${escapeHtml((vargaGate.present_keys || []).join(' / ') || 'none')}</span>
</div>
<div class="guided-topic-evidence-row">
<strong>dual dasha</strong>
<span>${escapeHtml(`${dashaGate.gate || 'none'} / ${dashaGate.used ? 'used' : 'blocked'}`)}</span>
</div>
</div>
<div class="guided-topic-questions">
<span class="guided-topic-subtitle">适合继续问</span>
<div class="guided-topic-question-list">
${questions.slice(0, 3).map(question => `
<button type="button" class="guided-topic-question" data-guided-topic-id="${escapeAttr(topic?.id || '')}" data-guided-topic-question="${escapeAttr(question)}">
${escapeHtml(question)}
</button>
`).join('')}
</div>
</div>
<div class="guided-topic-footer">
<span>VedAstro${escapeHtml(statusLabel)}</span>
<span>${escapeHtml(topic?.answer_mode || 'tap_or_ask')}</span>
</div>
</article>
`;
}
function renderAIPromptPackPanel(cd) {
const host = $('ai-prompt-pack-panel');
if (!host) return;
const pack = normalizeAIPromptPack(cd);
const evidence = pack.evidence_snapshot || {};
const ayanamsa = evidence.ayanamsa || {};
const core = evidence.core || {};
const timing = evidence.timing || {};
const strength = evidence.strength || {};
const vedastroOfficial = evidence.vedastro_official_full_snapshot || {};
const vedastroOverview = evidence.vedastro_overview || {};
const docs = pack.retrieval_plan?.local_reference_docs || [];
const tags = pack.retrieval_plan?.retrieval_tags || [];
const ranking = Array.isArray(strength.shadbala_ranking) ? strength.shadbala_ranking : [];
host.innerHTML = `
<section class="ai-prompt-pack-panel">
<div class="ai-prompt-pack-head">
<div>
<span>AI Prompt Pack</span>
<strong>${escapeHtml(pack.mode || 'jyotish_structured_prompt_pack')}</strong>
</div>
<div class="ai-prompt-pack-actions">
<button type="button" data-ai-prompt-action="copy-prompt">复制 Prompt</button>
<button type="button" data-ai-prompt-action="copy-evidence">复制 Evidence</button>
<em>schema v${escapeHtml(String(pack.schema_version || 1))}</em>
</div>
</div>
${renderAyanamsaRuntimeStatus(cd, pack)}
<div class="ai-prompt-pack-grid">
<div class="ai-prompt-pack-card">
<span>Ayanamsa</span>
<strong>${escapeHtml(ayanamsa.display || getCalculationSettingLabel('ayanamsa', readCalculationSettings().ayanamsa))}</strong>
<small>${escapeHtml(`node=${ayanamsa.node_mode || 'mean'} · value=${ayanamsa.value ?? '-'}`)}</small>
</div>
<div class="ai-prompt-pack-card">
<span>Lagna / Moon</span>
<strong>${escapeHtml(core.ascendant?.sign || cd?.ascendant?.sign || '-')} / ${escapeHtml(core.Moon?.sign || cd?.planets?.Moon?.sign || '-')}</strong>
<small>D1 evidence snapshot</small>
</div>
<div class="ai-prompt-pack-card">
<span>Dasha</span>
<strong>${escapeHtml(timing.current_mahadasha || cd?.dasha?.current_md || '-')}</strong>
<small>${escapeHtml(timing.current_antardasha ? `AD ${timing.current_antardasha}` : timing.start_date || '')}</small>
</div>
${renderVedAstroOfficialSnapshotPromptCard(vedastroOfficial)}
${renderVedAstroOverviewPromptCard(vedastroOverview)}
</div>
<div class="ai-prompt-pack-body">
<div>
<h4>Prompt</h4>
<pre>${escapeHtml(pack.prompt_zh || '')}</pre>
</div>
<div>
<h4>Evidence</h4>
<div class="ai-prompt-pack-evidence">
${['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'].map(planet => {
const pdata = core[planet] || cd?.planets?.[planet] || {};
return `<span>${escapeHtml(planet)} · ${escapeHtml(pdata.sign || '-')} ${escapeHtml(formatCompactDegree(pdata.degree))}</span>`;
}).join('')}
</div>
${ranking.length ? `
<div class="ai-prompt-pack-strength">
${ranking.slice(0, 5).map(item => `<span>${escapeHtml(item.planet || '-')} ${escapeHtml(String(item.total_rupas ?? item.rupas ?? '-'))}</span>`).join('')}
</div>
` : ''}
${renderStrictWorkflowContractPanel(vedastroOfficial)}
${renderInterpretationSourceGovernancePanel(evidence)}
</div>
</div>
<div class="ai-prompt-pack-foot">
<div>
<strong>Retrieval</strong>
${docs.slice(0, 6).map(doc => `<span>${escapeHtml(doc)}</span>`).join('')}
</div>
<div>
<strong>Boundary</strong>
${(tags.length ? tags : ['oracle_boundary_visible', 'confidence_labeled_reading']).map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}
</div>
</div>
</section>
`;
setupAIPromptPackActions(host, cd, pack);
}
function renderVedAstroOfficialSnapshotPromptCard(snapshot = {}) {
if (!snapshot || typeof snapshot !== 'object') return '';
const status = snapshot.status || 'blocked';
const pythonPath = snapshot.official_python_path || snapshot.primary_source || 'vedastro_official';
const bundleStatus = snapshot.official_bundle_status || 'blocked';
const chartState = snapshot.official_chart_available ? 'official chart ready' : 'official chart partial';
const catalogStatus = snapshot.official_full_capability_catalog_status || 'blocked';
const catalogSummary = snapshot.official_full_capability_catalog_summary || {};
const catalogHint = catalogSummary.catalog_method_count
? ` · catalog=${catalogStatus} ${catalogSummary.executed_method_count || 0}/${catalogSummary.catalog_method_count}`
: '';
const sectionHint = Array.isArray(snapshot.official_primary_sections_ok)
? snapshot.official_primary_sections_ok.slice(0, 4).join(', ')
: '';
const contractRoutes = Array.isArray(snapshot.strict_workflow_routes_available)
? snapshot.strict_workflow_routes_available
: [];
const primaryRoute = snapshot.strict_workflow_primary_route || contractRoutes[0] || '-';
const contractHint = contractRoutes.length
? ` · contract=${primaryRoute} [${contractRoutes.join('/')}]`
: '';
return `
<div class="ai-prompt-pack-card">
<span>VedAstro Official Snapshot</span>
<strong>${escapeHtml(status)}</strong>
<small>${escapeHtml(`${pythonPath} · bundle=${bundleStatus} · ${chartState}${catalogHint}${contractHint}${sectionHint ? ` · ${sectionHint}` : ''}`)}</small>
</div>
`;
}
function renderStrictWorkflowContractPanel(snapshot = {}) {
if (!snapshot || typeof snapshot !== 'object') return '';
const contracts = snapshot.strict_workflow_contracts;
if (!contracts || typeof contracts !== 'object' || !Object.keys(contracts).length) return '';
const primaryRoute = snapshot.strict_workflow_primary_route || Object.keys(contracts)[0];
return `
<div class="ai-prompt-pack-contracts">
<h4>Official-first Strict Contract</h4>
<div class="ai-prompt-pack-contract-list">
${Object.entries(contracts).map(([route, contract]) => renderStrictWorkflowContractCard(route, contract, route === primaryRoute)).join('')}
</div>
</div>
`;
}
function renderStrictWorkflowContractCard(route, contract = {}, isPrimary = false) {
const official = contract.official_primary_evidence || {};
const local = contract.local_supplemental_evidence || {};
const adjudicationStages = contract.adjudication_stages || {};
const multiReferenceSummary = contract.multi_reference_reading_summary || {};
const monthlyAdjudicationSummary = contract.monthly_adjudication_summary || {};
const techniqueAuditSummary = contract.technique_audit_summary || {};
const promiseStatus = adjudicationStages.promise?.status || 'missing';
const activationStatus = adjudicationStages.activation?.status || 'missing';
const manifestationStatus = adjudicationStages.manifestation?.status || 'missing';
const labelValue = adjudicationStages.label?.value || contract.dominant_label || '—';
const monthlyPrimaryState = monthlyAdjudicationSummary.primary_state?.value || '—';
const monthlyManifestation = monthlyAdjudicationSummary.manifestation_mode?.value || '—';
const monthlyFriction = monthlyAdjudicationSummary.friction_source?.value || '—';
const monthlyTiming = monthlyAdjudicationSummary.time_confidence?.value || '—';
const rootFrameKeys = Object.keys(multiReferenceSummary.root_frame || {});
const modifierFrameKeys = Object.keys(multiReferenceSummary.modifier_frame || {});
const auditKeys = Object.keys(techniqueAuditSummary || {});
const auditFunctional = techniqueAuditSummary.functional_benefic_malefic || {};
const auditVarga = techniqueAuditSummary.relevant_vargas || {};
const auditDasha = techniqueAuditSummary.vimshottari_narayana_crosscheck || {};
const officialStatuses = Object.entries(official)
.map(([key, value]) => `${key}:${value?.status || 'blocked'}`)
.join(' · ');
const localStatuses = Object.entries(local)
.map(([key, value]) => `${key}:${value?.present ? 'present' : 'missing'}`)
.join(' · ');
const fallback = Array.isArray(contract.fallback_used) && contract.fallback_used.length
? contract.fallback_used.join(' / ')
: 'none';
const blocked = Array.isArray(contract.blocked_items) && contract.blocked_items.length
? contract.blocked_items.join(' / ')
: 'none';
const conflicts = Array.isArray(contract.conflicts)
? contract.conflicts.map(item => item?.type).filter(Boolean)
: [];
const mainConflicts = Array.isArray(contract.main_conflicts)
? contract.main_conflicts.map(item => item?.type).filter(Boolean)
: [];
return `
<article class="ai-prompt-pack-contract-card${isPrimary ? ' is-primary' : ''}">
<div class="ai-prompt-pack-contract-head">
<strong>${escapeHtml(route)}</strong>
<span>${escapeHtml(isPrimary ? 'primary route' : (contract.confidence_cap || 'supplemental route'))}</span>
</div>
<div class="ai-prompt-pack-contract-grid">
<span>official: ${escapeHtml(officialStatuses || '—')}</span>
<span>local: ${escapeHtml(localStatuses || '—')}</span>
<span>fallback: ${escapeHtml(fallback)}</span>
<span>blocked: ${escapeHtml(blocked)}</span>
<span>conflicts: ${escapeHtml(conflicts.length ? conflicts.join(' / ') : 'none')}</span>
</div>
<div class="ai-prompt-pack-contract-grid">
<span>Top-reader adjudication: promise=${escapeHtml(promiseStatus)} · activation=${escapeHtml(activationStatus)} · manifestation=${escapeHtml(manifestationStatus)}</span>
<span>label: ${escapeHtml(labelValue)}</span>
<span>monthly_adjudication_summary.primary_state: ${escapeHtml(monthlyPrimaryState)}</span>
<span>monthly_adjudication_summary.manifestation_mode: ${escapeHtml(monthlyManifestation)}</span>
<span>monthly_adjudication_summary.friction_source: ${escapeHtml(monthlyFriction)}</span>
<span>monthly_adjudication_summary.time_confidence: ${escapeHtml(monthlyTiming)}</span>
</div>
<div class="ai-prompt-pack-contract-grid">
<span>technique_audit_summary: ${escapeHtml(auditKeys.length ? auditKeys.join(' / ') : 'none')}</span>
<span>functional gate: ${escapeHtml(`${auditFunctional.gate || 'none'} / ${auditFunctional.used ? 'used' : 'blocked'}`)}</span>
<span>varga gate: ${escapeHtml(`${auditVarga.gate || 'none'} / ${(auditVarga.present_keys || []).join(' / ') || 'none'}`)}</span>
<span>dual dasha gate: ${escapeHtml(`${auditDasha.gate || 'none'} / ${auditDasha.used ? 'used' : 'blocked'}`)}</span>
</div>
<div class="ai-prompt-pack-contract-grid">
<span>multi_reference_reading_summary.root_frame: ${escapeHtml(rootFrameKeys.length ? rootFrameKeys.join(' / ') : 'none')}</span>
<span>multi_reference_reading_summary.modifier_frame: ${escapeHtml(modifierFrameKeys.length ? modifierFrameKeys.join(' / ') : 'none')}</span>
<span>main_conflicts: ${escapeHtml(mainConflicts.length ? mainConflicts.join(' / ') : 'none')}</span>
</div>
<small>${escapeHtml(contract.reason || '')}</small>
</article>
`;
}
function renderInterpretationSourceGovernancePanel(evidence = {}) {
const sourcePack = evidence.interpretation_source_pack || {};
const boundary = evidence.prediction_boundary_contract || {};
const mevg = evidence.mevg_collection_queue || {};
const cases = evidence.real_case_calibration_layer || {};
const debt = evidence.technical_debt_contract || {};
const remaining = evidence.remaining_priority1_batch_queue || {};
const coreRefs = sourcePack.core_rule_source_refs || [];
const referenceOnly = sourcePack.reference_only_source_refs || [];
const blockedPolicy = boundary.confidence_boundary?.unverified_claim_policy || 'downgrade_or_block';
return `
<div class="ai-prompt-pack-contracts">
<h4>Source Governance</h4>
<div class="ai-prompt-pack-contract-grid">
<span>core source refs: ${escapeHtml(coreRefs.length ? String(coreRefs.length) : 'none')}</span>
<span>core sources: ${escapeHtml(coreRefs.length ? String(coreRefs.length) : 'none')}</span>
<span>reference-only: ${escapeHtml(referenceOnly.length ? String(referenceOnly.length) : 'none')}</span>
<span>blocked non-runtime: duplicate / obsolete / quarantine</span>
<span>why confidence downgraded: ${escapeHtml(blockedPolicy)}</span>
<span>confidence downgrade: ${escapeHtml(blockedPolicy)}</span>
<span>MEVG queue: ${escapeHtml(mevg.status || 'blocked')}</span>
<span>case calibration: ${escapeHtml(cases.status || 'blocked')}</span>
<span>Narayana debt: ${escapeHtml(debt.narayana?.status || 'unknown')}</span>
<span>Tajika debt: ${escapeHtml(debt.tajika?.status || 'unknown')}</span>
<span>next batch queue: ${escapeHtml((remaining.next_batches || []).join(' / ') || 'none')}</span>
<span>oracle parity: ${escapeHtml((evidence.oracle_parity_queue?.priority_domains || []).join(' / ') || 'blocked')}</span>
<span>next batches: ${escapeHtml((remaining.next_batches || []).join(' / ') || 'none')}</span>
</div>
</div>
`;
}
function renderVedAstroOverviewPromptCard(overview = {}) {
if (!overview || typeof overview !== 'object') return '';
const status = overview.status || 'blocked';
const label = overview.top_events_by_domain?.marriage?.signal_label
|| overview.top_events_by_domain?.career?.signal_label
|| overview.top_events_by_domain?.wealth?.signal_label
|| overview.source
|| '-';
return `
<div class="ai-prompt-pack-card">
<span>VedAstro Overview</span>
<strong>${escapeHtml(status)}</strong>
<small>${escapeHtml(`${label} · ${overview.search_scope || 'single_day_overview'} · overview only,不替代长周期精扫`)}</small>
</div>
`;
}
function renderAyanamsaRuntimeStatus(cd, pack) {
const settings = readCalculationSettings();
const boundary = cd?._calculation_boundary || {};
const evidenceAyanamsa = pack?.evidence_snapshot?.ayanamsa || {};
const requested = evidenceAyanamsa.name || settings.ayanamsa;
const applied = boundary.appliedAyanamsa || evidenceAyanamsa.name || requested;
const status = cd?._fallback ? 'browser_fallback' : 'backend_computed';
const label = status === 'backend_computed' ? '后端实算' : '浏览器降级';
const note = boundary.note || (
status === 'backend_computed'
? '当前 Ayanamsa 来自本地 API 服务返回,可随导出和 AI Prompt Pack 追溯。'
: '浏览器 fallback 只能使用 Lahiri 兼容路径;Raman/KP 等需启动本地 API 服务。'
);
return `
<div class="ayanamsa-runtime-status ayanamsa-runtime-status-${escapeAttr(status)}">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(getCalculationSettingLabel('ayanamsa', requested))}</strong>
<small>${escapeHtml(`applied=${applied} · status=${status}`)}</small>
<em>${escapeHtml(note)}</em>
</div>
`;
}
function setupAIPromptPackActions(host, cd, pack) {
host.querySelectorAll('[data-ai-prompt-action]').forEach(button => {
button.addEventListener('click', async () => {
const action = button.getAttribute('data-ai-prompt-action');
const copied = await copyAIPromptPackSection(action, cd, pack);
button.textContent = copied ? '已复制' : '复制失败';
setTimeout(() => {
button.textContent = action === 'copy-evidence' ? '复制 Evidence' : '复制 Prompt';
}, 1400);
});
});
}
async function copyAIPromptPackSection(action, cd, pack) {
const payload = action === 'copy-evidence'
? {
evidence_snapshot: pack?.evidence_snapshot || {},
retrieval_plan: pack?.retrieval_plan || {},
calculation_boundary: cd?._calculation_boundary || null,
}
: {
prompt_zh: pack?.prompt_zh || '',
mode: pack?.mode || 'jyotish_structured_prompt_pack',
schema_version: pack?.schema_version || 1,
};
const text = [
'AI Prompt Pack 审计上下文已复制',
JSON.stringify(payload, null, 2),
].join('\n');
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', 'true');
area.style.position = 'fixed';
area.style.left = '-9999px';
document.body.appendChild(area);
area.select();
const ok = document.execCommand('copy');
document.body.removeChild(area);
return ok;
} catch {
return false;
}
}
function normalizeAIPromptPack(cd = {}) {
if (cd?.ai_prompt_pack?.evidence_snapshot) return cd.ai_prompt_pack;
const settings = normalizeCalculationSettings(cd?._calculation_settings || readCalculationSettings());
const birth = cd.birth || cd.birth_info || {};
const planets = cd.planets || {};
return {
schema_version: 1,
mode: 'jyotish_structured_prompt_pack',
prompt_zh: [
'你是一个审慎的 AI Native 印度/吠陀占星分析助手。',
'请只基于 evidence_snapshot 中的计算证据生成解读,不要编造星盘不存在的配置。',
`本盘使用 ${getCalculationSettingLabel('ayanamsa', settings.ayanamsa)} ayanamsa节点口径为 ${getCalculationSettingLabel('nodeMode', settings.nodeMode)}`,
'不要仅凭单一配置下结论;核心判断至少交叉 D1、D9、Dasha、Shadbala/Ashtakavarga 或 Transit 中的两个证据层。',
].join('\n'),
evidence_snapshot: {
birth,
ayanamsa: {
name: settings.ayanamsa,
display: getCalculationSettingLabel('ayanamsa', settings.ayanamsa),
value: birth.ayanamsa ?? cd.ayanamsa,
node_mode: settings.nodeMode,
},
core: {
ascendant: cd.ascendant || {},
...Object.fromEntries(Object.entries(planets).filter(([planet]) => ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'].includes(planet))),
},
timing: {
current_mahadasha: cd.dasha?.current_md,
remaining_years: cd.dasha?.remaining_years,
start_date: cd.dasha?.start_date,
},
strength: {
shadbala_ranking: Object.entries(cd.shadbala || {}).map(([planet, pdata]) => ({ planet, rupas: pdata?.rupas, level: pdata?.level })),
},
oracle_progress: oracleProgressSnapshot(),
},
retrieval_plan: {
local_reference_docs: [
'references/ai-reading-workflow-prompt.md',
'references/comprehensive-reading-workflow.md',
'references/prediction-boundary-protocol.md',
],
retrieval_tags: ['no_single_factor_conclusion', 'oracle_boundary_visible', 'confidence_labeled_reading'],
},
};
}
function oracleProgressSnapshot() {
return {
scope: 'external_oracle_evidence_validation',
collection_queue: 'external_oracle_collection_queue',
total_packets: 5,
valid_packets: 0,
ready_for_calibration: 0,
production_tuning_allowed: false,
artifact_policy: 'references/oracle/artifacts/',
promotion_rule: 'external_verified requires source_artifact, filled target values, and non-local-engine external evidence.',
boundary: 'Dasha/Shadbala absolute values are not externally calibrated until enough packets pass validation.',
};
}
function formatCompactDegree(value) {
const num = Number(value);
return Number.isFinite(num) ? `${num.toFixed(2)}°` : '-';
}
function readCalculationSettings() {
try {
const saved = JSON.parse(localStorage.getItem(CALCULATION_SETTINGS_KEY) || '{}');
if (!saved.terminologyMode) saved.terminologyMode = localStorage.getItem(TERMINOLOGY_MODE_KEY);
return normalizeCalculationSettings(saved);
} catch {
return { ...DEFAULT_CALCULATION_SETTINGS };
}
}
function writeCalculationSettings(settings) {
const normalized = normalizeCalculationSettings(settings);
localStorage.setItem(CALCULATION_SETTINGS_KEY, JSON.stringify(normalized));
localStorage.setItem(TERMINOLOGY_MODE_KEY, normalized.terminologyMode);
setGlossaryTerminologyMode(normalized.terminologyMode);
if (chartData) chartData._terminology_mode = normalized.terminologyMode;
return normalized;
}
function normalizeCalculationSettings(settings = {}) {
const normalized = { ...DEFAULT_CALCULATION_SETTINGS };
Object.keys(DEFAULT_CALCULATION_SETTINGS).forEach(key => {
const allowed = (CALCULATION_SETTING_OPTIONS[key] || []).map(([value]) => value);
if (allowed.includes(settings[key])) normalized[key] = settings[key];
});
return normalized;
}
function getCalculationSettingLabel(key, value) {
const found = (CALCULATION_SETTING_OPTIONS[key] || []).find(([option]) => option === value);
return found ? found[1] : value || '-';
}
function applyCalculationSettingsToPayload(payload) {
const settings = readCalculationSettings();
return {
...payload,
ayanamsa: settings.ayanamsa,
node_mode: settings.nodeMode,
house_system: settings.houseSystem,
sunrise_policy: settings.sunrisePolicy,
geocoder_policy: settings.geocoderPolicy,
ephemeris_backend: settings.ephemerisBackend,
terminology_mode: settings.terminologyMode,
yoga_variant: settings.yogaVariant,
jaimini_karaka_variant: settings.jaiminiKarakaVariant,
kp_significator_variant: settings.kpSignificatorVariant,
ashtakavarga_variant: settings.ashtakavargaVariant,
shadbala_variant: settings.shadbalaVariant,
dasha_reference: settings.dashaReference,
};
}
function attachCalculationSettings(chart, settings = readCalculationSettings()) {
if (!chart) return chart;
chart._calculation_settings = normalizeCalculationSettings(settings);
chart._terminology_mode = readTerminologyMode();
return chart;
}
function buildCalculationProvenance(chartData, panchanga, context = {}) {
const birth = chartData?.birth_info || chartData?.birth || {};
const settings = normalizeCalculationSettings(chartData?._calculation_settings || readCalculationSettings());
const terminologyMode = readTerminologyMode(chartData?._terminology_mode);
const terminology = getTerminologyModeOption(terminologyMode);
const ayanamsaValue = birth.ayanamsa ?? chartData?.birth?.ayanamsa ?? chartData?.ayanamsa;
const ayanamsaLabel = birth.ayanamsa_display || chartData?.birth?.ayanamsa_display || getCalculationSettingLabel('ayanamsa', settings.ayanamsa);
const ayanamsa = ayanamsaValue != null
? `${ayanamsaLabel} (${Number(ayanamsaValue).toFixed(4)} deg)`
: ayanamsaLabel;
const hasPython = chartData?.success && !chartData?._fallback;
return {
engine: hasPython ? `本地 API 服务 ${chartData?.version || ''}`.trim() : 'Browser SwissEph fallback',
ephemeris: hasPython ? 'Swiss Ephemeris via local API' : 'SwissEph WASM/browser',
ephemerisBackend: getCalculationSettingLabel('ephemerisBackend', settings.ephemerisBackend),
terminologyMode: getCalculationSettingLabel('terminologyMode', settings.terminologyMode),
ayanamsa,
nodeMode: `${birth.node_mode || getCalculationSettingLabel('nodeMode', settings.nodeMode)}Ketu derived 180 deg opposite`,
houseSystem: `${getCalculationSettingLabel('houseSystem', settings.houseSystem)}Bhava Chalit 可在专项接口复核`,
sunrisePolicy: getCalculationSettingLabel('sunrisePolicy', settings.sunrisePolicy),
geocoderPolicy: getCalculationSettingLabel('geocoderPolicy', settings.geocoderPolicy),
yogaVariant: getCalculationSettingLabel('yogaVariant', settings.yogaVariant),
jaiminiKarakaVariant: getCalculationSettingLabel('jaiminiKarakaVariant', settings.jaiminiKarakaVariant),
kpSignificatorVariant: getCalculationSettingLabel('kpSignificatorVariant', settings.kpSignificatorVariant),
ashtakavargaVariant: getCalculationSettingLabel('ashtakavargaVariant', settings.ashtakavargaVariant),
shadbalaVariant: getCalculationSettingLabel('shadbalaVariant', settings.shadbalaVariant),
dashaReference: getCalculationSettingLabel('dashaReference', settings.dashaReference),
terminologyMode,
terminologyLabel: terminology.label,
terminologyNote: terminology.note,
calculationSettings: settings,
ruleVariantStatus: 'Rule variants are saved/exported as interpretive policy; non-current variants are staged until each engine path supports live switching.',
calculationSettingsStatus: 'Settings are saved and exported; ayanamsa and node mode are live API parameters. Rule variants remain interpretive policy unless the target endpoint returns live variant metadata.',
chartStyle: context.chartStyle === 'north' ? 'North Indian' : 'South Indian',
timezone: birth.tz || 'UTC+8',
coordinates: `${safeNumber(birth.lat ?? 0).toFixed(4)}, ${safeNumber(birth.lon ?? 0).toFixed(4)}`,
julianDay: birth.julian_day || chartData?.birth?.julian_day || '',
panchangaSource: panchanga ? 'Birth-time Sun/Moon longitudes in current chart' : 'Unavailable',
exportStatus: 'HTML/JSON/SVG/PNG export available; backend PDF pipeline is queued in product gap matrix',
savedChartStatus: readSavedChartStatus(chartData),
};
}
function readSavedChartStatus(cd) {
const lib = readChartLibrary();
const saved = Boolean(findChartLibraryEntry(lib, cd));
return {
count: lib.length,
currentSaved: saved,
label: saved ? '当前星盘已在本地星盘库' : '当前星盘尚未保存到本地星盘库',
};
}
function readChartLibrary() {
try {
const lib = JSON.parse(localStorage.getItem(CHART_LIBRARY_KEY) || '[]');
return Array.isArray(lib) ? lib : [];
} catch {
return [];
}
}
function writeChartLibrary(lib) {
localStorage.setItem(CHART_LIBRARY_KEY, JSON.stringify(Array.isArray(lib) ? lib : []));
}
function readSynastryPairLibrary() {
try {
const lib = JSON.parse(localStorage.getItem(SYNASTRY_PAIR_LIBRARY_KEY) || '[]');
return Array.isArray(lib) ? lib : [];
} catch {
return [];
}
}
function writeSynastryPairLibrary(lib) {
localStorage.setItem(SYNASTRY_PAIR_LIBRARY_KEY, JSON.stringify(Array.isArray(lib) ? lib : []));
}
function readPrashnaCaseLibrary() {
try {
const lib = JSON.parse(localStorage.getItem(PRASHNA_CASE_LIBRARY_KEY) || '[]');
return Array.isArray(lib) ? lib : [];
} catch {
return [];
}
}
function writePrashnaCaseLibrary(lib) {
localStorage.setItem(PRASHNA_CASE_LIBRARY_KEY, JSON.stringify(Array.isArray(lib) ? lib : []));
}
function sortChartLibrary(lib) {
return [...(Array.isArray(lib) ? lib : [])].sort((a, b) => {
const aTime = Date.parse(a?.updatedAt || a?.savedAt || '') || 0;
const bTime = Date.parse(b?.updatedAt || b?.savedAt || '') || 0;
return bTime - aTime;
});
}
function sortCaseLibrary(lib) {
return [...(Array.isArray(lib) ? lib : [])].sort((a, b) => {
const aTime = Date.parse(a?.updatedAt || a?.generatedAt || a?.savedAt || '') || 0;
const bTime = Date.parse(b?.updatedAt || b?.generatedAt || b?.savedAt || '') || 0;
return bTime - aTime;
});
}
function buildWorkspaceChartId(cd) {
const birth = cd?.birth_info || {};
if (!birth.date) return '';
return [
birth.date,
normalizeBirthIdPart(birth.time || '00:00'),
normalizeBirthIdPart(birth.lat),
normalizeBirthIdPart(birth.lon),
normalizeBirthIdPart(birth.tz),
].join('_');
}
function buildLegacyWorkspaceChartId(cd) {
const birth = cd?.birth_info || {};
if (!birth.date) return '';
return [
birth.date,
normalizeBirthIdPart(birth.lat),
normalizeBirthIdPart(birth.lon),
normalizeBirthIdPart(birth.tz),
].join('_');
}
function normalizeBirthIdPart(value) {
return String(value ?? '').trim().replace(/\s+/g, '');
}
function findChartLibraryEntry(lib, cd) {
const id = buildWorkspaceChartId(cd);
const legacyId = buildLegacyWorkspaceChartId(cd);
return (Array.isArray(lib) ? lib : []).find(entry => entry?.id === id || entry?.id === legacyId) || null;
}
function buildWorkspaceChartLabel(cd) {
const birth = cd?.birth_info || {};
const asc = cd?.ascendant || {};
const date = birth.date || '-';
const time = birth.time || '';
const ascLabel = asc.sign ? `${signName(asc.sign)} rising` : 'chart';
return `${date} ${time} · ${ascLabel}`;
}
function buildDefaultChartWorkspaceMeta(cd) {
const birth = cd?.birth_info || cd?.birth || {};
const named = String(birth.name || birth.person_name || '').trim();
return {
group: named ? '家庭' : '未分组',
relation: 'self',
tags: ['chart'],
};
}
function normalizeWorkspaceMeta(record, fallback = {}) {
const rawTags = Array.isArray(record?.tags)
? record.tags
: String(record?.tags || '').split(',').map(tag => tag.trim()).filter(Boolean);
return {
group: record?.group || fallback.group || '未分组',
relation: record?.relation || fallback.relation || 'unknown',
tags: rawTags.length ? rawTags : (fallback.tags || []),
};
}
function getCaseDefaultMeta(kind) {
if (kind === 'pair') return { group: '伴侣', relation: 'partner', tags: ['synastry', 'relationship'] };
if (kind === 'prashna') return { group: '研究', relation: 'research', tags: ['prashna'] };
return { group: '未分组', relation: 'self', tags: ['chart'] };
}
function getCaseGroupLabel(record, kind) {
return normalizeWorkspaceMeta(record, getCaseDefaultMeta(kind)).group;
}
function getCaseRelationValue(record, kind) {
return normalizeWorkspaceMeta(record, getCaseDefaultMeta(kind)).relation;
}
function getCaseRelationLabel(record, kind) {
const value = getCaseRelationValue(record, kind);
return CASE_RELATION_PRESETS.find(([key]) => key === value)?.[1] || '未标注';
}
function renderCaseMetaLine(record, kind) {
const meta = normalizeWorkspaceMeta(record, getCaseDefaultMeta(kind));
return `
<div class="case-meta-line">
<span>${escapeHtml(meta.group)}</span>
<span>${escapeHtml(getCaseRelationLabel(meta, kind))}</span>
${meta.tags.slice(0, 3).map(tag => `<em>${escapeHtml(tag)}</em>`).join('')}
</div>
`;
}
function formatWorkspaceCaseTitle(record, kind) {
return `${record?.label || record?.id || '未命名'} · ${getCaseGroupLabel(record, kind)}`;
}
function saveCurrentChartToLibrary() {
if (!chartData) return false;
const id = buildWorkspaceChartId(chartData);
if (!id) return false;
const now = new Date().toISOString();
const lib = readChartLibrary();
const existing = findChartLibraryEntry(lib, chartData);
if (existing) {
const meta = normalizeWorkspaceMeta(existing, buildDefaultChartWorkspaceMeta(chartData));
existing.id = id;
existing.data = chartData;
existing.label = existing.label || buildWorkspaceChartLabel(chartData);
existing.group = meta.group;
existing.relation = meta.relation;
existing.tags = meta.tags;
existing.updatedAt = now;
} else {
const meta = buildDefaultChartWorkspaceMeta(chartData);
lib.push({
id,
label: buildWorkspaceChartLabel(chartData),
data: chartData,
...meta,
savedAt: now,
updatedAt: now,
});
}
writeChartLibrary(sortChartLibrary(lib).slice(0, 24));
updateSaveChartButton();
renderSavedChartPanel();
return true;
}
function updateSaveChartButton() {
const btn = $('btn-save-chart');
if (!btn) return;
if (!chartData) {
btn.disabled = true;
btn.textContent = '保存星盘';
return;
}
const saved = readSavedChartStatus(chartData);
btn.disabled = false;
btn.textContent = saved.currentSaved ? '已保存' : '保存星盘';
btn.classList.toggle('is-saved', Boolean(saved.currentSaved));
}
function setupSavedChartPanel() {
const saveBtn = $('btn-save-chart');
if (saveBtn && !saveBtn.dataset.bound) {
saveBtn.dataset.bound = 'true';
saveBtn.addEventListener('click', () => {
if (!saveCurrentChartToLibrary()) return;
saveBtn.textContent = '已保存';
setTimeout(updateSaveChartButton, 1200);
if ($('provenance-panel')) renderAll();
});
}
renderSavedChartPanel();
}
function renderSavedChartPanel() {
const panel = $('saved-chart-panel');
if (!panel) return;
const lib = sortChartLibrary(readChartLibrary());
if (!lib.length) {
panel.innerHTML = `
<div class="saved-chart-empty">
<strong>本地星盘库</strong>
<span>先用示例盘导入资料或手动输入生成星盘保存后可一键打开</span>
</div>
`;
return;
}
panel.innerHTML = `
<div class="saved-chart-head">
<strong>本地星盘库</strong>
<span>${escapeHtml(String(lib.length))} 个星盘</span>
</div>
<div class="saved-chart-list">
${lib.slice(0, 5).map(entry => `
<button type="button" class="saved-chart-item" data-open-saved-chart="${escapeAttr(entry.id || '')}">
<span>${escapeHtml(entry.label || entry.id || '未命名星盘')}</span>
<small>${escapeHtml(formatSavedAt(entry.updatedAt || entry.savedAt))}</small>
</button>
`).join('')}
</div>
`;
panel.querySelectorAll('[data-open-saved-chart]').forEach(button => {
button.addEventListener('click', () => openSavedChartFromPanel(button.dataset.openSavedChart));
});
}
function openSavedChartFromPanel(id) {
const entry = readChartLibrary().find(item => item?.id === id);
if (!entry?.data) return;
chartData = entry.data;
window.__jyotishBirth = normalizeSavedBirth(entry.data);
renderAll();
showPage('chart');
aiChatSetChartData(chartData);
}
function normalizeSavedBirth(cd) {
const birth = cd?.birth_info || cd?.birth || {};
const [year, month, day] = String(birth.date || '').split('-').map(Number);
const [hour, minute, secondRaw] = String(birth.time || '').split(':').map(Number);
const second = Number.isFinite(secondRaw) ? secondRaw : Number(birth.second) || 0;
return {
year: year || 2000,
month: month || 1,
day: day || 1,
hour: hour || 12,
minute: minute || 0,
second,
lat: Number(birth.lat) || 0,
lon: Number(birth.lon) || 0,
tz: resolveTimezoneValue(birth.tz),
};
}
function renderProvenancePanel({ chartData, panchanga, provenance, validation, audit, allYogas, dashaData, extraDasas }) {
const container = $('provenance-panel');
if (!container) return;
container._panchangaChartData = chartData;
const birth = chartData?.birth_info || {};
const dashaCount = Math.max(Object.keys(extraDasas || {}).length, dashaData ? 1 : 0, chartData?.available_dashas?.length || 0);
const saved = provenance.savedChartStatus || {};
const chartLibrary = readChartLibrary();
const pairLibrary = sortCaseLibrary(readSynastryPairLibrary());
const prashnaLibrary = sortCaseLibrary(readPrashnaCaseLibrary());
const panchangaRows = buildPanchangaRows(panchanga);
const tithiLord = chartData?.tithi_lord_analysis || {};
const auditRows = [
['核心能力', '65/65 registry 已产品化', '由 capability audit 与碎片审计持续守门'],
['Yoga 数量', `${allYogas?.length || 0} `, '后续进入规则搜索/流派变体模式'],
['Dasha 覆盖', `${dashaCount} /层级入口`, '后续强化事件时间线与候选窗口'],
['校验状态', validation?.status || audit?.overall || '已生成', '导出 JSON 会携带 validation/audit 模块'],
];
const roadmap = [
['当前完成', 'PWA / Trust Center / 术语模式', '安装入口、本地数据边界、入门/专业/梵文术语偏好已进入产品面'],
['下一步 P1', '桌面包装', 'PWA 作为当前交付;Pake 适合快速 URL 壳;Tauri 留给本地 API 服务 sidecar 与签名策略'],
['下一步 P1', '星历抽象', '评估 SwissEph、VedAstro、Xalen 等底座替换边界,避免算法假切换'],
['守门检查', 'Packaging preflight', '运行 scripts/desktop_packaging_preflight.py 检查 manifest、service worker、loopback API 与 Trust Center'],
];
const workflowProvenance = renderWorkflowProvenancePanel(chartData);
container.innerHTML = `
<div class="provenance-grid">
${workflowProvenance}
<section class="provenance-card provenance-card-wide">
<div class="provenance-head">
<span>Calculation Provenance</span>
<strong>${escapeHtml(provenance.engine)}</strong>
</div>
<div class="provenance-kv-grid">
${renderKV('出生时间', `${birth.date || '-'} ${birth.time || ''} ${birth.tz || ''}`)}
${renderKV('坐标', provenance.coordinates)}
${renderKV('Ayanamsa', provenance.ayanamsa)}
${renderKV('星历', provenance.ephemeris)}
${renderKV('星历底座', provenance.ephemerisBackend)}
${renderKV('术语模式', provenance.terminologyMode)}
${renderKV('宫位策略', provenance.houseSystem)}
${renderKV('节点策略', provenance.nodeMode)}
${renderKV('日出策略', provenance.sunrisePolicy)}
${renderKV('地理策略', provenance.geocoderPolicy)}
${renderKV('Yoga 口径', provenance.yogaVariant)}
${renderKV('Jaimini 口径', provenance.jaiminiKarakaVariant)}
${renderKV('KP 口径', provenance.kpSignificatorVariant)}
${renderKV('AV 口径', provenance.ashtakavargaVariant)}
${renderKV('Julian Day', provenance.julianDay || '-')}
${renderKV('当前盘式', provenance.chartStyle)}
</div>
${renderCalculationSettingsPanel(provenance.calculationSettings)}
</section>
<section class="provenance-card">
<div class="provenance-head">
<span>Panchanga Preview</span>
<strong>${escapeHtml(panchanga?.tithi?.paksha || 'Birth-time')}</strong>
</div>
<div class="provenance-list">
${panchangaRows.map(row => `
<div class="provenance-list-row">
<span>${escapeHtml(row[0])}</span>
<strong>${escapeHtml(row[1])}</strong>
<small>${escapeHtml(row[2])}</small>
</div>
`).join('')}
</div>
${renderTithiLordInsight(tithiLord)}
<div class="panchanga-range-controls">
<label>
<span>开始</span>
<input type="date" id="panchanga-start" value="${escapeAttr(toDateISO(new Date()))}">
</label>
<label>
<span>结束</span>
<input type="date" id="panchanga-end" value="${escapeAttr(toDateISO(addDays(new Date(), 6)))}">
</label>
<label>
<span>日出</span>
<input type="time" id="panchanga-sunrise" value="06:00">
</label>
<label>
<span>日落</span>
<input type="time" id="panchanga-sunset" value="18:00">
</label>
<label>
<span>活动</span>
<select id="panchanga-activity">
${PANCHANGA_ACTIVITIES.map(([value, label]) => `<option value="${escapeAttr(value)}">${escapeHtml(label)}</option>`).join('')}
</select>
</label>
<div class="panchanga-condition-filter" id="panchanga-condition">
<div class="panchanga-condition-head">
<span>条件组合</span>
<select id="panchanga-condition-mode" aria-label="Panchanga 条件组合模式">
${PANCHANGA_CONDITION_MODES.map(([value, label]) => `<option value="${escapeAttr(value)}">${escapeHtml(label)}</option>`).join('')}
</select>
</div>
<div class="panchanga-condition-options">
${PANCHANGA_CONDITIONS.filter(([value]) => value !== 'all').map(([value, label]) => `
<label>
<input type="checkbox" name="panchanga-condition-option" value="${escapeAttr(value)}">
<small>${escapeHtml(label)}</small>
</label>
`).join('')}
</div>
</div>
</div>
<div class="provenance-actions">
<button type="button" class="provenance-action" data-action="panchanga-week">本周</button>
<button type="button" class="provenance-action" data-action="panchanga-month">本月</button>
<button type="button" class="provenance-action" data-action="panchanga-range">生成日历</button>
<button type="button" class="provenance-action" data-action="panchanga-csv">导出 CSV</button>
<button type="button" class="provenance-action" data-action="panchanga-ics">导出 ICS</button>
</div>
<div id="panchanga-range-result" class="panchanga-week-status">使用 /api/panchanga_range 生成 Panchanga 日期范围月历活动筛选Rahu KalaYamagandaGulika</div>
</section>
<section class="provenance-card">
<div class="provenance-head">
<span>Workspace</span>
<strong>${escapeHtml(saved.count ?? 0)} saved</strong>
</div>
<div class="workspace-status">
<strong>${escapeHtml(saved.label || '星盘库状态未知')}</strong>
<p>本地星盘库与 AI 面板共用同一份数据可在主界面保存打开删除和导出案例</p>
</div>
${renderChartWorkspaceList(chartLibrary)}
${renderCaseWorkspaceSummary(pairLibrary, prashnaLibrary)}
<div class="provenance-actions">
<button type="button" class="provenance-action" data-action="workspace-save-current">保存当前盘</button>
<button type="button" class="provenance-action" data-action="workspace-open-selected">打开所选</button>
<button type="button" class="provenance-action" data-action="workspace-delete-selected">删除所选</button>
<button type="button" class="provenance-action" data-action="workspace-export-selected">导出所选</button>
<button type="button" class="provenance-action" data-action="workspace-export-cases">导出案例库</button>
<label class="provenance-action file-action">
<input type="file" id="workspace-case-import-file" accept=".json,application/json">
导入案例库
</label>
<button type="button" class="provenance-action" data-action="export-json">导出 JSON</button>
<button type="button" class="provenance-action" data-action="export-html">导出 HTML 报告</button>
<button type="button" class="provenance-action" data-action="open-ai-library">打开 AI 面板</button>
</div>
<div id="workspace-case-import-status" class="workspace-import-status" aria-live="polite"></div>
</section>
${renderTrustCenterPanel()}
<section class="provenance-card provenance-card-wide">
<div class="provenance-head">
<span>Audit & Next Work</span>
<strong>Product parity track</strong>
</div>
<div class="provenance-table-wrap">
<table class="provenance-table">
<tbody>
${auditRows.map(row => `<tr><th>${escapeHtml(row[0])}</th><td>${escapeHtml(row[1])}</td><td>${escapeHtml(row[2])}</td></tr>`).join('')}
</tbody>
</table>
</div>
<div class="roadmap-grid">
${roadmap.map(item => `
<div class="roadmap-card">
<span>${escapeHtml(item[0])}</span>
<strong>${escapeHtml(item[1])}</strong>
<p>${escapeHtml(item[2])}</p>
</div>
`).join('')}
</div>
<details class="technique-json">
<summary>产品差距矩阵路径</summary>
<pre>${escapeHtml(PRODUCT_GAP_DOC)}</pre>
</details>
</section>
</div>
`;
bindProvenanceActions();
}
function renderWorkflowProvenancePanel(chartRecord) {
const workflow = getConsultationWorkflow(chartRecord);
const runtimePlanner = getRuntimePlanner(chartRecord);
const runtimeTruth = workflow.runtime_truth || {};
const sourceCoverage = workflow.interpretation_source_runtime_coverage || {};
if (!Object.keys(workflow).length && !Object.keys(runtimePlanner).length) return '';
const route = runtimePlanner.route || workflow.routing || {};
const unified = workflow.unified_orchestrator || {};
const sourcePriority = workflow.source_priority || unified.source_priority || runtimePlanner.source_priority || {};
const focusTechniques = Array.isArray(route.focus_techniques) ? route.focus_techniques : [];
const syncSteps = Array.isArray(runtimePlanner.sync_steps) ? runtimePlanner.sync_steps : [];
const asyncCandidates = Array.isArray(runtimePlanner.async_candidates) ? runtimePlanner.async_candidates : [];
return `
<section class="provenance-card provenance-card-wide workflow-provenance-panel">
<div class="provenance-head">
<span>Workflow Provenance</span>
<strong>${escapeHtml(runtimePlanner.planner_name || 'UnifiedConsultationRuntimePlanner')}</strong>
</div>
<div class="workflow-provenance-list">
<div class="provenance-list-row">
<span>unified_orchestrator</span>
<strong>${escapeHtml(unified.name || 'UnifiedConsultationOrchestrator')}</strong>
<small>${escapeHtml(`${unified.surface || runtimePlanner.surface || '-'} · ${formatWorkflowEntryMode(unified.entry_mode || workflow.entry_mode || runtimePlanner.entry_mode)}`)}</small>
</div>
<div class="provenance-list-row">
<span>routing.focus_techniques</span>
<strong>${escapeHtml(formatWorkflowRouteLabel(route))}</strong>
<small>${escapeHtml(focusTechniques.join(' / ') || '-')}</small>
</div>
<div class="provenance-list-row">
<span>source_priority</span>
<strong>${escapeHtml(sourcePriority.mode || 'vedastro_official_snapshot_first')}</strong>
<small>${escapeHtml((sourcePriority.priority || []).join(' -> ') || 'VedAstro official -> local supplemental -> fallback')}</small>
</div>
<div class="provenance-list-row">
<span>runtime planner</span>
<strong>${escapeHtml(syncSteps.join(' -> ') || '-')}</strong>
<small>${escapeHtml(`async: ${asyncCandidates.join(' / ') || 'none'}`)}</small>
<code>${escapeHtml(runtimePlanner.boundary || workflow.boundary || '')}</code>
</div>
<div class="provenance-list-row">
<span>VedAstro Runtime Truth</span>
<strong>${escapeHtml(runtimeTruth.catalog_boundary || '-')}</strong>
<small>${escapeHtml([
`chart_core:${runtimeTruth.official_execution_layers?.chart_core || '-'}`,
`event_radar:${runtimeTruth.official_execution_layers?.event_radar || '-'}`,
`catalog:${runtimeTruth.official_execution_layers?.catalog_status || '-'}`,
].join(' · '))}</small>
</div>
<div class="provenance-list-row">
<span>Interpretation Source Coverage</span>
<strong>${escapeHtml(sourceCoverage.source_pack_status || '-')}</strong>
<small>${escapeHtml([
`runtime_visibility_status:${sourceCoverage.runtime_visibility_status || '-'}`,
`markers:${(sourceCoverage.proven_runtime_markers || []).join(' / ') || '-'}`,
].join(' · '))}</small>
</div>
</div>
</section>
`;
}
function renderKV(label, value) {
return `
<div class="provenance-kv">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(String(value || '-'))}</strong>
</div>
`;
}
function renderCalculationSettingsPanel(settings = readCalculationSettings()) {
const current = normalizeCalculationSettings(settings);
return `
<div class="calculation-settings-panel">
<div class="calculation-settings-head">
<strong>Calculation Settings</strong>
<span>保存后下一次排盘生效并随导出报告记录</span>
</div>
<div class="calculation-settings-grid">
${renderCalculationSelect('ayanamsa', 'Ayanamsa', current.ayanamsa)}
${renderCalculationSelect('nodeMode', 'Node', current.nodeMode)}
${renderCalculationSelect('houseSystem', 'House', current.houseSystem)}
${renderCalculationSelect('sunrisePolicy', 'Sunrise', current.sunrisePolicy)}
${renderCalculationSelect('geocoderPolicy', 'Geocoder', current.geocoderPolicy)}
${renderCalculationSelect('ephemerisBackend', 'Ephemeris', current.ephemerisBackend)}
${renderCalculationSelect('terminologyMode', 'Terminology', current.terminologyMode)}
</div>
${renderTerminologyModePreview(current.terminologyMode)}
<div class="rule-variant-panel">
<div class="calculation-settings-head">
<strong>Rule Variants</strong>
<span>当前先记录解释口径后续逐项接入实时切换</span>
</div>
<div class="calculation-settings-grid rule-variant-grid">
${renderCalculationSelect('yogaVariant', 'Yoga', current.yogaVariant)}
${renderCalculationSelect('jaiminiKarakaVariant', 'Jaimini', current.jaiminiKarakaVariant)}
${renderCalculationSelect('kpSignificatorVariant', 'KP', current.kpSignificatorVariant)}
${renderCalculationSelect('ashtakavargaVariant', 'Ashtakavarga', current.ashtakavargaVariant)}
${renderCalculationSelect('shadbalaVariant', 'Shadbala', current.shadbalaVariant)}
${renderCalculationSelect('dashaReference', 'Dasha', current.dashaReference)}
</div>
</div>
<div class="calculation-settings-note">
Lahiri / Mean Node / Whole Sign / Swiss Ephemeris 与当前 Rule Variants 是主路径xalen-ephemeris 先作为 Apache-2.0 可行性记录不改变当前核心黄经
</div>
<button type="button" class="provenance-action calculation-settings-save" data-action="save-calculation-settings">保存计算设置</button>
</div>
`;
}
function renderTerminologyModePreview(mode) {
const copy = {
beginner: [
'初学模式',
'tooltip 会优先解释成日常生活语言,例如“第10宫=事业角色与公开责任”。',
],
professional: [
'专业模式',
'tooltip 会保留 Sanskrit / English / 技法名,适合复盘 DK、UL、SAV、Shadbala 等证据。',
],
balanced: [
'平衡模式',
'tooltip 同时显示中文、Sanskrit、English 和短解释,是默认阅读模式。',
],
};
const [title, note] = copy[mode] || copy.balanced;
return `
<div class="terminology-mode-preview" data-terminology-mode="${escapeAttr(mode)}">
<strong>${escapeHtml(title)}</strong>
<span>${escapeHtml(note)}</span>
</div>
`;
}
function renderCalculationSelect(key, label, value) {
const options = CALCULATION_SETTING_OPTIONS[key] || [];
return `
<label>
<span>${escapeHtml(label)}</span>
<select data-setting-key="${escapeAttr(key)}">
${options.map(([option, optionLabel]) => `<option value="${escapeAttr(option)}"${option === value ? ' selected' : ''}>${escapeHtml(optionLabel)}</option>`).join('')}
</select>
</label>
`;
}
function initPWAInstallability() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
window.__jyotishPWAStatus = {
serviceWorker: 'registered',
scope: registration.scope,
controlled: Boolean(navigator.serviceWorker.controller),
};
updateTrustCenterPWAStatus();
})
.catch(error => {
window.__jyotishPWAStatus = {
serviceWorker: 'failed',
error: error?.message || 'registration failed',
};
updateTrustCenterPWAStatus();
});
} else {
window.__jyotishPWAStatus = { serviceWorker: 'unsupported' };
}
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault();
window.__jyotishDeferredInstallPrompt = event;
window.__jyotishPWAStatus = {
...(window.__jyotishPWAStatus || {}),
installPrompt: 'ready',
};
updateTrustCenterPWAStatus();
});
window.addEventListener('appinstalled', () => {
window.__jyotishDeferredInstallPrompt = null;
window.__jyotishPWAStatus = {
...(window.__jyotishPWAStatus || {}),
installed: true,
installPrompt: 'installed',
};
updateTrustCenterPWAStatus();
});
}
function renderTrustCenterPanel() {
const stats = getTrustCenterStats();
const pwa = getPWAStatus();
const terminology = getTerminologyModeOption();
const runtime = getRuntimeHealthStatus();
const vedastro = getVedAstroStatus();
const statusMessage = getTrustCenterStatusMessage(pwa, runtime);
return `
<section class="provenance-card trust-center-panel">
<div class="provenance-head">
<span>Trust Center</span>
<strong>Local-first</strong>
</div>
<div class="trust-status-grid">
${renderTrustStatus('PWA', pwa.label, pwa.note)}
${renderTrustStatus('本地星盘', `${stats.charts} charts`, '存储在浏览器 localStorage')}
${renderTrustStatus('配对/问事', `${stats.pairs} pairs · ${stats.prashnas} prashna`, '可单独或整库导出')}
${renderTrustStatus('术语', terminology.shortLabel, terminology.note)}
${renderTrustStatus('API', '127.0.0.1:5200', '本地 API 服务;不需要外部账号')}
${renderTrustStatus('运行体检', runtime.label, runtime.note)}
${renderVedAstroStatus(vedastro)}
</div>
${renderRuntimeHealthPanel(runtime)}
${renderVedAstroUserScanPanel()}
<div id="professional-reading-host">
${renderProfessionalReadingPanel(professionalReadingState)}
</div>
${renderValidationTransparencyPanel()}
${renderDashaShadbalaCalibrationPanel()}
${renderOracleEvidenceIntakePanel()}
${renderRealCaseRevalidationPanel()}
${renderTerminologyModePanel()}
<div class="trust-center-copy">
<p>出生资料保存星盘配对记录问事记录和计算设置默认保存在本机浏览器导出报告会写入你主动下载的文件PDF 后端工件仅由本地 API 生成</p>
<p>术语模式只改变解释层和导出记录不改变底层排盘AI 聊天订阅登录或外部接口只在你配置并主动使用对应功能时发生</p>
</div>
<div class="provenance-actions">
<button type="button" class="provenance-action" data-action="trust-run-health">运行健康检查</button>
<button type="button" class="provenance-action" data-action="trust-run-real-cases">复验真实案例</button>
<button type="button" class="provenance-action" data-action="pwa-install">安装为应用</button>
<button type="button" class="provenance-action" data-action="trust-export-local">导出本地资料</button>
<button type="button" class="provenance-action danger" data-action="trust-clear-local">清空本地资料</button>
</div>
<div id="trust-center-status" class="workspace-import-status" aria-live="polite">${escapeHtml(statusMessage)}</div>
</section>
`;
}
const DASHA_SHADBALA_CALIBRATION_STATUS = {
title: 'Dasha/Shadbala Calibration Status',
collection: 'external_oracle_collection_queue',
validator: 'external_oracle_evidence_validation',
totalTasks: '5 template cases',
readyForCalibration: 'ready_for_calibration: 0',
validPackets: 'valid_packets: 0',
productionTuning: 'production_tuning_allowed: false',
highConfidence: 'D1/D9/SAV 高可信',
boundary: '大运起点和 Shadbala 绝对值仍在外部 evidence validator 校准中',
nextAction: '继续采集 JHora/PyJHora 黑盒截图与分量值;未达标前不得把大运起点或 Shadbala 绝对值说成已完成外部校准。',
};
const ORACLE_EVIDENCE_PACKET_REQUIRED_METADATA = [
'tool_name',
'tool_version_or_url',
'capture_date',
'source_artifact',
'ayanamsa',
'node_mode',
'timezone',
'operator_note',
];
const ORACLE_EVIDENCE_INTAKE_TASKS = [
{
caseId: 'template_steve_jobs_dasha_lahiri',
title: 'Steve Jobs · Dasha 边界样本',
birth: '1955-02-24 19:15 · San Francisco · 37.7749, -122.4194 · TZ -08:00',
settings: 'ayanamsa=lahiri · node_mode=mean',
preferredSources: 'JHora / PyJHora 黑盒输出',
targetFields: ['vimshottari_start_date', 'shadbala_components'],
},
{
caseId: 'template_synthetic_north_china_shadbala_raman',
title: '合成公开样本 · Raman Shadbala',
birth: '1980-01-01 12:00 · synthetic fixture · TZ +08:00',
settings: 'ayanamsa=raman · node_mode=mean',
preferredSources: 'JHora / PyJHora Shadbala 黑盒输出',
targetFields: ['moon_sidereal_longitude_deg', 'shadbala_components'],
},
{
caseId: 'template_extreme_latitude_kp',
title: '高纬样本 · KP/Ayanamsa 边界',
birth: 'Extreme latitude fixture · local civil time retained',
settings: 'ayanamsa=kp · node_mode=mean',
preferredSources: 'JHora / VedAstro longitude cross-check',
targetFields: ['ascendant_longitude_deg', 'shadbala_components'],
},
{
caseId: 'template_historical_epoch_lahiri',
title: '历史年代样本 · Lahiri 回归',
birth: 'Historical epoch fixture · Julian/Gregorian policy noted',
settings: 'ayanamsa=lahiri · node_mode=mean',
preferredSources: 'JHora date policy screenshot',
targetFields: ['sun_sidereal_longitude_deg', 'vimshottari_start_date'],
},
{
caseId: 'template_bv_raman_vimshottari_boundary_series',
title: 'B.V. Raman · Dasha 边界',
birth: '1912-08-08 19:43 · public reference chart · TZ +05:30',
settings: 'ayanamsa=raman · node_mode=unspecified',
preferredSources: 'Published example / JHora boundary verification',
targetFields: ['vimshottari_mahadasa_boundaries'],
},
];
function renderDashaShadbalaCalibrationPanel() {
return `
<div class="dasha-shadbala-calibration-panel">
<div class="calculation-settings-head">
<strong>${escapeHtml(DASHA_SHADBALA_CALIBRATION_STATUS.title)}</strong>
<span>${escapeHtml(DASHA_SHADBALA_CALIBRATION_STATUS.highConfidence)} · ${escapeHtml(DASHA_SHADBALA_CALIBRATION_STATUS.boundary)}</span>
</div>
<div class="dasha-shadbala-calibration-grid">
${renderValidationTransparencyMetric('Queue', DASHA_SHADBALA_CALIBRATION_STATUS.totalTasks, DASHA_SHADBALA_CALIBRATION_STATUS.collection)}
${renderValidationTransparencyMetric('Calibration', DASHA_SHADBALA_CALIBRATION_STATUS.readyForCalibration, '可用于生产调参的外部证据包数量。')}
${renderValidationTransparencyMetric('Evidence', DASHA_SHADBALA_CALIBRATION_STATUS.validPackets, DASHA_SHADBALA_CALIBRATION_STATUS.validator)}
${renderValidationTransparencyMetric('Tuning', DASHA_SHADBALA_CALIBRATION_STATUS.productionTuning, '禁止用单份 PDF、本地输出或全局缩放调生产常数。')}
</div>
<div class="dasha-shadbala-calibration-boundary">
<strong>边界</strong>
<span>${escapeHtml(DASHA_SHADBALA_CALIBRATION_STATUS.nextAction)}</span>
</div>
</div>
`;
}
function renderOracleEvidenceIntakePanel() {
return `
<div class="oracle-evidence-intake-panel">
<div class="calculation-settings-head">
<strong>Oracle Evidence Intake</strong>
<span>下载 5 个外部真值空白包只接受 JHora/PyJHora/VedAstro 黑盒证据禁止本地输出冒充 external_verified</span>
</div>
${renderOracleEvidenceProgressDashboard()}
<div class="oracle-evidence-intake-grid">
${ORACLE_EVIDENCE_INTAKE_TASKS.map(task => `
<article class="oracle-evidence-card">
<div>
<strong>${escapeHtml(task.title)}</strong>
<span>${escapeHtml(task.birth)}</span>
<small>${escapeHtml(task.settings)} · ${escapeHtml(task.preferredSources)}</small>
</div>
<div class="oracle-evidence-fields">
${task.targetFields.map(field => `<span>${escapeHtml(field)}</span>`).join('')}
</div>
<button type="button" class="mini-action" data-action="oracle-download-packet" data-case-id="${escapeAttr(task.caseId)}">下载 Evidence Packet</button>
</article>
`).join('')}
</div>
<div class="dasha-shadbala-calibration-boundary">
<strong>晋级规则</strong>
<span>默认 status=draft必须补齐 ${escapeHtml(ORACLE_EVIDENCE_PACKET_REQUIRED_METADATA.join(', '))} 与目标字段且通过 external_oracle_evidence_validation 才允许进入 external_verified 审核</span>
</div>
<div class="oracle-evidence-import">
<label class="provenance-action file-action">
<input type="file" id="oracle-evidence-upload" accept=".json,application/json">
导入 Evidence Packet 判卷
</label>
<div id="oracle-evidence-validation-result" class="oracle-evidence-validation-result" aria-live="polite">
等待填写后的 Evidence Packetdraft空字段本地输出会被 external_oracle_evidence_validation 拦截常见红灯status_not_external_verifiedlocal_engine_artifact_rejected
</div>
</div>
</div>
`;
}
function renderOracleEvidenceProgressDashboard() {
const totalPackets = ORACLE_EVIDENCE_INTAKE_TASKS.length;
const validPackets = 0;
const readyPackets = 0;
const shadbalaTargets = ORACLE_EVIDENCE_INTAKE_TASKS
.filter(task => task.targetFields.includes('shadbala_components')).length;
const dashaTargets = ORACLE_EVIDENCE_INTAKE_TASKS
.filter(task => task.targetFields.includes('vimshottari_start_date')).length;
const progress = totalPackets ? Math.round((validPackets / totalPackets) * 100) : 0;
return `
<section class="oracle-evidence-progress-dashboard" aria-label="Dasha/Shadbala 真实进度">
<div class="calculation-settings-head">
<strong>Dasha/Shadbala 真实进度</strong>
<span>${validPackets} / ${totalPackets} valid_packets · ${readyPackets} ready_for_calibration · production_tuning_allowed=false</span>
</div>
<div class="oracle-evidence-progress-bar" aria-label="Oracle evidence progress ${validPackets} / ${totalPackets}">
<span style="width: ${progress}%"></span>
</div>
<div class="dasha-shadbala-calibration-grid">
${renderValidationTransparencyMetric('Evidence', `${validPackets} / ${totalPackets}`, '0 / 5 外部证据包通过前,生产调参保持关闭。')}
${renderValidationTransparencyMetric('Dasha', `${dashaTargets} targets`, 'vimshottari_start_date 需要 JHora/PyJHora 黑盒对照。')}
${renderValidationTransparencyMetric('Shadbala', `${shadbalaTargets} targets`, 'missing_shadbala_component 会拦截缺 sthana/dig/kala/chesta/naisargika/drik 的七曜分量。')}
${renderValidationTransparencyMetric('Artifacts', 'references/oracle/artifacts/', 'source_artifact 必须指向脱敏后的外部截图或 stdout。')}
</div>
<div class="dasha-shadbala-calibration-boundary">
<strong>隐私</strong>
<span>私人截图必须打码不得上传私人 PDF 原件完整出生报告或浏览器 scratch 会话文件</span>
</div>
</section>
`;
}
function buildOracleEvidencePacket(task) {
const target = {};
task.targetFields.forEach(field => {
target[field] = field === 'shadbala_components' ? {} : null;
});
return {
schema_version: 1,
packet_type: 'dasha_shadbala_external_oracle_evidence',
case_id: task.caseId,
status: 'draft',
source_policy: {
allowed_sources: task.preferredSources,
forbidden_sources: ['local_engine_output', 'copied_agpl_or_commercial_code'],
integrity_checks: [
'must_not_come_from_local_engine',
'requires_external_artifact',
'reject_global_shadbala_scaling',
],
promotion_target: 'external_verified',
},
case_context: {
title: task.title,
birth: task.birth,
settings: task.settings,
target_fields: task.targetFields,
},
target,
evidence_packet: {
status: 'draft',
metadata: ORACLE_EVIDENCE_PACKET_REQUIRED_METADATA.reduce((acc, key) => {
acc[key] = key === 'capture_date' ? toDateISO(new Date()) : '';
return acc;
}, {}),
},
operator_checklist: [
'Fill tool_name and tool_version_or_url from the external product.',
'Attach source_artifact as a repo-relative references/oracle/artifacts/... path after 必须打码.',
'Do not attach private PDF originals, complete birth reports, browser scratch folders, account sessions, cookies, tokens, or desktop notifications.',
'Fill every target value from black-box output before requesting review.',
],
};
}
function downloadOracleEvidencePacket(caseId) {
const task = ORACLE_EVIDENCE_INTAKE_TASKS.find(item => item.caseId === caseId);
const status = $('trust-center-status');
if (!task) {
if (status) status.textContent = '未找到对应的外部真值采集任务。';
return;
}
const packet = buildOracleEvidencePacket(task);
downloadText(JSON.stringify(packet, null, 2), `jyotish-oracle-evidence-${task.caseId}.json`, 'application/json;charset=utf-8');
if (status) {
status.textContent = `已下载 ${task.caseId} 空白证据包source_artifact 请指向 references/oracle/artifacts/ 下已打码文件再运行 evidence validator`;
}
}
async function importOracleEvidencePacket(file) {
const result = $('oracle-evidence-validation-result');
if (!file) return;
if (!file.name.toLowerCase().endsWith('.json') && file.type !== 'application/json') {
if (result) result.textContent = '请选择 JSON Evidence Packet 文件。';
return;
}
try {
const packet = JSON.parse(await readWorkspaceFileAsText(file));
if (result) result.textContent = '正在调用本地 external_oracle_evidence_validation 判卷...';
const response = await validateOracleEvidencePacket(packet);
if (result) result.innerHTML = renderOracleEvidenceValidationResult(response);
} catch (error) {
if (result) {
result.innerHTML = renderOracleEvidenceValidationResult({
summary: {
valid_packets: 0,
ready_for_calibration: 0,
production_tuning_allowed: false,
},
packets: [{
case_id: 'uploaded',
valid: false,
problems: [error?.message || 'JSON 格式不正确或本地 API 未连接'],
}],
boundary: 'Evidence Packet 必须是 JSON,并通过 /api/oracle_evidence 调用本地 validator。',
});
}
}
}
async function validateOracleEvidencePacket(packet) {
if (!window.JyotishAPI?.validateOracleEvidence) {
throw new Error('/api/oracle_evidence 不可用,请先启动本地 API 服务。');
}
return window.JyotishAPI.validateOracleEvidence({ packet });
}
function renderOracleEvidenceValidationResult(response = {}) {
const summary = response.summary || response.report?.summary || {};
const packets = response.packets || response.report?.packets || [];
const valid = Number(summary.valid_packets || 0);
const ready = Number(summary.ready_for_calibration || 0);
const tuningAllowed = summary.production_tuning_allowed === true;
const tone = tuningAllowed ? 'ok' : (valid > 0 || ready > 0 ? 'warn' : 'blocked');
const rows = packets.map(packet => {
const problems = Array.isArray(packet.problems) ? packet.problems : [];
return `
<li>
<strong>${escapeHtml(packet.case_id || packet.task_id || 'uploaded')}</strong>
<span>${escapeHtml(packet.valid ? 'external_verified 可进入人工复核' : '未通过')}</span>
${problems.length ? `<small>${problems.map(escapeHtml).join(' · ')}</small>` : '<small>无 problems;仍需人工确认外部截图与数值。</small>'}
</li>
`;
}).join('');
return `
<div class="oracle-evidence-validation-result oracle-evidence-validation-${escapeAttr(tone)}">
<div>
<strong>external_oracle_evidence_validation</strong>
<span>valid_packets: ${escapeHtml(String(valid))} · ready_for_calibration: ${escapeHtml(String(ready))} · production_tuning_allowed: ${escapeHtml(String(Boolean(tuningAllowed)).toLowerCase())}</span>
</div>
<ul>${rows || '<li><strong>uploaded</strong><span>没有返回 packet 明细</span><small>请检查 JSON 格式。</small></li>'}</ul>
<small>${escapeHtml(response.boundary || response.report?.boundary || 'Local engine output remains rejected as an external oracle source.')}</small>
</div>
`;
}
const VALIDATION_TRANSPARENCY = {
source: 'Yoga logic benchmark',
charts: '60 charts',
comparableRules: '82 comparable rules',
precision: 'Precision 96.48%',
recall: 'Recall 93.99%',
f1: 'F1 95.22%',
unmappedPyjhora: 'unmapped_pyjhora: 718',
unmappedExternalBenchmark: 'unmapped_external_benchmark: tracked',
gates: ['golden cases', 'BPHS invariants', 'release-quality-gate'],
};
function renderValidationTransparencyPanel() {
return `
<div class="validation-transparency-panel">
<div class="calculation-settings-head">
<strong>Validation Transparency</strong>
<span>${escapeHtml(VALIDATION_TRANSPARENCY.source)} · ${escapeHtml(VALIDATION_TRANSPARENCY.charts)} · ${escapeHtml(VALIDATION_TRANSPARENCY.comparableRules)}</span>
</div>
<div class="validation-transparency-grid">
${renderValidationTransparencyMetric('Precision', VALIDATION_TRANSPARENCY.precision, '命中的规则中,与参考比较一致的比例。')}
${renderValidationTransparencyMetric('Recall', VALIDATION_TRANSPARENCY.recall, '参考可比较规则中,被当前引擎找回的比例。')}
${renderValidationTransparencyMetric('F1', VALIDATION_TRANSPARENCY.f1, 'Precision 与 Recall 的综合分。')}
${renderValidationTransparencyMetric('Coverage gap', VALIDATION_TRANSPARENCY.unmappedPyjhora, '仍未映射的 PyJHora/B.V. Raman 规则保持可见。')}
${renderValidationTransparencyMetric('External gap', VALIDATION_TRANSPARENCY.unmappedExternalBenchmark, '外部 benchmark 缺口保持可见,但不得等同个人事件预测准确率。')}
</div>
<div class="validation-transparency-boundary">
<strong>边界</strong>
<span>这些数字来自 references/validation_logic_report.json Yoga 规则对照不是个人事件预测准确率出生时间地点星历流派选择和现实事件记录仍会影响解读</span>
</div>
<div class="capability-command-tags">
${VALIDATION_TRANSPARENCY.gates.map(gate => `<span>${escapeHtml(gate)}</span>`).join('')}
</div>
</div>
`;
}
function renderValidationTransparencyMetric(label, value, note) {
return `
<div class="validation-transparency-metric">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
<small>${escapeHtml(note)}</small>
</div>
`;
}
const REAL_CASE_REVALIDATION_BASELINE = {
status: 'baseline',
publicReference: { label: '公开人物星座级一致率', passed: 66, total: 66, passRate: 1, display: '66/66' },
allChecks: { passed: 87, total: 99, display: '87/99' },
controversialReference: { caseCount: 5, note: 'controversial_reference 样本保留展示,不计入发布阻断口径。' },
boundary: '公开人物星座级一致率,不是人生事件预测准确率。',
};
function renderRealCaseRevalidationPanel() {
const state = window.__jyotishRealCaseRevalidation || REAL_CASE_REVALIDATION_BASELINE;
const publicRef = state.publicReference || REAL_CASE_REVALIDATION_BASELINE.publicReference;
const allChecks = state.allChecks || REAL_CASE_REVALIDATION_BASELINE.allChecks;
const controversial = state.controversialReference || REAL_CASE_REVALIDATION_BASELINE.controversialReference;
const checkedLabel = state.checkedAt ? `已复验 · ${formatDateTime(state.checkedAt)}` : 'release gate baseline';
const tone = state.status === 'warn' ? 'warn' : state.status === 'checking' ? 'pending' : 'ok';
const publicValue = publicRef.display || `${publicRef.passed ?? 66}/${publicRef.total ?? 66}`;
const allValue = allChecks.display || `${allChecks.passed ?? 87}/${allChecks.total ?? 99}`;
return `
<div class="real-case-revalidation-panel real-case-revalidation-${escapeAttr(tone)}">
<div class="calculation-settings-head">
<strong>真实案例复验</strong>
<span>${escapeHtml(checkedLabel)}</span>
</div>
<div class="real-case-revalidation-grid">
${renderRealCaseMetric('公开人物星座级一致率', publicValue, '发布阻断口径,只统计非争议公开样本的 Lagna/Sun/Moon 星座。')}
${renderRealCaseMetric('全量诊断', allValue, '包含争议来源和度数诊断,保留差异供审计。')}
${renderRealCaseMetric('controversial_reference', `${controversial.caseCount ?? 5} cases`, controversial.note || '来源矛盾、时区争议或边界度数样本。')}
</div>
<div class="real-case-revalidation-boundary">
<strong>边界</strong>
<span>${escapeHtml(state.boundary || REAL_CASE_REVALIDATION_BASELINE.boundary)}</span>
</div>
</div>
`;
}
function renderRealCaseMetric(label, value, note) {
return `
<div class="real-case-revalidation-metric">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
<small>${escapeHtml(note)}</small>
</div>
`;
}
function getTrustCenterStatusMessage(pwa, runtime = getRuntimeHealthStatus()) {
const health = window.__jyotishRuntimeHealth || {};
if (health.status === 'checking') return '正在运行健康检查...';
if (health.status === 'ok') return '健康检查通过:本地 API 服务、能力目录和 PWA 安装壳状态已记录。';
if (health.status === 'warn') return `健康检查未通过${health.error || runtime.note || '本地 API 或能力目录未通过健康检查。'}`;
return pwa.note;
}
function renderTrustStatus(label, value, note) {
return `
<div class="trust-status-card">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(value)}</strong>
<small>${escapeHtml(note)}</small>
</div>
`;
}
function getVedAstroStatus() {
const status = window.__jyotishVedAstroStatus;
if (!status) {
return {
label: '未检查',
note: '运行健康检查后读取 /api/vedastro/status;需要 VEDASTRO_API_ENDPOINT 才会连接外部雷达。',
tone: 'pending',
};
}
if (status.status === 'checking') {
return {
label: '检查中',
note: '正在读取 /api/vedastro/status。',
tone: 'pending',
};
}
if (status.status === 'live_ready') {
return {
label: 'live-ready',
note: `${status.endpoint_host || 'configured'} · VEDASTRO_ENABLE_NETWORK 已启用外部证据仍只进 secondary context`,
tone: 'ok',
};
}
if (status.status === 'network_execution_disabled') {
return {
label: '已配置/禁用网络',
note: `${status.endpoint_host || 'endpoint configured'} · 需显式启用 VEDASTRO_ENABLE_NETWORK`,
tone: 'warn',
};
}
if (status.status === 'service_endpoint_not_configured') {
return {
label: '未配置',
note: '未设置 VEDASTRO_API_ENDPOINT;本地引擎继续可用,VedAstro 外部雷达保持 blocked。',
tone: 'pending',
};
}
return {
label: '需处理',
note: status.error || status.boundary || 'VedAstro 外部雷达状态不可用。',
tone: 'warn',
};
}
function renderVedAstroStatus(status = getVedAstroStatus()) {
return renderTrustStatus('VedAstro 外部雷达', status.label, status.note);
}
function getVedAstroScanState() {
return window.__jyotishVedAstroRangeScan || { status: 'idle' };
}
function renderVedAstroUserScanPanel(state = getVedAstroScanState()) {
const today = new Date();
const start = state.start_date || `${today.getFullYear()}-01-01`;
const end = state.end_date || `${today.getFullYear()}-12-31`;
const domain = state.domain || 'career';
return `
<div class="runtime-health-panel vedastro-user-scan-panel" data-vedastro-user-scan="true">
<div class="calculation-settings-head">
<strong>VedAstro Range Scan</strong>
<span>对当前星盘运行外部高频事件雷达外部证据只进 secondary context</span>
</div>
<div class="calculation-settings-grid">
<label>
<span>领域</span>
<select id="vedastro-scan-domain">
<option value="career"${domain === 'career' ? ' selected' : ''}>事业</option>
<option value="relationship"${domain === 'relationship' ? ' selected' : ''}>婚恋</option>
<option value="finance"${domain === 'finance' ? ' selected' : ''}>财富</option>
</select>
</label>
<label>
<span>开始</span>
<input id="vedastro-scan-start" type="date" value="${escapeAttr(start)}">
</label>
<label>
<span>结束</span>
<input id="vedastro-scan-end" type="date" value="${escapeAttr(end)}">
</label>
</div>
<div class="provenance-actions">
<button type="button" class="provenance-action" data-action="vedastro-run-range-scan">运行 VedAstro 外部雷达扫描</button>
</div>
${renderVedAstroRangeScanResult(state)}
</div>
`;
}
function renderVedAstroRangeScanResult(state = getVedAstroScanState()) {
if (state.status === 'running') {
return '<div class="workspace-import-status" aria-live="polite">正在请求 /api/vedastro/range_scan...</div>';
}
if (state.status === 'idle') {
return '<div class="calculation-settings-note">请先生成星盘,再选择年份或日期范围运行扫描。没有配置 VEDASTRO_API_ENDPOINT 时会返回 blocked 边界。</div>';
}
if (state.status === 'error') {
return `<div class="workspace-import-status" aria-live="polite">VedAstro Range Scan 未完成${escapeHtml(state.error || '本地 API 不可用')}</div>`;
}
const result = state.result?.result || state.result || {};
const metadata = result.source_metadata || {};
const events = Array.isArray(result.evidence_ledger) ? result.evidence_ledger.slice(0, 5) : [];
const status = result.status || state.status || '-';
const eventCount = result.event_count ?? events.length ?? 0;
return `
<div class="workspace-import-status" aria-live="polite">
VedAstro Range Scan${escapeHtml(status)} · ${escapeHtml(String(eventCount))} events · ${escapeHtml(state.start_date || '-')}/${escapeHtml(state.end_date || '-')}
</div>
<div class="calculation-settings-note">
${escapeHtml(result.reason || state.result?.boundary || 'VedAstro official snapshot 为主证据;range scan 是外部事件雷达,本地 Jyotish gates 作为补充与官方 blocked 时的 fallback。')}
${metadata.artifact_path ? ` artifact: ${escapeHtml(metadata.artifact_path)}` : ''}
</div>
${events.length ? `
<div class="runtime-health-grid">
${events.map(event => `
<div class="trust-status-card">
<span>${escapeHtml(event.signal_label || event.event_id || 'VedAstro event')}</span>
<strong>${escapeHtml(event.start || '-')}</strong>
<small>${escapeHtml(event.signal_family || event.domain || 'range_scan')}</small>
</div>
`).join('')}
</div>
` : ''}
`;
}
function getRuntimeHealthStatus() {
const health = window.__jyotishRuntimeHealth;
if (!health) {
return {
label: '未运行',
note: '点击运行健康检查,确认本地 API 服务、能力目录和 PWA 安装壳状态。',
tone: 'pending',
details: [],
};
}
if (health.status === 'checking') {
return {
label: '检查中',
note: '正在探测 /api/health、/api/capability_audit 与 PWA 状态。',
tone: 'pending',
details: health.details || [],
};
}
if (health.status === 'ok') {
return {
label: '可用',
note: `${health.apiBase || '本地 API'} · v${health.version || '-'} · ${health.latencyMs ?? '-'}ms`,
tone: 'ok',
details: health.details || [],
};
}
return {
label: '需处理',
note: health.error || '本地 API 或能力目录未通过健康检查。',
tone: 'warn',
details: health.details || [],
};
}
function renderRuntimeHealthPanel(runtime = getRuntimeHealthStatus()) {
const details = runtime.details?.length ? runtime.details : [
['PWA 安装壳', getPWAStatus().label, getPWAStatus().note],
['本地 API 服务', '待检查', '默认探测 http://127.0.0.1:5200/api/health'],
['Desktop path', 'PWA now', 'Pake shell 短期可用;Tauri sidecar 留给一键启动本地 API 服务。'],
];
return `
<div class="runtime-health-panel runtime-health-${escapeAttr(runtime.tone || 'pending')}">
<div class="calculation-settings-head">
<strong>Runtime Health</strong>
<span>${escapeHtml(runtime.note)}</span>
</div>
<div class="runtime-health-grid">
${details.map(([label, value, note]) => `
<div class="runtime-health-item">
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(String(value || '-'))}</strong>
<small>${escapeHtml(note || '')}</small>
</div>
`).join('')}
</div>
${renderStaticDemoBoundary()}
<p class="runtime-health-note">Packaging preflight: python3 scripts/desktop_packaging_preflight.py。Pake 适合快速 URL 壳;Tauri shell with sidecar 适合后续一键启动本地 API 服务。</p>
</div>
`;
}
function renderStaticDemoBoundary() {
return `
<div class="static-demo-boundary static-demo-boundary-compact" data-static-demo-boundary>
<div class="static-demo-boundary-head">
<strong>静态演示模式</strong>
<span>公开静态站点只承载网页壳;完整计算需要用户自己启动本地 API。</span>
</div>
<div class="static-demo-boundary-grid">
<div>
<span>浏览器 fallback</span>
<p>可直接体验:出生资料输入、基础 D1/D9 星盘、术语模式、Trust Center</p>
</div>
<div>
<span>需要本地 API 服务</span>
<p>需要本地 APIPDF/HTML 报告、高级技法、真实案例复验、AI 解读代理</p>
</div>
<div>
<span>Deploy</span>
<p>Vercel / Netlify / GitHub Pages 适合静态壳;Docker Compose 适合完整本机版本。</p>
</div>
</div>
</div>
`;
}
function getPWAStatus() {
const status = window.__jyotishPWAStatus || {};
if (status.installed || status.installPrompt === 'installed') {
return { label: '已安装', note: '浏览器已把 Jyotish 加入应用环境。' };
}
if (status.installPrompt === 'ready') {
return { label: '可安装', note: '可以通过按钮安装为桌面/移动应用。' };
}
if (status.serviceWorker === 'registered') {
return { label: '离线壳已注册', note: '静态页面可缓存;高级计算仍需本地 API 服务在线。' };
}
if (status.serviceWorker === 'failed') {
return { label: '注册失败', note: status.error || 'Service worker 注册失败。' };
}
if (status.serviceWorker === 'unsupported') {
return { label: '不支持', note: '当前浏览器不支持 service worker。' };
}
return { label: '检测中', note: '正在检查 manifest 与 service worker。' };
}
function updateTrustCenterPWAStatus() {
const host = $('trust-center-status');
if (!host) return;
host.textContent = getPWAStatus().note;
}
function getTrustCenterStats() {
return {
charts: readChartLibrary().length,
pairs: readSynastryPairLibrary().length,
prashnas: readPrashnaCaseLibrary().length,
keys: TRUST_CENTER_STORAGE_KEYS.filter(key => localStorage.getItem(key) !== null).length,
};
}
function exportTrustCenterLocalData() {
const payload = {
exportedAt: new Date().toISOString(),
terminologyMode: readTerminologyMode(),
storage: Object.fromEntries(TRUST_CENTER_STORAGE_KEYS.map(key => [key, readTrustStorageValue(key)])),
};
downloadText(JSON.stringify(payload, null, 2), `jyotish-local-data-${toDateISO(new Date())}.json`, 'application/json;charset=utf-8');
const status = $('trust-center-status');
if (status) status.textContent = '已导出本地资料 JSON。';
}
function readTrustStorageValue(key) {
const raw = localStorage.getItem(key);
if (raw == null) return null;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
function clearTrustCenterLocalData() {
if (!window.confirm('清空本地星盘、配对、问事、计算设置和相关本地状态?此操作不可撤销。')) return;
TRUST_CENTER_STORAGE_KEYS.forEach(key => localStorage.removeItem(key));
_caseWorkspaceSelection.clear();
_caseWorkspacePreview = null;
setGlossaryTerminologyMode(DEFAULT_CALCULATION_SETTINGS.terminologyMode);
if (chartData) chartData._terminology_mode = DEFAULT_CALCULATION_SETTINGS.terminologyMode;
const status = $('trust-center-status');
if (status) status.textContent = '本地资料已清空。';
renderAll();
setupSavedChartPanel();
}
async function runTrustCenterHealthCheck() {
const status = $('trust-center-status');
const checkedAt = new Date().toISOString();
window.__jyotishRuntimeHealth = {
status: 'checking',
checkedAt,
details: [
['PWA 安装壳', getPWAStatus().label, getPWAStatus().note],
['本地 API 服务', '检查中', '正在请求 /api/health'],
['Technique catalog', '等待中', 'API 在线后检查能力目录'],
['VedAstro 外部雷达', '检查中', '正在请求 /api/vedastro/status'],
],
};
window.__jyotishVedAstroStatus = { status: 'checking' };
if (status) status.textContent = '正在运行健康检查...';
renderAll();
try {
const api = await window.JyotishAPI.getAPIHealth();
const audit = await window.JyotishAPI.getCapabilityAudit();
let vedastroStatus;
try {
vedastroStatus = await window.JyotishAPI.getVedAstroStatus();
} catch (error) {
vedastroStatus = {
status: 'unavailable',
error: error?.message || '/api/vedastro/status 不可用',
};
}
window.__jyotishVedAstroStatus = vedastroStatus;
const vedastro = getVedAstroStatus();
const surfaces = audit?.surfaces || {};
const registry = audit?.registry || {};
window.__jyotishRuntimeHealth = {
status: 'ok',
checkedAt,
apiBase: api.base,
version: api.version,
latencyMs: api.latencyMs,
details: [
['PWA 安装壳', getPWAStatus().label, getPWAStatus().note],
['本地 API 服务', api.base || 'online', `health ok · v${api.version || '-'} · ${api.latencyMs}ms`],
['Swiss Ephemeris', api.swisseph_available ? (api.swisseph_version || 'available') : 'unavailable', '当前本地 API 的星历运行时状态'],
['Ayanamsa', api.ayanamsa_default || 'lahiri', '当前健康检查声明的默认 sidereal 基准'],
['Technique catalog', `${registry.technique_count || 0} techniques`, `${surfaces.api_endpoint_count || 0} API endpoints 可被前端发现`],
['VedAstro 外部雷达', vedastro.label, vedastro.note],
['Desktop path', 'PWA now', 'Pake shell 可快速包 URLTauri sidecar 等 API 生命周期和签名策略确定后再落地。'],
],
};
if (status) status.textContent = '健康检查通过:本地 API 服务、能力目录和 PWA 安装壳状态已记录。';
} catch (error) {
window.__jyotishVedAstroStatus = {
status: 'unavailable',
error: '本地 API 未连接时无法读取 /api/vedastro/status。',
};
const vedastro = getVedAstroStatus();
window.__jyotishRuntimeHealth = {
status: 'warn',
checkedAt,
error: error?.message || '运行健康检查失败',
details: [
['PWA 安装壳', getPWAStatus().label, getPWAStatus().note],
['本地 API 服务', '未连接', error?.message || '请确认本地 API 服务正在 127.0.0.1:5200 运行'],
['VedAstro 外部雷达', vedastro.label, vedastro.note],
['启动路径', '普通用户启动路径', '按 README 先启动网页服务,再启动本地 API 服务。'],
['Packaging preflight', '待检查', '运行 python3 scripts/desktop_packaging_preflight.py 获取桌面包装前置结果。'],
],
};
if (status) status.textContent = `健康检查未通过:${window.__jyotishRuntimeHealth.error}`;
}
renderAll();
}
async function runTrustCenterRealCaseRevalidation() {
const status = $('trust-center-status');
window.__jyotishRealCaseRevalidation = {
status: 'checking',
checkedAt: new Date().toISOString(),
publicReference: REAL_CASE_REVALIDATION_BASELINE.publicReference,
allChecks: REAL_CASE_REVALIDATION_BASELINE.allChecks,
controversialReference: REAL_CASE_REVALIDATION_BASELINE.controversialReference,
boundary: REAL_CASE_REVALIDATION_BASELINE.boundary,
};
if (status) status.textContent = '正在复验真实案例...';
renderAll();
try {
const result = await window.JyotishAPI.getRealCaseRevalidation();
window.__jyotishRealCaseRevalidation = {
status: result.success ? 'ok' : 'warn',
checkedAt: new Date().toISOString(),
publicReference: {
label: result.public_reference?.label || '公开人物星座级一致率',
passed: result.public_reference?.passed,
total: result.public_reference?.total,
passRate: result.public_reference?.pass_rate,
},
allChecks: {
passed: result.all_checks?.passed,
total: result.all_checks?.total,
},
controversialReference: {
caseCount: result.controversial_reference?.case_count,
note: result.controversial_reference?.note,
},
boundary: result.accuracy_boundary || REAL_CASE_REVALIDATION_BASELINE.boundary,
};
if (status) {
const ref = window.__jyotishRealCaseRevalidation.publicReference;
status.textContent = `真实案例复验完成:公开人物星座级一致率 ${ref.passed}/${ref.total}`;
}
} catch (error) {
window.__jyotishRealCaseRevalidation = {
status: 'warn',
checkedAt: new Date().toISOString(),
publicReference: REAL_CASE_REVALIDATION_BASELINE.publicReference,
allChecks: REAL_CASE_REVALIDATION_BASELINE.allChecks,
controversialReference: REAL_CASE_REVALIDATION_BASELINE.controversialReference,
boundary: `真实案例复验需要本地 API 服务:${error?.message || '请先启动本地 API。'} 这不是人生事件预测准确率。`,
};
if (status) status.textContent = `真实案例复验未完成:${error?.message || '本地 API 未连接'}`;
}
renderAll();
}
function buildVedAstroRangeScanPayload(panel) {
const birth = window.__jyotishBirth || normalizeSavedBirth(chartData || {});
if (!chartData && !window.__jyotishBirth) {
throw new Error('请先生成星盘,再运行 VedAstro 外部雷达扫描。');
}
const settings = readCalculationSettings();
return {
...birth,
domain: panel.querySelector('#vedastro-scan-domain')?.value || 'career',
start_date: panel.querySelector('#vedastro-scan-start')?.value || `${new Date().getFullYear()}-01-01`,
end_date: panel.querySelector('#vedastro-scan-end')?.value || `${new Date().getFullYear()}-12-31`,
ayanamsa_policy: settings.ayanamsa,
node_policy: settings.nodeMode,
case_id: getCurrentChartId(chartData || { birth_info: birth }) || 'user_chart',
};
}
async function runVedAstroRangeScanFromPanel(panel) {
const status = $('trust-center-status');
let payload;
try {
payload = buildVedAstroRangeScanPayload(panel);
} catch (error) {
window.__jyotishVedAstroRangeScan = { status: 'error', error: error?.message || '缺少当前星盘' };
if (status) status.textContent = window.__jyotishVedAstroRangeScan.error;
renderAll();
return;
}
window.__jyotishVedAstroRangeScan = {
status: 'running',
domain: payload.domain,
start_date: payload.start_date,
end_date: payload.end_date,
};
if (status) status.textContent = '正在运行 VedAstro 外部雷达扫描...';
renderAll();
try {
const result = await window.JyotishAPI.runVedAstroRangeScan(payload);
window.__jyotishVedAstroRangeScan = {
status: result.result?.status || 'ok',
domain: payload.domain,
start_date: payload.start_date,
end_date: payload.end_date,
result,
};
if (chartData) {
chartData.modules = chartData.modules || {};
chartData.modules.vedastro_range_scan_result = result.result;
}
if (status) status.textContent = `VedAstro Range Scan 完成:${result.result?.status || 'ok'}`;
} catch (error) {
window.__jyotishVedAstroRangeScan = {
status: 'error',
domain: payload.domain,
start_date: payload.start_date,
end_date: payload.end_date,
error: error?.message || 'VedAstro Range Scan failed',
};
if (status) status.textContent = `VedAstro Range Scan 未完成:${window.__jyotishVedAstroRangeScan.error}`;
}
renderAll();
}
async function refreshProfessionalReadingGatewayStatus() {
const status = $('professional-reading-status') || $('trust-center-status');
professionalReadingState = { ...professionalReadingState, message: '正在刷新 VedAstro Gateway...' };
if (status) status.textContent = professionalReadingState.message;
try {
const gatewayStatus = await window.JyotishAPI.getVedAstroGatewayStatus();
professionalReadingState = {
...professionalReadingState,
gatewayStatus,
message: 'VedAstro Gateway 状态已刷新。',
};
} catch (error) {
professionalReadingState = {
...professionalReadingState,
message: `VedAstro Gateway 状态未完成:${error?.message || '本地 API 未连接'}`,
};
}
renderAll();
}
async function runProfessionalReadingFromPanel() {
const status = $('professional-reading-status') || $('trust-center-status');
professionalReadingState = { ...professionalReadingState, message: '正在运行专业解盘代理...' };
if (status) status.textContent = professionalReadingState.message;
try {
const payload = buildProfessionalReadingPayload(chartData || {});
const result = await window.JyotishAPI.runProfessionalReading(payload);
professionalReadingState = {
...professionalReadingState,
result,
gatewayStatus: result?.professional_reading?.vedastro_gateway?.gateway_status || professionalReadingState.gatewayStatus || {},
message: '专业解盘包已生成。',
};
if (chartData) {
chartData.professional_reading = result?.professional_reading || result;
window.__jyotishProfessionalReading = result;
}
} catch (error) {
professionalReadingState = {
...professionalReadingState,
message: `专业解盘未完成:${error?.message || '本地 API 未连接'}`,
};
}
renderAll();
}
async function promptPWAInstall() {
const status = $('trust-center-status');
const prompt = window.__jyotishDeferredInstallPrompt;
if (!prompt) {
if (status) status.textContent = getPWAStatus().note || '当前浏览器尚未提供安装提示,可使用浏览器菜单添加到主屏幕。';
return;
}
prompt.prompt();
const result = await prompt.userChoice.catch(() => null);
window.__jyotishDeferredInstallPrompt = null;
if (status) status.textContent = result?.outcome === 'accepted' ? '已接受安装。' : '已取消安装。';
}
function saveCalculationSettingsFromPanel(panel) {
const settings = {};
panel.querySelectorAll('[data-setting-key]').forEach(input => {
settings[input.dataset.settingKey] = input.value;
});
const normalized = writeCalculationSettings(settings);
if (chartData) attachCalculationSettings(chartData, normalized);
renderAll();
}
function readTerminologyMode(value = readCalculationSettings().terminologyMode) {
return Object.prototype.hasOwnProperty.call(TERMINOLOGY_MODE_OPTIONS, value) ? value : DEFAULT_CALCULATION_SETTINGS.terminologyMode;
}
function writeTerminologyMode(mode) {
const normalized = readTerminologyMode(mode);
const settings = { ...readCalculationSettings(), terminologyMode: normalized };
writeCalculationSettings(settings);
localStorage.setItem(TERMINOLOGY_MODE_KEY, normalized);
setGlossaryTerminologyMode(normalized);
if (chartData) chartData._terminology_mode = normalized;
return normalized;
}
function getTerminologyModeOption(mode = readTerminologyMode()) {
const normalized = readTerminologyMode(mode);
return TERMINOLOGY_MODE_OPTIONS[normalized] || TERMINOLOGY_MODE_OPTIONS.beginner;
}
function renderTerminologyModePanel() {
const current = readTerminologyMode();
return `
<div class="terminology-mode-panel">
<div class="calculation-settings-head">
<strong>Terminology Mode</strong>
<span>${escapeHtml(getTerminologyModeOption(current).note)}</span>
</div>
<div class="terminology-mode-options" role="radiogroup" aria-label="术语模式">
${Object.entries(TERMINOLOGY_MODE_OPTIONS).map(([value, option]) => `
<label class="terminology-mode-option${value === current ? ' active' : ''}">
<input type="radio" name="terminology-mode" value="${escapeAttr(value)}"${value === current ? ' checked' : ''}>
<strong>${escapeHtml(option.label)}</strong>
<span>${escapeHtml(option.shortLabel)}</span>
<small>${escapeHtml(option.note)}</small>
</label>
`).join('')}
</div>
<button type="button" class="provenance-action terminology-mode-save" data-action="save-terminology-mode">保存术语模式</button>
</div>
`;
}
function saveTerminologyModeFromPanel(panel) {
const selected = panel.querySelector('input[name="terminology-mode"]:checked')?.value;
writeTerminologyMode(selected);
renderAll();
}
function buildPanchangaRows(panchanga) {
if (!panchanga) return [['状态', '不可用', '当前星盘缺少太阳/月亮经度']];
return [
['Vara', panchanga.vara || '-', '星期/日主'],
['Tithi', `${panchanga.tithi?.number || '-'} · ${panchanga.tithi?.name || '-'}`, panchanga.tithi?.paksha || ''],
['Karana', panchanga.karana?.name || panchanga.karana || '-', '半 Tithi'],
['Yoga', `${panchanga.yoga?.number || '-'} · ${panchanga.yoga?.name || '-'}`, '日月合经'],
];
}
function renderTithiLordInsight(analysis) {
if (!analysis || !analysis.tithi_lord) return '';
const doshaCount = Array.isArray(analysis.doshas) ? analysis.doshas.length : 0;
return `
<div class="tithi-lord-insight">
<div class="provenance-head">
<span>Tithi Lord</span>
<strong>${escapeHtml(analysis.tithi_lord)} · ${escapeHtml(String(Math.round((analysis.tithi_score || 0) * 100)))}%</strong>
</div>
<p>${escapeHtml(analysis.relationship_style || analysis.emotional_pattern || '已接入 Tithi 主星分析。')}</p>
<div class="tithi-chip-row">
<span>${escapeHtml(analysis.tithi_type_cn || analysis.tithi_type || 'Tithi')}</span>
<span>${escapeHtml(analysis.lord_sign || '-')} H${escapeHtml(String(analysis.lord_house || '-'))}</span>
<span>${doshaCount ? `${doshaCount} dosha` : 'no dosha'}</span>
</div>
</div>
`;
}
function renderChartWorkspaceList(lib) {
if (!Array.isArray(lib) || !lib.length) {
return '<div class="workspace-library empty">暂无保存星盘。保存当前盘后可从这里直接打开。</div>';
}
return `
<div class="workspace-library">
<label>
<span>本地星盘库</span>
<select id="workspace-chart-select">
${lib.map(entry => `<option value="${escapeAttr(entry.id)}">${escapeHtml(formatWorkspaceCaseTitle(entry, 'chart'))}</option>`).join('')}
</select>
</label>
<div class="workspace-chart-list">
${lib.slice(0, 4).map(entry => `
<div class="workspace-chart-row">
<strong>${escapeHtml(entry.label || entry.id || '未命名星盘')}</strong>
${renderCaseMetaLine(entry, 'chart')}
<span>${escapeHtml(formatSavedAt(entry.savedAt || entry.updatedAt))}</span>
</div>
`).join('')}
</div>
</div>
`;
}
function renderCaseWorkspaceSummary(pairs, prashnas) {
const filter = readCaseWorkspaceFilter();
const chartLibrary = sortChartLibrary(readChartLibrary());
const filteredCharts = filterCaseRecords(chartLibrary, filter, 'chart');
const filteredPairs = filterCaseRecords(pairs || [], filter, 'pair');
const filteredPrashnas = filterCaseRecords(prashnas || [], filter, 'prashna');
pruneCaseWorkspaceSelection(chartLibrary, pairs || [], prashnas || []);
return `
<div id="case-workspace-summary" class="workspace-library case-workspace-summary">
<div id="case-workspace-counts" class="case-workspace-counts">
${renderCaseWorkspaceCounts(chartLibrary, pairs || [], prashnas || [], filteredCharts, filteredPairs, filteredPrashnas)}
</div>
<div class="case-workspace-controls">
<input type="search" id="workspace-case-search" placeholder="搜索问题、对象或结论" value="${escapeAttr(filter.query)}">
<select id="workspace-case-type">
<option value="all"${filter.type === 'all' ? ' selected' : ''}>全部案例</option>
<option value="chart"${filter.type === 'chart' ? ' selected' : ''}>只看星盘</option>
<option value="pair"${filter.type === 'pair' ? ' selected' : ''}>只看配对</option>
<option value="prashna"${filter.type === 'prashna' ? ' selected' : ''}>只看问事</option>
</select>
<select id="workspace-case-group">
${CASE_GROUP_PRESETS.map(group => `<option value="${escapeAttr(group)}"${filter.group === group ? ' selected' : ''}>${escapeHtml(group)}</option>`).join('')}
</select>
<select id="workspace-case-relation">
${CASE_RELATION_PRESETS.map(([value, label]) => `<option value="${escapeAttr(value)}"${filter.relation === value ? ' selected' : ''}>${escapeHtml(label)}</option>`).join('')}
</select>
</div>
<div class="case-bulk-actions">
<button type="button" class="mini-action" data-action="workspace-select-visible">全选当前</button>
<button type="button" class="mini-action" data-action="workspace-clear-selection">清空选择</button>
<button type="button" class="mini-action" data-action="workspace-export-selected-cases">导出已选</button>
<button type="button" class="mini-action danger" data-action="workspace-delete-selected-cases">删除已选</button>
</div>
<div id="workspace-case-list" class="workspace-chart-list">
${renderCaseWorkspaceRows(filteredCharts, filteredPairs, filteredPrashnas, filter)}
</div>
<div id="workspace-case-preview" class="case-preview-panel">
${renderWorkspaceCasePreview()}
</div>
</div>
`;
}
function renderCaseWorkspaceCounts(charts, pairs, prashnas, filteredCharts, filteredPairs, filteredPrashnas) {
return `
<span>星盘 ${escapeHtml(String((charts || []).length))}</span>
<span>配对 ${escapeHtml(String((pairs || []).length))}</span>
<span>问事 ${escapeHtml(String((prashnas || []).length))}</span>
<span>显示 ${escapeHtml(String((filteredCharts || []).length + (filteredPairs || []).length + (filteredPrashnas || []).length))}</span>
<span>已选 ${escapeHtml(String(_caseWorkspaceSelection.size))}</span>
`;
}
function renderCaseWorkspaceRows(filteredCharts, filteredPairs, filteredPrashnas, filter) {
const chartRows = filteredCharts.slice(0, 4).map(record => `
<div class="workspace-chart-row workspace-case-row">
${renderCaseSelectControl('chart', record.id || '', '星盘')}
<div>
<strong>${escapeHtml(record.label || record.id || '未命名星盘')}</strong>
${renderCaseMetaLine(record, 'chart')}
<span>${escapeHtml(formatSavedAt(record.updatedAt || record.savedAt))}</span>
</div>
<div class="case-row-actions">
<button type="button" class="mini-action" data-action="workspace-preview-case" data-case-kind="chart" data-case-id="${escapeAttr(record.id || '')}">预览</button>
<button type="button" class="mini-action" data-action="workspace-edit-case" data-case-kind="chart" data-case-id="${escapeAttr(record.id || '')}">编辑</button>
<button type="button" class="mini-action" data-action="workspace-open-chart" data-case-id="${escapeAttr(record.id || '')}">打开</button>
</div>
</div>
`).join('');
const pairRows = filteredPairs.slice(0, 4).map(record => `
<div class="workspace-chart-row workspace-case-row">
${renderCaseSelectControl('pair', record.id || '', '配对')}
<div>
<strong>${escapeHtml(record.label || record.id || '未命名配对')}</strong>
${renderCaseMetaLine(record, 'pair')}
<span>${escapeHtml(record.score?.total ?? 0)} / ${escapeHtml(record.score?.max ?? 36)} · ${escapeHtml(record.verdict || '-')}</span>
</div>
<div class="case-row-actions">
<button type="button" class="mini-action" data-action="workspace-preview-case" data-case-kind="pair" data-case-id="${escapeAttr(record.id || '')}">预览</button>
<button type="button" class="mini-action" data-action="workspace-edit-case" data-case-kind="pair" data-case-id="${escapeAttr(record.id || '')}">编辑</button>
<button type="button" class="mini-action" data-action="workspace-open-pair" data-case-id="${escapeAttr(record.id || '')}">打开</button>
<button type="button" class="mini-action danger" data-action="workspace-delete-pair" data-case-id="${escapeAttr(record.id || '')}">删除</button>
</div>
</div>
`).join('');
const prashnaRows = filteredPrashnas.slice(0, 4).map(record => `
<div class="workspace-chart-row workspace-case-row">
${renderCaseSelectControl('prashna', record.id || '', '问事')}
<div>
<strong>${escapeHtml(record.label || record.id || '未命名问事')}</strong>
${renderCaseMetaLine(record, 'prashna')}
<span>${escapeHtml(record.conclusion || '-')} · ${escapeHtml(formatSavedAt(record.updatedAt || record.generatedAt))}</span>
</div>
<div class="case-row-actions">
<button type="button" class="mini-action" data-action="workspace-preview-case" data-case-kind="prashna" data-case-id="${escapeAttr(record.id || '')}">预览</button>
<button type="button" class="mini-action" data-action="workspace-edit-case" data-case-kind="prashna" data-case-id="${escapeAttr(record.id || '')}">编辑</button>
<button type="button" class="mini-action" data-action="workspace-open-prashna" data-case-id="${escapeAttr(record.id || '')}">打开</button>
<button type="button" class="mini-action danger" data-action="workspace-delete-prashna" data-case-id="${escapeAttr(record.id || '')}">删除</button>
</div>
</div>
`).join('');
return `
${filter.type !== 'pair' && filter.type !== 'prashna' ? chartRows : ''}
${filter.type !== 'prashna' ? pairRows : ''}
${filter.type !== 'pair' ? prashnaRows : ''}
${!chartRows && !pairRows && !prashnaRows ? '<div class="workspace-library empty">暂无匹配案例。</div>' : ''}
`;
}
function refreshCaseWorkspaceList() {
const countContainer = $('case-workspace-counts');
const listContainer = $('workspace-case-list');
if (!countContainer || !listContainer) return;
const charts = sortChartLibrary(readChartLibrary());
const pairs = sortCaseLibrary(readSynastryPairLibrary());
const prashnas = sortCaseLibrary(readPrashnaCaseLibrary());
pruneCaseWorkspaceSelection(charts, pairs, prashnas);
const filter = readCaseWorkspaceFilter();
const filteredCharts = filterCaseRecords(charts, filter, 'chart');
const filteredPairs = filterCaseRecords(pairs, filter, 'pair');
const filteredPrashnas = filterCaseRecords(prashnas, filter, 'prashna');
countContainer.innerHTML = renderCaseWorkspaceCounts(charts, pairs, prashnas, filteredCharts, filteredPairs, filteredPrashnas);
listContainer.innerHTML = renderCaseWorkspaceRows(filteredCharts, filteredPairs, filteredPrashnas, filter);
renderWorkspaceCasePreviewPanel();
}
function readCaseWorkspaceFilter() {
return {
query: $('workspace-case-search')?.value?.trim() || '',
type: $('workspace-case-type')?.value || 'all',
group: $('workspace-case-group')?.value || '全部分组',
relation: $('workspace-case-relation')?.value || 'all',
};
}
function filterCaseRecords(records, filter, kind) {
if (filter.type !== 'all' && filter.type !== kind) return [];
let filtered = Array.isArray(records) ? records : [];
if (filter.group && filter.group !== '全部分组') {
filtered = filtered.filter(record => getCaseGroupLabel(record, kind) === filter.group);
}
if (filter.relation && filter.relation !== 'all') {
filtered = filtered.filter(record => getCaseRelationValue(record, kind) === filter.relation);
}
const query = normalizeCaseSearchText(filter.query);
if (!query) return filtered;
return filtered.filter(record => normalizeCaseSearchText([
record?.label,
record?.group,
getCaseRelationLabel(record, kind),
...(Array.isArray(record?.tags) ? record.tags : []),
record?.question_text,
record?.question_type,
record?.conclusion,
record?.verdict,
record?.self?.ascendant,
record?.partner?.ascendant,
record?.chartLabel,
record?.id,
].filter(Boolean).join(' ')).includes(query));
}
function normalizeCaseSearchText(value) {
return String(value || '').trim().toLowerCase();
}
function buildCaseSelectionKey(kind, id) {
return `${kind}:${id}`;
}
function renderCaseSelectControl(kind, id, label) {
const key = buildCaseSelectionKey(kind, id || '');
return `
<label class="case-select-control" title="选择案例">
<input type="checkbox" data-action="workspace-toggle-case" data-case-key="${escapeAttr(key)}" ${_caseWorkspaceSelection.has(key) ? 'checked' : ''}>
<span>${escapeHtml(label)}</span>
</label>
`;
}
function pruneCaseWorkspaceSelection(charts, pairs, prashnas) {
const valid = new Set([
...(charts || []).filter(item => item?.id).map(item => buildCaseSelectionKey('chart', item.id)),
...(pairs || []).filter(item => item?.id).map(item => buildCaseSelectionKey('pair', item.id)),
...(prashnas || []).filter(item => item?.id).map(item => buildCaseSelectionKey('prashna', item.id)),
]);
_caseWorkspaceSelection = new Set([..._caseWorkspaceSelection].filter(key => valid.has(key)));
}
function getVisibleCaseSelectionKeys() {
return [...document.querySelectorAll('#workspace-case-list [data-case-key]')]
.map(input => input.dataset.caseKey)
.filter(Boolean);
}
function resolveSelectedCaseRecords() {
const charts = readChartLibrary();
const pairs = readSynastryPairLibrary();
const prashnas = readPrashnaCaseLibrary();
const selected = [..._caseWorkspaceSelection];
return {
chartRecords: selected
.filter(key => key.startsWith('chart:'))
.map(key => charts.find(record => record?.id === key.slice(6)))
.filter(Boolean),
pairRecords: selected
.filter(key => key.startsWith('pair:'))
.map(key => pairs.find(record => record?.id === key.slice(5)))
.filter(Boolean),
prashnaRecords: selected
.filter(key => key.startsWith('prashna:'))
.map(key => prashnas.find(record => record?.id === key.slice(8)))
.filter(Boolean),
};
}
function previewWorkspaceCase(kind, id) {
const record = findWorkspaceCaseRecord(kind, id);
if (!record) return;
_caseWorkspacePreview = { kind, id };
renderWorkspaceCasePreviewPanel();
}
function findWorkspaceCaseRecord(kind, id) {
if (!id) return null;
if (kind === 'pair') return readSynastryPairLibrary().find(record => record?.id === id) || null;
if (kind === 'prashna') return readPrashnaCaseLibrary().find(record => record?.id === id) || null;
return readChartLibrary().find(record => record?.id === id) || null;
}
function renderWorkspaceCasePreviewPanel() {
const panel = $('workspace-case-preview');
if (!panel) return;
panel.innerHTML = renderWorkspaceCasePreview();
}
function renderWorkspaceCasePreview() {
if (!_caseWorkspacePreview) {
return '<div class="case-preview-empty">选择一条案例后,可在这里快速查看摘要。</div>';
}
const { kind, id } = _caseWorkspacePreview;
const record = findWorkspaceCaseRecord(kind, id);
if (!record) {
_caseWorkspacePreview = null;
return '<div class="case-preview-empty">所选案例已不存在。</div>';
}
const meta = normalizeWorkspaceMeta(record, getCaseDefaultMeta(kind));
const title = record.label || record.question_text || record.id || '未命名案例';
return `
<div class="case-preview-head">
<div>
<span>${escapeHtml(getCaseKindLabel(kind))}</span>
<strong>${escapeHtml(title)}</strong>
</div>
<button type="button" class="mini-action" data-action="workspace-clear-preview">收起</button>
</div>
${renderCaseMetaLine(record, kind)}
<div class="case-preview-grid">
${renderWorkspaceCasePreviewCards(record, kind)}
</div>
<div class="case-preview-footer">
<span>${escapeHtml(meta.group)} · ${escapeHtml(getCaseRelationLabel(record, kind))}</span>
<span>${escapeHtml(formatSavedAt(record.updatedAt || record.generatedAt || record.savedAt))}</span>
</div>
`;
}
function renderWorkspaceCasePreviewCards(record, kind) {
if (kind === 'pair') return renderSynastryCasePreviewCards(record);
if (kind === 'prashna') return renderPrashnaCasePreviewCards(record);
return renderChartCasePreviewCards(record);
}
function renderChartCasePreviewCards(record) {
const data = record.data || {};
const birth = data.birth_info || {};
const asc = data.ascendant || {};
const moon = data.planets?.Moon || {};
return [
['出生', `${birth.date || '-'} ${birth.time || ''}`.trim() || '-'],
['上升', asc.sign ? signName(asc.sign) : '-'],
['月亮', moon.sign ? `${signName(moon.sign)} ${moon.nakshatra || ''}`.trim() : '-'],
['坐标', birth.lat != null && birth.lon != null ? `${safeNumber(birth.lat).toFixed(3)}, ${safeNumber(birth.lon).toFixed(3)}` : '-'],
].map(renderCasePreviewCard).join('');
}
function renderSynastryCasePreviewCards(record) {
const score = record.score || {};
const deep = record.deep || {};
return [
['合盘分', `${score.total ?? 0} / ${score.max ?? 36}${score.percentage != null ? ` · ${score.percentage}%` : ''}`],
['判断', record.verdict || '-'],
['对方', record.partner?.moon || record.partner?.ascendant || '-'],
['节奏', deep.dasha?.note || deep.dasha?.label || '-'],
].map(renderCasePreviewCard).join('');
}
function renderPrashnaCasePreviewCards(record) {
return [
['问题', record.question_text || record.question_type || '-'],
['结论', record.conclusion || '-'],
['置信度', record.confidence || '-'],
['下一步', record.next_action || '-'],
].map(renderCasePreviewCard).join('');
}
function renderCasePreviewCard([label, value]) {
return `<div class="case-preview-card"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value || '-')}</strong></div>`;
}
function getCaseKindLabel(kind) {
if (kind === 'pair') return '配对';
if (kind === 'prashna') return '问事';
return '星盘';
}
function editWorkspaceCaseMetadata(kind, id) {
if (!id) return;
const store = getWorkspaceCaseStore(kind);
const records = store.read();
const record = records.find(item => item?.id === id);
if (!record) return;
const fallback = getCaseDefaultMeta(kind);
const currentMeta = normalizeWorkspaceMeta(record, fallback);
const label = window.prompt('案例标题', record.label || record.id || '');
if (label === null) return;
const group = window.prompt(`分组(${CASE_GROUP_PRESETS.slice(1).join(' / ')}`, currentMeta.group || fallback.group);
if (group === null) return;
const relation = window.prompt(`关系类型(${CASE_RELATION_PRESETS.filter(([key]) => key !== 'all').map(([key]) => key).join(' / ')}`, currentMeta.relation || fallback.relation);
if (relation === null) return;
const tags = window.prompt('标签(逗号分隔)', currentMeta.tags.join(', '));
if (tags === null) return;
const updated = applyWorkspaceCaseMetadata(record, { label, group, relation, tags }, fallback);
store.write(records.map(item => item?.id === id ? updated : item));
renderSynastryPairWorkspace();
renderPrashnaCaseWorkspace();
refreshCaseWorkspaceList();
}
function getWorkspaceCaseStore(kind) {
if (kind === 'pair') {
return { read: readSynastryPairLibrary, write: value => writeSynastryPairLibrary(sortCaseLibrary(value).slice(0, 80)) };
}
if (kind === 'prashna') {
return { read: readPrashnaCaseLibrary, write: value => writePrashnaCaseLibrary(sortCaseLibrary(value).slice(0, 80)) };
}
return { read: readChartLibrary, write: value => writeChartLibrary(sortChartLibrary(value).slice(0, 24)) };
}
function applyWorkspaceCaseMetadata(record, input, fallback = {}) {
const tags = String(input.tags || '')
.split(',')
.map(tag => tag.trim())
.filter(Boolean);
const relationKeys = new Set(CASE_RELATION_PRESETS.map(([key]) => key));
const relation = relationKeys.has(input.relation) && input.relation !== 'all'
? input.relation
: (fallback.relation || 'unknown');
return {
...record,
label: String(input.label || record.label || record.id || '').trim() || record.label || record.id,
group: String(input.group || '').trim() || fallback.group || '未分组',
relation,
tags: tags.length ? tags : (fallback.tags || []),
updatedAt: new Date().toISOString(),
};
}
function formatSavedAt(value) {
if (!value) return '未记录保存时间';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function bindProvenanceActions() {
const panel = $('provenance-panel');
if (!panel || panel.dataset.bound === 'true') return;
panel.dataset.bound = 'true';
panel.addEventListener('click', event => {
const btn = event.target.closest('.provenance-action, .mini-action');
if (!btn) return;
if (btn.dataset.action === 'export-json') {
const item = document.querySelector('.export-item[data-format="json"]');
if (item) item.click();
}
if (btn.dataset.action === 'export-html') {
const item = document.querySelector('.export-item[data-format="html"]');
if (item) item.click();
}
if (btn.dataset.action === 'save-calculation-settings') {
saveCalculationSettingsFromPanel(panel);
}
if (btn.dataset.action === 'save-terminology-mode') {
saveTerminologyModeFromPanel(panel);
}
if (btn.dataset.action === 'panchanga-week') {
setPanchangaRangePreset('week');
renderPanchangaRange(panel);
}
if (btn.dataset.action === 'panchanga-month') {
setPanchangaRangePreset('month');
renderPanchangaRange(panel);
}
if (btn.dataset.action === 'panchanga-range') {
renderPanchangaRange(panel);
}
if (btn.dataset.action === 'panchanga-csv') {
exportPanchangaRangeCSV(panel);
}
if (btn.dataset.action === 'panchanga-ics') {
exportPanchangaRangeICS(panel);
}
if (btn.dataset.action === 'workspace-save-current') {
saveCurrentChartToWorkspace(panel);
}
if (btn.dataset.action === 'workspace-open-selected') {
openSelectedWorkspaceChart(panel);
}
if (btn.dataset.action === 'workspace-delete-selected') {
deleteSelectedWorkspaceChart();
}
if (btn.dataset.action === 'trust-run-health') {
runTrustCenterHealthCheck();
}
if (btn.dataset.action === 'vedastro-run-range-scan') {
runVedAstroRangeScanFromPanel(panel);
}
if (btn.dataset.action === 'professional-reading-gateway') {
refreshProfessionalReadingGatewayStatus();
}
if (btn.dataset.action === 'professional-reading-run') {
runProfessionalReadingFromPanel();
}
if (btn.dataset.action === 'pwa-install') {
promptPWAInstall();
}
if (btn.dataset.action === 'trust-export-local') {
exportTrustCenterLocalData();
}
if (btn.dataset.action === 'trust-clear-local') {
clearTrustCenterLocalData();
}
if (btn.dataset.action === 'oracle-download-packet') {
downloadOracleEvidencePacket(btn.dataset.caseId || '');
}
if (btn.dataset.action === 'workspace-export-selected') {
exportSelectedWorkspaceChart();
}
if (btn.dataset.action === 'workspace-export-cases') {
exportWorkspaceCaseLibrary();
}
if (btn.dataset.action === 'workspace-select-visible') {
selectVisibleWorkspaceCases();
}
if (btn.dataset.action === 'workspace-clear-selection') {
_caseWorkspaceSelection.clear();
refreshCaseWorkspaceList();
}
if (btn.dataset.action === 'workspace-export-selected-cases') {
exportSelectedWorkspaceCases();
}
if (btn.dataset.action === 'workspace-delete-selected-cases') {
deleteSelectedWorkspaceCases();
}
if (btn.dataset.action === 'workspace-edit-case') {
editWorkspaceCaseMetadata(btn.dataset.caseKind || 'chart', btn.dataset.caseId || '');
}
if (btn.dataset.action === 'workspace-preview-case') {
previewWorkspaceCase(btn.dataset.caseKind || 'chart', btn.dataset.caseId || '');
}
if (btn.dataset.action === 'workspace-clear-preview') {
_caseWorkspacePreview = null;
renderWorkspaceCasePreviewPanel();
}
if (btn.dataset.action === 'workspace-open-pair') {
openSavedSynastryPair(btn.dataset.caseId || '');
}
if (btn.dataset.action === 'workspace-open-chart') {
openSavedChartFromPanel(btn.dataset.caseId || '');
}
if (btn.dataset.action === 'workspace-delete-pair') {
deleteSavedSynastryPair(btn.dataset.caseId || '');
}
if (btn.dataset.action === 'workspace-open-prashna') {
openSavedPrashnaCase(btn.dataset.caseId || '');
}
if (btn.dataset.action === 'workspace-delete-prashna') {
deleteSavedPrashnaCase(btn.dataset.caseId || '');
}
if (btn.dataset.action === 'open-ai-library') {
const fab = document.querySelector('.ai-fab');
if (fab) fab.click();
}
});
panel.addEventListener('change', event => {
const input = event.target.closest('#workspace-case-import-file');
if (input) {
importWorkspaceCaseLibrary(input.files?.[0]);
input.value = '';
return;
}
const oracleInput = event.target.closest('#oracle-evidence-upload');
if (oracleInput) {
importOracleEvidencePacket(oracleInput.files?.[0]);
oracleInput.value = '';
return;
}
const caseToggle = event.target.closest('[data-action="workspace-toggle-case"]');
if (caseToggle) {
if (caseToggle.checked) {
_caseWorkspaceSelection.add(caseToggle.dataset.caseKey || '');
} else {
_caseWorkspaceSelection.delete(caseToggle.dataset.caseKey || '');
}
_caseWorkspaceSelection.delete('');
refreshCaseWorkspaceList();
return;
}
if (event.target.matches('input[name="terminology-mode"]')) {
panel.querySelectorAll('.terminology-mode-option').forEach(option => option.classList.remove('active'));
event.target.closest('.terminology-mode-option')?.classList.add('active');
setGlossaryTerminologyMode(readTerminologyMode(event.target.value));
return;
}
if (event.target.closest('#workspace-case-type')) {
refreshCaseWorkspaceList();
}
if (event.target.closest('#workspace-case-group')) {
refreshCaseWorkspaceList();
}
if (event.target.closest('#workspace-case-relation')) {
refreshCaseWorkspaceList();
}
});
panel.addEventListener('input', event => {
if (event.target.closest('#workspace-case-search')) {
refreshCaseWorkspaceList();
}
});
}
function getSelectedWorkspaceEntry() {
const select = $('workspace-chart-select');
const id = select?.value;
if (!id) return null;
return sortChartLibrary(readChartLibrary()).find(entry => entry?.id === id) || null;
}
function saveCurrentChartToWorkspace(panel) {
const source = panel?._panchangaChartData || chartData;
if (!source) return;
const id = buildWorkspaceChartId(source);
if (!id) return;
const lib = readChartLibrary();
const existing = findChartLibraryEntry(lib, source);
if (existing) {
const meta = normalizeWorkspaceMeta(existing, buildDefaultChartWorkspaceMeta(source));
existing.id = id;
existing.data = source;
existing.label = existing.label || buildWorkspaceChartLabel(source);
existing.group = meta.group;
existing.relation = meta.relation;
existing.tags = meta.tags;
existing.updatedAt = new Date().toISOString();
} else {
const meta = buildDefaultChartWorkspaceMeta(source);
lib.push({
id,
label: buildWorkspaceChartLabel(source),
data: source,
...meta,
savedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
writeChartLibrary(sortChartLibrary(lib).slice(0, 24));
renderAll();
}
function openSelectedWorkspaceChart(panel) {
const entry = getSelectedWorkspaceEntry();
if (!entry?.data) return;
chartData = entry.data;
panel._panchangaChartData = chartData;
renderAll();
showPage('chart');
aiChatSetChartData(chartData);
}
function deleteSelectedWorkspaceChart() {
const entry = getSelectedWorkspaceEntry();
if (!entry) return;
if (!window.confirm(`删除「${entry.label || entry.id}」?`)) return;
writeChartLibrary(readChartLibrary().filter(item => item?.id !== entry.id));
renderAll();
}
function exportSelectedWorkspaceChart() {
const entry = getSelectedWorkspaceEntry();
if (!entry) return;
downloadText(JSON.stringify(entry, null, 2), `jyotish-chart-${entry.id || 'saved'}.json`, 'application/json;charset=utf-8');
}
function exportWorkspaceCaseLibrary() {
const payload = {
exportedAt: new Date().toISOString(),
charts: sortChartLibrary(readChartLibrary()).map(({ data, ...entry }) => ({
...entry,
meta: normalizeWorkspaceMeta(entry, buildDefaultChartWorkspaceMeta(data)),
})),
synastry_pairs: sortCaseLibrary(readSynastryPairLibrary()),
prashna_cases: sortCaseLibrary(readPrashnaCaseLibrary()),
};
downloadText(JSON.stringify(payload, null, 2), `jyotish-case-library-${toDateISO(new Date())}.json`, 'application/json;charset=utf-8');
}
function selectVisibleWorkspaceCases() {
getVisibleCaseSelectionKeys().forEach(key => _caseWorkspaceSelection.add(key));
refreshCaseWorkspaceList();
}
function exportSelectedWorkspaceCases() {
const { chartRecords, pairRecords, prashnaRecords } = resolveSelectedCaseRecords();
if (!chartRecords.length && !pairRecords.length && !prashnaRecords.length) return;
const payload = {
exportedAt: new Date().toISOString(),
charts: chartRecords.map(({ data, ...entry }) => entry),
synastry_pairs: pairRecords,
prashna_cases: prashnaRecords,
};
downloadText(JSON.stringify(payload, null, 2), `jyotish-selected-cases-${toDateISO(new Date())}.json`, 'application/json;charset=utf-8');
}
function deleteSelectedWorkspaceCases() {
const { chartRecords, pairRecords, prashnaRecords } = resolveSelectedCaseRecords();
const count = chartRecords.length + pairRecords.length + prashnaRecords.length;
if (!count) return;
if (!window.confirm(`删除已选 ${count} 条案例?此操作只会删除本地案例库记录。`)) return;
const chartIds = new Set(chartRecords.map(record => record.id));
const pairIds = new Set(pairRecords.map(record => record.id));
const prashnaIds = new Set(prashnaRecords.map(record => record.id));
writeChartLibrary(readChartLibrary().filter(record => !chartIds.has(record?.id)));
writeSynastryPairLibrary(readSynastryPairLibrary().filter(record => !pairIds.has(record?.id)));
writePrashnaCaseLibrary(readPrashnaCaseLibrary().filter(record => !prashnaIds.has(record?.id)));
_caseWorkspaceSelection.clear();
_caseWorkspacePreview = null;
renderSynastryPairWorkspace();
renderPrashnaCaseWorkspace();
renderAll();
}
async function importWorkspaceCaseLibrary(file) {
const status = $('workspace-case-import-status');
if (!file) return;
if (!file.name.toLowerCase().endsWith('.json') && file.type !== 'application/json') {
if (status) status.textContent = '请选择 JSON 案例库文件。';
return;
}
try {
const payload = JSON.parse(await readWorkspaceFileAsText(file));
const imported = mergeWorkspaceCaseLibrary(payload);
if (status) {
status.textContent = `已导入:星盘 ${imported.charts}、配对 ${imported.pairs}、问事 ${imported.prashnas}`;
}
renderAll();
} catch (error) {
if (status) status.textContent = `导入失败:${error?.message || 'JSON 格式不正确'}`;
}
}
function readWorkspaceFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('文件读取失败'));
reader.readAsText(file);
});
}
function mergeWorkspaceCaseLibrary(payload) {
const charts = Array.isArray(payload?.charts) ? payload.charts : [];
const pairs = Array.isArray(payload?.synastry_pairs) ? payload.synastry_pairs : [];
const prashnas = Array.isArray(payload?.prashna_cases) ? payload.prashna_cases : [];
if (!charts.length && !pairs.length && !prashnas.length) {
throw new Error('未找到 charts、synastry_pairs 或 prashna_cases。');
}
const validCharts = charts.filter(item => item?.id);
const validPairs = pairs.filter(item => item?.id);
const validPrashnas = prashnas.filter(item => item?.id);
if (!validCharts.length && !validPairs.length && !validPrashnas.length) {
throw new Error('案例库记录缺少 id,无法导入。');
}
const chartLib = mergeLibraryById(readChartLibrary(), validCharts, getCaseDefaultMeta('chart'));
const pairLib = mergeLibraryById(readSynastryPairLibrary(), validPairs, getCaseDefaultMeta('pair'));
const prashnaLib = mergeLibraryById(readPrashnaCaseLibrary(), validPrashnas, getCaseDefaultMeta('prashna'));
writeChartLibrary(sortChartLibrary(chartLib).slice(0, 24));
writeSynastryPairLibrary(sortCaseLibrary(pairLib).slice(0, 80));
writePrashnaCaseLibrary(sortCaseLibrary(prashnaLib).slice(0, 80));
return { charts: validCharts.length, pairs: validPairs.length, prashnas: validPrashnas.length };
}
function mergeLibraryById(current, incoming, defaultMeta = {}) {
const byId = new Map((Array.isArray(current) ? current : []).filter(item => item?.id).map(item => [item.id, item]));
for (const item of incoming) {
const merged = { ...(byId.get(item.id) || {}), ...item, importedAt: new Date().toISOString() };
const meta = normalizeWorkspaceMeta(merged, merged.meta || defaultMeta);
byId.set(item.id, { ...merged, ...meta });
}
return [...byId.values()];
}
function openSavedSynastryPair(caseId) {
const record = readSynastryPairLibrary().find(item => item?.id === caseId);
if (!record) return;
_lastSynastryPairRecord = record;
const result = $('synastry-result');
if (result) {
result.innerHTML = renderSavedSynastryPair(record);
bindTerms(result);
}
showPage('chart');
switchToTab('synastry');
}
function deleteSavedSynastryPair(caseId) {
const record = readSynastryPairLibrary().find(item => item?.id === caseId);
if (!record) return;
if (!window.confirm(`删除配对「${record.label || record.id}」?`)) return;
writeSynastryPairLibrary(readSynastryPairLibrary().filter(item => item?.id !== caseId));
if (_caseWorkspacePreview?.kind === 'pair' && _caseWorkspacePreview.id === caseId) _caseWorkspacePreview = null;
renderSynastryPairWorkspace();
renderAll();
}
function openSavedPrashnaCase(caseId) {
const record = readPrashnaCaseLibrary().find(item => item?.id === caseId);
if (!record) return;
_lastPrashnaCaseRecord = record;
recordPrashnaWorkflow(record.data || {}, record.question_text || '', record.question_type || 'general');
const result = $('prashna-result');
if (result) {
result.innerHTML = renderPrashnaResult(record.data || {}, record.question_text || '');
bindTerms(result);
}
showPage('chart');
switchToTab('prashna');
}
function deleteSavedPrashnaCase(caseId) {
const record = readPrashnaCaseLibrary().find(item => item?.id === caseId);
if (!record) return;
if (!window.confirm(`删除问事「${record.question_text || record.label || record.id}」?`)) return;
writePrashnaCaseLibrary(readPrashnaCaseLibrary().filter(item => item?.id !== caseId));
if (_caseWorkspacePreview?.kind === 'prashna' && _caseWorkspacePreview.id === caseId) _caseWorkspacePreview = null;
renderPrashnaCaseWorkspace();
renderAll();
}
function setPanchangaRangePreset(mode) {
const startEl = $('panchanga-start');
const endEl = $('panchanga-end');
if (!startEl || !endEl) return;
const base = parseDateISO(startEl.value || toDateISO(new Date()));
if (mode === 'month') {
const first = new Date(base.getFullYear(), base.getMonth(), 1);
const last = new Date(base.getFullYear(), base.getMonth() + 1, 0);
startEl.value = toDateISO(first);
endEl.value = toDateISO(last);
return;
}
startEl.value = toDateISO(base);
endEl.value = toDateISO(addDays(base, 6));
}
async function renderPanchangaRange(panel) {
const output = $('panchanga-range-result');
const source = panel?._panchangaChartData || chartData;
if (!output) return;
if (!source) {
output.innerHTML = '<div class="panchanga-week-error">请先生成星盘。</div>';
return;
}
if (!window.JyotishAPI?.computePanchangaRange) {
output.innerHTML = '<div class="panchanga-week-error">当前 API bridge 未提供 /api/panchanga_range。</div>';
return;
}
output.innerHTML = '<div class="panchanga-week-loading">正在生成 Panchanga 日历...</div>';
try {
const payload = readPanchangaRangeInputs();
const result = await window.JyotishAPI.computePanchangaRange(payload);
const report = result?.report || {};
const allRows = normalizePanchangaRangeRows(report.days || []);
const selectedConditions = normalizePanchangaConditions(payload.conditions || payload.condition || []);
const conditionMode = normalizePanchangaConditionMode(payload.condition_mode || payload.conditionMode);
const rows = filterPanchangaRowsByCondition(allRows, selectedConditions, conditionMode);
const activityLabel = getPanchangaActivityLabel(payload.activity || 'all');
const conditionLabel = getPanchangaConditionSelectionLabel(selectedConditions, conditionMode);
panel._lastPanchangaRange = { report, rows, allRows, activity: payload.activity || 'all', condition: selectedConditions[0] || 'all', conditions: selectedConditions, conditionMode };
output.innerHTML = `
<div class="panchanga-range-summary">
<strong>${escapeHtml(report.start_date || payload.start_date)}${escapeHtml(report.end_date || payload.end_date)}</strong>
<span>${escapeHtml(String(rows.length))}/${escapeHtml(String(report.day_count || allRows.length))} 天 · ${escapeHtml(activityLabel)} · ${escapeHtml(conditionLabel)} · ${escapeHtml(report.calculation_policy?.sunrise_sunset || report.calculation_policy?.inauspicious_periods || 'daytime eight-segment rule')}</span>
${renderPanchangaLocationSummary(report.location, source)}
</div>
${renderPanchangaConditionGuide(selectedConditions, conditionMode, report.search_summary)}
${renderPanchangaFestivalDetails(rows)}
${renderPanchangaMonthGrid(report.month_grid || [])}
<div class="panchanga-week-table-wrap">
<table class="panchanga-week-table">
<thead>
<tr><th>日期</th><th>质量</th><th>活动</th><th>条件</th><th>标签</th><th>吉时窗口</th><th>Tithi</th><th>Nakshatra</th><th>Yoga</th><th>结束时间</th><th>日出/日落</th><th>Rahu Kala</th><th>Yamaganda</th><th>Gulika</th></tr>
</thead>
<tbody>
${rows.length ? rows.map(row => `
<tr>
<td>${escapeHtml(row.date)}</td>
<td><strong>${escapeHtml(row.quality)}</strong><span>${formatPercent(row.score)}</span></td>
<td><span class="${escapeAttr(getPanchangaActivityClass(row.activityVerdict))}">${escapeHtml(row.activityVerdict)}</span></td>
<td>${renderPanchangaConditionBadges(row.conditionTags)}</td>
<td>${renderVrataTagBadges(row.vrataTags)}</td>
<td>${escapeHtml(row.subDaySummary)}</td>
<td>${escapeHtml(row.tithi)}</td>
<td>${escapeHtml(row.nakshatra)}</td>
<td>${escapeHtml(row.yoga)}</td>
<td>${escapeHtml(row.endSummary)}</td>
<td>${escapeHtml(row.sunrise)}-${escapeHtml(row.sunset)}</td>
<td>${escapeHtml(row.rahuKala)}</td>
<td>${escapeHtml(row.yamaganda)}</td>
<td>${escapeHtml(row.gulika)}</td>
</tr>
`).join('') : '<tr><td colspan="14">当前范围内没有匹配条件的日期。</td></tr>'}
</tbody>
</table>
</div>
`;
} catch (error) {
output.innerHTML = `<div class="panchanga-week-error">Panchanga 日历生成失败:${escapeHtml(error?.message || 'unknown error')}</div>`;
}
}
function readPanchangaRangeInputs() {
const today = new Date();
const start = $('panchanga-start')?.value || toDateISO(today);
const end = $('panchanga-end')?.value || toDateISO(addDays(today, 6));
const payload = {
start_date: start,
end_date: end,
sunrise: $('panchanga-sunrise')?.value || '06:00',
sunset: $('panchanga-sunset')?.value || '18:00',
hour_from_sunrise: 6,
};
const activity = $('panchanga-activity')?.value || 'all';
if (activity !== 'all') payload.activity = activity;
const conditions = getSelectedPanchangaConditions();
payload.conditions = conditions;
payload.condition = conditions[0] || 'all';
payload.condition_mode = $('panchanga-condition-mode')?.value || 'all';
const birth = chartData?.birth_info || chartData?.birth || {};
const lat = Number(birth.lat);
const lon = Number(birth.lon);
const tz = Number(birth.tz);
if (Number.isFinite(lat) && Number.isFinite(lon) && Number.isFinite(tz)) {
payload.lat = lat;
payload.lon = lon;
payload.tz = tz;
}
return payload;
}
function normalizePanchangaRangeRows(days) {
return days.map(day => {
const p = day.panchanga || {};
const summary = day.summary || {};
const periods = day.inauspicious_periods || {};
const solar = day.solar_times || {};
const checks = day.activity_checks || {};
const primaryCheck = Object.values(checks)[0] || null;
const endTimes = day.end_times || {};
const vrataTags = Array.isArray(day.vrata_tags) ? day.vrata_tags : [];
const festivalDetails = Array.isArray(day.festival_details) ? day.festival_details : [];
const conditionTags = Array.isArray(day.condition_tags) ? day.condition_tags : [];
const choghadiya = day.choghadiya || {};
const horaWindows = day.hora_windows || {};
return {
date: day.query_date || '-',
quality: summary.overall_quality || p.overall_quality || '-',
score: summary.overall_score ?? p.overall_score,
tithi: p.tithi?.full_name || p.tithi?.name || '-',
nakshatra: p.nakshatra?.nakshatra || p.nakshatra?.name || '-',
yoga: p.yoga?.yoga || p.yoga?.name || '-',
vara: p.vara?.vara || p.vara?.name || '-',
sunrise: solar.sunrise || periods.sunrise || '-',
sunset: solar.sunset || periods.sunset || '-',
solarPolicy: solar.policy || '',
endTimes,
tithiEndsAt: endTimes.tithi?.ends_at || '',
nakshatraEndsAt: endTimes.nakshatra?.ends_at || '',
yogaEndsAt: endTimes.yoga?.ends_at || '',
endSummary: formatEndTimeSummary(endTimes),
vrataTags,
festivalDetails,
vrataLabels: vrataTags.map(tag => tag.label || tag.key).filter(Boolean).join(' / '),
conditionTags,
conditionLabels: conditionTags.map(tag => tag.label || tag.key).filter(Boolean).join(' / '),
subDaySummary: formatSubDaySummary(choghadiya, horaWindows),
choghadiya,
horaWindows,
activityVerdict: primaryCheck?.verdict || summarizeActivities(summary),
activityNotes: Array.isArray(primaryCheck?.notes) ? primaryCheck.notes.join('; ') : '',
rahuKala: formatWindow(periods.rahu_kala),
yamaganda: formatWindow(periods.yamaganda),
gulika: formatWindow(periods.gulika),
};
});
}
function renderPanchangaMonthGrid(grid) {
if (!Array.isArray(grid) || !grid.length) return '';
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return `
<div class="panchanga-month-grid" aria-label="Panchanga month grid">
${weekdays.map(day => `<div class="panchanga-month-head">${escapeHtml(day)}</div>`).join('')}
${grid.flatMap(week => week.map(cell => {
if (!cell) return '<div class="panchanga-month-cell is-empty"></div>';
const best = Array.isArray(cell.best_activities) ? cell.best_activities.slice(0, 2).map(getPanchangaActivityLabel).join(' / ') : '';
const tagLabels = Array.isArray(cell.vrata_tags) ? cell.vrata_tags.slice(0, 2).map(tag => tag.label || tag.key).filter(Boolean) : [];
const bestChoghadiya = Array.isArray(cell.best_choghadiya) ? cell.best_choghadiya.slice(0, 2).map(item => `${item.name} ${item.start}`).join(' / ') : '';
return `
<div class="panchanga-month-cell">
<div class="panchanga-month-date">
<strong>${escapeHtml(String(cell.day || ''))}</strong>
<span>${escapeHtml(formatPercent(cell.score))}</span>
</div>
<p>${escapeHtml(cell.tithi || '-')}</p>
<small>${escapeHtml(cell.nakshatra || '-')}</small>
${cell.tithi_ends_at ? `<small>${escapeHtml(shortLocalDateTime(cell.tithi_ends_at))}</small>` : ''}
${tagLabels.length ? `<div class="panchanga-tag-row">${tagLabels.map(label => `<span>${escapeHtml(label)}</span>`).join('')}</div>` : ''}
${bestChoghadiya ? `<small>${escapeHtml(bestChoghadiya)}</small>` : ''}
${best ? `<em>${escapeHtml(best)}</em>` : ''}
</div>
`;
})).join('')}
</div>
`;
}
function getPanchangaActivityLabel(value) {
const found = PANCHANGA_ACTIVITIES.find(([key]) => key === value);
return found ? found[1] : value || '全部活动';
}
function getPanchangaConditionLabel(value) {
const found = PANCHANGA_CONDITIONS.find(([key]) => key === value);
return found ? found[1] : value || '全部条件';
}
function getSelectedPanchangaConditions() {
return Array.from(document.querySelectorAll('input[name="panchanga-condition-option"]:checked'))
.map(input => input.value)
.filter(Boolean);
}
function normalizePanchangaConditions(conditions) {
const list = Array.isArray(conditions) ? conditions : [conditions];
return [...new Set(list.filter(condition => condition && condition !== 'all'))];
}
function normalizePanchangaConditionMode(mode) {
return mode === 'any' ? 'any' : 'all';
}
function getPanchangaConditionModeLabel(mode) {
const normalized = normalizePanchangaConditionMode(mode);
const found = PANCHANGA_CONDITION_MODES.find(([key]) => key === normalized);
return found ? found[1] : '满足全部';
}
function getPanchangaConditionSelectionLabel(conditions, mode = 'all') {
const selected = normalizePanchangaConditions(conditions);
if (!selected.length) return getPanchangaConditionLabel('all');
const joiner = normalizePanchangaConditionMode(mode) === 'any' ? ' / ' : ' + ';
return `${getPanchangaConditionModeLabel(mode)}${selected.map(getPanchangaConditionLabel).join(joiner)}`;
}
function renderPanchangaLocationSummary(location, sourceChart) {
const birth = sourceChart?.birth_info || sourceChart?.birth || {};
if (location?.lat != null && location?.lon != null) {
const city = birth.city || birth.place || '';
return `<small>地点:${escapeHtml(city ? `${city} · ` : '')}${escapeHtml(Number(location.lat).toFixed(3))}, ${escapeHtml(Number(location.lon).toFixed(3))} · TZ ${escapeHtml(location.tz)}</small>`;
}
return '<small>地点:手动日出/日落;未使用出生地坐标。</small>';
}
function renderPanchangaConditionGuide(conditions, mode = 'all', searchSummary = null) {
const selected = normalizePanchangaConditions(conditions);
const items = selected.length ? selected : ['all'];
const counts = searchSummary?.condition_counts || {};
return `
<div class="panchanga-condition-guide">
<div>
<strong>${escapeHtml(getPanchangaConditionModeLabel(mode))}</strong>
<span>${escapeHtml(selected.length ? `当前组合 ${selected.length} 个条件;同类 /panchanga/search 可按 tithi+nākṣatra+yoga+vara 或标签组合检索。` : '未选择条件,显示全部日期。')}</span>
</div>
${items.map(key => `
<div>
<strong>${escapeHtml(getPanchangaConditionLabel(key))}${counts[key] ? ` · ${escapeHtml(String(counts[key]))}` : ''}</strong>
<span>${escapeHtml(PANCHANGA_CONDITION_GUIDE[key] || '按当前 Panchanga 标签筛选。')}</span>
</div>
`).join('')}
</div>
`;
}
function renderPanchangaFestivalDetails(rows) {
const details = [];
rows.forEach(row => {
(row.festivalDetails || []).forEach(detail => {
details.push({ ...detail, date: row.date });
});
});
if (!details.length) return '';
return `
<div class="panchanga-festival-details">
${details.slice(0, 6).map(detail => `
<div class="panchanga-festival-card ${detail.requires_confirmation ? 'is-candidate' : ''}">
<strong>${escapeHtml(detail.label || detail.key || '-')}</strong>
<span>${escapeHtml(detail.date || '')} · ${escapeHtml(detail.type || 'observance')}</span>
<p>${escapeHtml(detail.guidance || detail.confirmation_note || '')}</p>
<small>${escapeHtml((detail.basis || []).join(' · '))}</small>
</div>
`).join('')}
</div>
`;
}
function filterPanchangaRowsByCondition(rows, condition, mode = 'all') {
const selected = normalizePanchangaConditions(condition);
if (!selected.length) return rows;
const matcher = normalizePanchangaConditionMode(mode) === 'any'
? selected.some.bind(selected)
: selected.every.bind(selected);
return rows.filter(row => matcher(item => rowMatchesPanchangaCondition(row, item)));
}
function rowMatchesPanchangaCondition(row, condition) {
const tags = Array.isArray(row?.conditionTags) ? row.conditionTags : [];
if (tags.some(tag => tag.key === condition)) return true;
if (condition === 'festival_candidate') {
return (row?.vrataTags || []).some(tag => tag.type === 'festival_candidate');
}
if (condition === 'has_vrata') return Boolean((row?.vrataTags || []).length);
if (condition === 'auspicious_activity') return /推荐|大吉|吉|Excellent|Good/.test(String(row?.activityVerdict || ''));
if (condition === 'avoid_new_start') return /避开|不宜|Avoid|Poor/.test(String(row?.activityVerdict || ''));
if (condition === 'good_choghadiya') {
return ['day', 'night'].some(part => (row?.choghadiya?.[part] || []).some(item => item.quality === 'auspicious'));
}
if (condition === 'spiritual_practice') {
return (row?.vrataTags || []).some(tag => ['fasting', 'observance', 'lunar', 'vrata'].includes(tag.type));
}
return false;
}
function summarizeActivities(summary) {
const best = Array.isArray(summary?.best_activities) ? summary.best_activities : [];
const avoid = Array.isArray(summary?.avoid_activities) ? summary.avoid_activities : [];
if (avoid.length) return `避开 ${avoid.map(getPanchangaActivityLabel).slice(0, 2).join('/')}`;
if (best.length) return `推荐 ${best.map(getPanchangaActivityLabel).slice(0, 2).join('/')}`;
return '中性';
}
function getPanchangaActivityClass(verdict) {
const text = String(verdict || '');
if (/避开|不宜|Avoid|Poor/.test(text)) return 'panchanga-activity-badge is-bad';
if (/推荐|大吉|吉|Excellent|Good/.test(text)) return 'panchanga-activity-badge is-good';
return 'panchanga-activity-badge';
}
function formatEndTimeSummary(endTimes) {
const parts = [];
if (endTimes?.tithi?.ends_at) parts.push(`T ${shortLocalDateTime(endTimes.tithi.ends_at)}`);
if (endTimes?.nakshatra?.ends_at) parts.push(`N ${shortLocalDateTime(endTimes.nakshatra.ends_at)}`);
if (endTimes?.yoga?.ends_at) parts.push(`Y ${shortLocalDateTime(endTimes.yoga.ends_at)}`);
return parts.join(' · ') || '-';
}
function shortLocalDateTime(value) {
const text = String(value || '');
return text.length >= 16 ? text.slice(5, 16) : text || '-';
}
function renderVrataTagBadges(tags) {
if (!Array.isArray(tags) || !tags.length) return '-';
return `<div class="panchanga-tag-row">${tags.slice(0, 3).map(tag => `<span>${escapeHtml(tag.label || tag.key || '-')}</span>`).join('')}</div>`;
}
function renderPanchangaConditionBadges(tags) {
if (!Array.isArray(tags) || !tags.length) return '-';
return `<div class="panchanga-condition-chip">${tags.slice(0, 3).map(tag => `<span>${escapeHtml(tag.label || tag.key || '-')}</span>`).join('')}</div>`;
}
function formatSubDaySummary(choghadiya, horaWindows) {
const good = [
...(Array.isArray(choghadiya?.day) ? choghadiya.day : []),
...(Array.isArray(choghadiya?.night) ? choghadiya.night : []),
].filter(item => item.quality === 'auspicious').slice(0, 2);
const hora = Array.isArray(horaWindows?.day) ? horaWindows.day[0] : null;
const goodText = good.map(item => `${item.name} ${item.start}-${item.end}`).join(' · ');
const horaText = hora ? `Hora ${hora.lord} ${hora.start}-${hora.end}` : '';
return [goodText, horaText].filter(Boolean).join(' · ') || '-';
}
function formatWindow(period) {
if (!period) return '-';
return `${period.start || '-'}-${period.end || '-'}`;
}
function exportPanchangaRangeCSV(panel) {
const rows = panel?._lastPanchangaRange?.rows || [];
if (!rows.length) {
renderPanchangaRange(panel);
return;
}
const header = ['date', 'quality', 'score', 'activity_verdict', 'activity_notes', 'condition_tags', 'vrata_tags', 'sub_day_windows', 'tithi', 'tithi_ends_at', 'nakshatra', 'nakshatra_ends_at', 'yoga', 'yoga_ends_at', 'vara', 'sunrise', 'sunset', 'rahu_kala', 'yamaganda', 'gulika'];
const csvRows = [header, ...rows.map(row => [
row.date,
row.quality,
formatPercent(row.score),
row.activityVerdict,
row.activityNotes,
row.conditionLabels,
row.vrataLabels,
row.subDaySummary,
row.tithi,
row.tithiEndsAt,
row.nakshatra,
row.nakshatraEndsAt,
row.yoga,
row.yogaEndsAt,
row.vara,
row.sunrise,
row.sunset,
row.rahuKala,
row.yamaganda,
row.gulika,
])];
const csv = csvRows.map(cols => cols.map(escapeCsv).join(',')).join('\n');
downloadText(csv, `panchanga-${rows[0].date || 'range'}-${rows[rows.length - 1].date || 'range'}.csv`, 'text/csv;charset=utf-8');
}
function exportPanchangaRangeICS(panel) {
const rows = panel?._lastPanchangaRange?.rows || [];
if (!rows.length) {
renderPanchangaRange(panel);
return;
}
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Jyotish Web App//Panchanga//CN',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
];
rows.forEach((row, index) => {
const uid = `panchanga-${row.date}-${index}@jyotish-web-app`;
const start = row.date.replace(/-/g, '');
const end = toDateISO(addDays(parseDateISO(row.date), 1)).replace(/-/g, '');
const summary = `Panchanga ${row.quality}`;
const description = [
`Tithi: ${row.tithi}`,
`Nakshatra: ${row.nakshatra}`,
`Yoga: ${row.yoga}`,
`Vara: ${row.vara}`,
`Activity: ${row.activityVerdict}`,
`Condition tags: ${row.conditionLabels || '-'}`,
`Vrata tags: ${row.vrataLabels || '-'}`,
`Sub-day windows: ${row.subDaySummary}`,
`End times: ${row.endSummary}`,
`Sunrise/Sunset: ${row.sunrise}-${row.sunset}`,
`Rahu Kala: ${row.rahuKala}`,
`Yamaganda: ${row.yamaganda}`,
`Gulika: ${row.gulika}`,
].join('\\n');
lines.push(
'BEGIN:VEVENT',
`UID:${escapeIcs(uid)}`,
`DTSTAMP:${formatIcsTimestamp(new Date())}`,
`DTSTART;VALUE=DATE:${start}`,
`DTEND;VALUE=DATE:${end}`,
`SUMMARY:${escapeIcs(summary)}`,
`DESCRIPTION:${escapeIcs(description)}`,
'END:VEVENT',
);
});
lines.push('END:VCALENDAR');
downloadText(lines.join('\r\n'), `panchanga-${rows[0].date || 'range'}-${rows[rows.length - 1].date || 'range'}.ics`, 'text/calendar;charset=utf-8');
}
function escapeIcs(value) {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/,/g, '\\,')
.replace(/;/g, '\\;');
}
function formatIcsTimestamp(date) {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
}
function escapeCsv(value) {
const s = String(value ?? '');
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
function downloadText(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
function parseDateISO(value) {
const [year, month, day] = String(value).split('-').map(Number);
return new Date(year, (month || 1) - 1, day || 1);
}
function toDateISO(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function formatPercent(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '-';
return `${Math.round(n * 100)}%`;
}
function buildPlanetSignIndex(planets) {
const result = {};
for (const [pn, p] of Object.entries(planets || {})) {
if (!p || p.error) continue;
const signIdx = SIGNS.indexOf(p.sign);
if (signIdx >= 0) result[pn] = signIdx;
}
return result;
}
function renderSpecialLagnaReport(arudha, ascendant, birthInfo, specialLagnas) {
const container = $('special-lagna-section');
if (!container) return;
if (!ascendant?.sign) {
container.innerHTML = '';
return;
}
const special = buildSpecialLagnaReport(arudha, ascendant, birthInfo, specialLagnas);
const cards = special.cards.map(card => `
<div class="special-lagna-card ${card.kind}">
<span>${escapeHtml(card.code)}</span>
<strong>${escapeHtml(card.title)}</strong>
<div>${escapeHtml(card.sign)}</div>
<p>${escapeHtml(card.meaning)}</p>
</div>
`).join('');
container.innerHTML = `
<section class="special-lagna-panel">
<div class="special-lagna-head">
<div>
<h4>特殊 Lagna 专题</h4>
<p>把 AL/UL/A10 的外显影像与 HL/GL/VL 的时间敏感辅助点放在同一张卡片里,作为完整解盘的身份、婚姻、事业、财富与权力观察入口。</p>
</div>
<span>${escapeHtml(special.ghatiLabel)}</span>
</div>
${special.meta.length ? `<div class="special-lagna-meta">${special.meta.map(item => `<span>${escapeHtml(item)}</span>`).join('')}</div>` : ''}
<div class="special-lagna-grid">${cards}</div>
<p class="special-lagna-note">${escapeHtml(special.note)}</p>
</section>
`;
}
function buildSpecialLagnaReport(arudha, ascendant, birthInfo, specialLagnas) {
const apiSpecial = normalizeSpecialLagnas(specialLagnas);
if (apiSpecial) {
const card = (code, title, sign, lord, meaning, kind = 'derived') => ({
code,
title,
sign: sign ? `${signName(sign)} · 主星 ${planetName(lord || SIGN_LORDS[sign])}` : '暂缺',
meaning,
kind,
});
return {
ghatiLabel: `Ghati ${apiSpecial.ghatis.toFixed(2)}`,
meta: [
apiSpecial.precision === 'sunrise_correct' ? '日出校正' : '后端计算',
apiSpecial.sunriseLocal ? `日出 ${apiSpecial.sunriseLocal}` : '',
].filter(Boolean),
note: apiSpecial.note || 'HL/GL 已按出生地日出起算;若出生时间不确定,请结合生时校正模块共同判断。',
cards: [
card('AL', 'Arudha Lagna 社会形象', arudha?.AL?.sign, arudha?.AL?.lord, '别人最容易看到的外在形象、名声与社会投射。', 'core'),
card('UL', 'Upapada Lagna 婚姻外显', arudha?.UL?.sign, arudha?.UL?.lord, '伴侣关系在外界呈现出的样子,也用于观察婚姻持续性。', 'core'),
card('A10', 'Karma Pada 事业形象', arudha?.A10?.sign, arudha?.A10?.lord, '职业名声、公众认可方式,以及事业成果如何被看见。', 'core'),
card('HL', 'Hora Lagna 财富驱动', apiSpecial.HL?.sign, apiSpecial.HL?.lord, '财富获取方式、资源动机和物质层面的行动入口。'),
card('GL', 'Ghati Lagna 权力行动', apiSpecial.GL?.sign, apiSpecial.GL?.lord, '行动效率、权力表达、竞争与事件触发敏感点。'),
card('VL', 'Varnada Lagna 身份来源', apiSpecial.VL?.sign, apiSpecial.VL?.lord, '身份阶层、外在定位和人生舞台的来源感。'),
],
};
}
const birth = window.__jyotishBirth || {};
const time = birthInfo?.time || `${String(birth.hour ?? 12).padStart(2, '0')}:${String(birth.minute ?? 0).padStart(2, '0')}`;
const [hourRaw, minuteRaw, secondRaw] = String(time).split(':');
const hour = Number.isFinite(Number(hourRaw)) ? Number(hourRaw) : safeNumber(birth.hour, 12);
const minute = Number.isFinite(Number(minuteRaw)) ? Number(minuteRaw) : safeNumber(birth.minute, 0);
const second = Number.isFinite(Number(secondRaw)) ? Number(secondRaw) : safeNumber(birth.second, 0);
const ascIdx = SIGNS.indexOf(ascendant.sign);
const ghatis = ((hour + minute / 60 + second / 3600) / 24) * 60;
const ghatiFloor = Math.floor(ghatis);
const hlIdx = ghatiFloor % 2 ? ghatiFloor % 12 : (7 - ghatiFloor + 120) % 12;
const glIdx = ghatiFloor % 12;
const vlIdx = (ascIdx * 3) % 12;
const card = (code, title, sign, lord, meaning, kind = 'derived') => ({
code,
title,
sign: sign ? `${signName(sign)} · 主星 ${planetName(lord || SIGN_LORDS[sign])}` : '暂缺',
meaning,
kind,
});
return {
ghatiLabel: `Ghati ${ghatis.toFixed(2)}`,
meta: ['本地简化值'],
note: '边界:HL/GL/VL 当前使用本地简化算法,按午夜起算 ghati;传统精算应使用出生地日出时间。因此它们作为辅助观察,不用于单独定论。',
cards: [
card('AL', 'Arudha Lagna 社会形象', arudha?.AL?.sign, arudha?.AL?.lord, '别人最容易看到的外在形象、名声与社会投射。', 'core'),
card('UL', 'Upapada Lagna 婚姻外显', arudha?.UL?.sign, arudha?.UL?.lord, '伴侣关系在外界呈现出的样子,也用于观察婚姻持续性。', 'core'),
card('A10', 'Karma Pada 事业形象', arudha?.A10?.sign, arudha?.A10?.lord, '职业名声、公众认可方式,以及事业成果如何被看见。', 'core'),
card('HL', 'Hora Lagna 财富驱动', SIGNS[hlIdx], SIGN_LORDS[SIGNS[hlIdx]], '财富获取方式、资源动机和物质层面的行动入口。'),
card('GL', 'Ghati Lagna 权力行动', SIGNS[glIdx], SIGN_LORDS[SIGNS[glIdx]], '行动效率、权力表达、竞争与事件触发敏感点。'),
card('VL', 'Varnada Lagna 身份来源', SIGNS[vlIdx], SIGN_LORDS[SIGNS[vlIdx]], '身份阶层、外在定位和人生舞台的来源感。'),
],
};
}
function normalizeSpecialLagnas(specialLagnas) {
if (!specialLagnas || !specialLagnas.HL || !specialLagnas.GL || !specialLagnas.VL) return null;
const ghatis = safeNumber(
specialLagnas.ghatis_elapsed_from_sunrise ?? specialLagnas.HL?.ghatis_elapsed,
NaN,
);
if (!Number.isFinite(ghatis)) return null;
return {
precision: specialLagnas.precision || specialLagnas.capability_status || '',
sunriseLocal: specialLagnas.sunrise_local_time || '',
ghatis,
note: specialLagnas.note || '',
HL: normalizeSpecialPoint(specialLagnas.HL),
GL: normalizeSpecialPoint(specialLagnas.GL),
VL: normalizeSpecialPoint(specialLagnas.VL),
};
}
function normalizeSpecialPoint(point) {
if (!point) return null;
const sign = point.sign || SIGNS[point.sign_idx];
return {
sign,
lord: point.lord || SIGN_LORDS[sign],
degree: point.degree_in_sign ?? point.longitude,
};
}
// ============================================================================
// D9 Navamsa 婚姻专题
// ============================================================================
function renderD9MarriageReport(allV, planets, ascendant, karaka) {
const container = $('d9-marriage-section');
if (!container) return;
const report = buildD9MarriageReport(allV, planets, ascendant, karaka);
if (!report) {
container.innerHTML = `
<div class="d9-panel">
<div class="d9-empty">D9 婚姻专题需要完整出生时间和行星度数。请先生成可计算分盘的星盘。</div>
</div>
`;
return;
}
const coreHtml = report.core.map(item => `
<div class="d9-core-card">
<span>${escapeHtml(item.label)}</span>
<strong>${escapeHtml(item.value)}</strong>
<p>${escapeHtml(item.note)}</p>
</div>
`).join('');
const flagHtml = report.flags.map(flag => `
<div class="d9-flag d9-flag-${escapeAttr(flag.level)}">
<div class="d9-flag-head">
<strong>${escapeHtml(flag.name)}</strong>
<span>${escapeHtml(flag.label)}</span>
</div>
<p>${escapeHtml(flag.detail)}</p>
</div>
`).join('');
const evidenceHtml = report.evidence.map(item => `
<li><strong>${escapeHtml(item.label)}</strong>${escapeHtml(item.value)}</li>
`).join('');
container.innerHTML = `
<div class="d9-panel">
<div class="d9-panel-head">
<div>
<h4>D9 Navamsa 婚姻专题</h4>
<p>基于 D9 上升、Darakaraka、Venus、D9 七宫、Vargottama 与 Pushkara 的八步旗标。</p>
</div>
<div class="d9-score d9-score-${escapeAttr(report.zone.key)}">
<span>${escapeHtml(report.zone.name)}</span>
<strong>${escapeHtml(report.score)} / 8</strong>
</div>
</div>
<div class="d9-core-grid">${coreHtml}</div>
<div class="d9-flags">${flagHtml}</div>
<div class="d9-evidence">
<div>
<strong>关键证据</strong>
<ul>${evidenceHtml}</ul>
</div>
<p>边界:D9 需要准确出生时间,且应与 D1、Dasha、Transit、现实关系模式一起判断;这里展示的是婚恋专题信号,不替代个人选择或专业咨询。</p>
</div>
</div>
`;
}
function buildD9MarriageReport(allV, planets, ascendant, karaka) {
if (!allV?.D9?.planets || !planets || !ascendant?.sign) return null;
const ascSign = getD9AscSign(ascendant);
if (!ascSign) return null;
const ascIdx = SIGNS.indexOf(ascSign);
const d9Planets = buildD9PlanetPositions(allV.D9.planets, planets, ascIdx);
const d9AscLord = SIGN_LORDS[ascSign];
const d9AscLordPos = d9Planets[d9AscLord];
const dkPlanet = karaka?.karaka7?.DK?.planet || karaka?.karaka8?.DK?.planet;
const dkPos = dkPlanet ? d9Planets[dkPlanet] : null;
const venusPos = d9Planets.Venus;
const seventhSign = SIGNS[(ascIdx + 6) % 12];
const seventhLord = SIGN_LORDS[seventhSign];
const seventhLordPos = d9Planets[seventhLord];
const seventhPlanets = Object.entries(d9Planets).filter(([, p]) => p.house === 7).map(([pn]) => pn);
const vargottama = Object.entries(d9Planets)
.filter(([pn, p]) => planets[pn]?.sign && planets[pn].sign === p.sign)
.map(([pn]) => pn);
const pushkara = Object.entries(planets)
.filter(([pn, p]) => d9Planets[pn] && isPushkaraNavamsa(p))
.map(([pn]) => pn);
const flags = [
evaluateAscLordFlag(d9AscLord, d9AscLordPos),
evaluateDKFlag(dkPlanet, dkPos),
evaluatePlanetDignityFlag('Venus', venusPos, 'Venus在D9尊严'),
evaluateSeventhLordFlag(seventhLord, seventhLordPos),
evaluateDKVenusFlag(dkPlanet, dkPos, venusPos),
evaluateSeventhMaleficsFlag(seventhPlanets),
evaluateVargottamaFlag(vargottama, dkPlanet),
evaluatePushkaraFlag(pushkara, dkPlanet, seventhLord),
];
const score = flags.reduce((sum, flag) => sum + flag.score, 0);
const positiveCount = flags.filter(flag => flag.score > 0).length;
const zone = d9Zone(score);
return {
score: `${score >= 0 ? '+' : ''}${score}`,
zone,
flags,
core: [
{ label: 'D9上升', value: signName(ascSign), note: `${planetName(d9AscLord)}主导婚姻成熟后的运作气质` },
{ label: 'DK配偶征象', value: dkPlanet ? `${planetName(dkPlanet)} · ${formatD9Pos(dkPos)}` : '未取得DK', note: 'Darakaraka 描述配偶/亲密关系触发点' },
{ label: 'Venus关系品质', value: venusPos ? formatD9Pos(venusPos) : '缺少Venus', note: 'Venus 代表关系审美、亲密体验与和合力' },
{ label: 'D9七宫', value: `${signName(seventhSign)} · 主星 ${planetName(seventhLord)}`, note: seventhPlanets.length ? `七宫行星:${seventhPlanets.map(planetName).join('、')}` : '七宫无行星,重点看七宫主' },
],
evidence: [
{ label: 'D9上升主星', value: `${planetName(d9AscLord)} ${formatD9Pos(d9AscLordPos)}` },
{ label: 'D9七宫主', value: `${planetName(seventhLord)} ${formatD9Pos(seventhLordPos)}` },
{ label: 'D9七宫行星', value: seventhPlanets.length ? seventhPlanets.map(planetName).join('、') : '无' },
{ label: 'Vargottama', value: vargottama.length ? vargottama.map(planetName).join('、') : '无' },
{ label: 'Pushkara Navamsa', value: pushkara.length ? pushkara.map(planetName).join('、') : '无' },
{ label: '旗标概览', value: `${positiveCount} 个绿旗,${flags.filter(flag => flag.score < 0).length} 个红旗` },
],
};
}
function buildD9PlanetPositions(d9Planets, natalPlanets, ascIdx) {
const result = {};
for (const [pn, p] of Object.entries(d9Planets || {})) {
if (!p?.sign) continue;
const signIdx = SIGNS.indexOf(p.sign);
if (signIdx < 0) continue;
result[pn] = {
sign: p.sign,
house: ((signIdx - ascIdx + 12) % 12) + 1,
dignity: planetDignity(pn, p.sign),
natal: natalPlanets[pn] || {},
};
}
return result;
}
function getD9AscSign(ascendant) {
const si = SIGNS.indexOf(ascendant?.sign);
if (si < 0) return null;
const raw = safeNumber(ascendant.degree_in_sign ?? ascendant.degree);
const degInSign = raw >= 30 ? ((raw % 30) + 30) % 30 : raw;
const seg = Math.min(8, Math.floor(degInSign / (30 / 9)));
const elementStart = [0, 9, 6, 3];
return SIGNS[(elementStart[si % 4] + seg) % 12];
}
function planetDignity(planet, sign) {
const data = PLANET_DIGNITY[planet] || NODE_DIGNITY[planet];
if (!data || !sign) return { key: 'neutral', label: '中性' };
if (data.debilitation?.sign === sign) return { key: 'debilitated', label: '落陷' };
if (data.exaltation?.sign === sign) return { key: 'exalted', label: '旺相' };
if (data.ownSigns?.includes(sign)) return { key: 'own', label: '入庙' };
if (data.friends?.some(friend => SIGN_LORDS[sign] === friend)) return { key: 'friend', label: '友星座' };
if (data.enemies?.some(enemy => SIGN_LORDS[sign] === enemy)) return { key: 'enemy', label: '敌星座' };
return { key: 'neutral', label: '中性' };
}
function d9Flag(level, name, detail) {
return {
level,
name,
detail,
score: level === 'green' ? 1 : level === 'red' ? -1 : 0,
label: level === 'green' ? '绿旗' : level === 'red' ? '红旗' : '黄旗',
};
}
function evaluateAscLordFlag(planet, pos) {
if (!planet || !pos) return d9Flag('yellow', 'D9上升主星状态', '缺少上升主星位置,暂按中性处理。');
if (['exalted', 'own', 'friend'].includes(pos.dignity.key) || D9_GREEN_HOUSES.includes(pos.house)) {
return d9Flag('green', 'D9上升主星状态', `${planetName(planet)}${formatD9Pos(pos)},婚姻成熟后的自我调适力较好。`);
}
if (pos.dignity.key === 'debilitated' || pos.dignity.key === 'enemy' || D9_HARD_HOUSES.includes(pos.house)) {
return d9Flag('red', 'D9上升主星状态', `${planetName(planet)}${formatD9Pos(pos)},亲密关系中需要更有意识地修复自我防御。`);
}
return d9Flag('yellow', 'D9上升主星状态', `${planetName(planet)}${formatD9Pos(pos)},信号中性,需结合D1与Dasha。`);
}
function evaluateDKFlag(planet, pos) {
if (!planet || !pos) return d9Flag('yellow', 'DK在D9位置', '缺少DK位置,暂按中性处理。');
if ([1, 3, 4, 5, 7, 9, 10].includes(pos.house)) {
return d9Flag('green', 'DK在D9位置', `${planetName(planet)}${formatD9Pos(pos)},配偶征象容易进入关系主轴。`);
}
if ([8, 12].includes(pos.house)) {
return d9Flag('red', 'DK在D9位置', `${planetName(planet)}${formatD9Pos(pos)},关系可能触发深层转化、距离感或隐性议题。`);
}
if ([6, 11].includes(pos.house)) {
return d9Flag('yellow', 'DK在D9位置', `${planetName(planet)}${formatD9Pos(pos)},关系议题偏现实协作或期待管理。`);
}
return d9Flag('yellow', 'DK在D9位置', `${planetName(planet)}${formatD9Pos(pos)},需与Venus和七宫主交叉判断。`);
}
function evaluatePlanetDignityFlag(planet, pos, name) {
if (!pos) return d9Flag('yellow', name, `缺少${planetName(planet)}位置,暂按中性处理。`);
if (['exalted', 'own', 'friend'].includes(pos.dignity.key)) {
return d9Flag('green', name, `${planetName(planet)}${formatD9Pos(pos)},关系品质有稳定支撑。`);
}
if (['debilitated', 'enemy'].includes(pos.dignity.key)) {
return d9Flag('red', name, `${planetName(planet)}${formatD9Pos(pos)},亲密表达和价值交换需要主动经营。`);
}
return d9Flag('yellow', name, `${planetName(planet)}${formatD9Pos(pos)},关系品质信号中性。`);
}
function evaluateSeventhLordFlag(planet, pos) {
if (!planet || !pos) return d9Flag('yellow', 'D9七宫主位置', '缺少D9七宫主位置,暂按中性处理。');
if (['exalted', 'own', 'friend'].includes(pos.dignity.key) || [1, 4, 5, 7, 9, 10].includes(pos.house)) {
return d9Flag('green', 'D9七宫主位置', `${planetName(planet)}${formatD9Pos(pos)},婚姻运作模式较容易成形。`);
}
if (pos.dignity.key === 'debilitated' || [8, 12].includes(pos.house)) {
return d9Flag('red', 'D9七宫主位置', `${planetName(planet)}${formatD9Pos(pos)},伴侣协作与长期承诺需要更清晰规则。`);
}
return d9Flag('yellow', 'D9七宫主位置', `${planetName(planet)}${formatD9Pos(pos)},婚姻运作信号中等。`);
}
function evaluateDKVenusFlag(dkPlanet, dkPos, venusPos) {
if (!dkPlanet || !dkPos || !venusPos) return d9Flag('yellow', 'DK-Venus关系', '缺少DK或Venus,暂按中性处理。');
const distance = signDistance(dkPos.sign, venusPos.sign);
if (distance === 1 || distance === 5 || distance === 7 || distance === 9 || dkPos.sign === venusPos.sign) {
return d9Flag('green', 'DK-Venus关系', `DK ${planetName(dkPlanet)}与Venus形成同宫/吉性距离,配偶征象与关系品质较能互相接住。`);
}
if ([6, 8, 12].includes(distance)) {
return d9Flag('red', 'DK-Venus关系', `DK ${planetName(dkPlanet)}与Venus呈${distance}位关系,吸引力与相处方式可能需要磨合。`);
}
return d9Flag('yellow', 'DK-Venus关系', `DK ${planetName(dkPlanet)}与Venus无明显强连接,需看Dasha触发。`);
}
function evaluateSeventhMaleficsFlag(seventhPlanets) {
const malefics = seventhPlanets.filter(pn => D9_MALEFICS.includes(pn));
if (malefics.length === 0) return d9Flag('green', 'D9七宫凶星压力', 'D9七宫无自然凶星占据,关系场域压力较轻。');
if (malefics.length === 1) return d9Flag('yellow', 'D9七宫凶星压力', `D9七宫有${planetName(malefics[0])},关系需要管理节奏和边界。`);
return d9Flag('red', 'D9七宫凶星压力', `D9七宫有${malefics.map(planetName).join('、')},冲突/压力信号较集中。`);
}
function evaluateVargottamaFlag(vargottama, dkPlanet) {
if (dkPlanet && vargottama.includes(dkPlanet)) return d9Flag('green', 'Vargottama检查', `DK ${planetName(dkPlanet)}为Vargottama,配偶征象跨D1/D9一致。`);
if (vargottama.includes('Venus')) return d9Flag('green', 'Vargottama检查', 'Venus为Vargottama,关系品质主题较稳定。');
if (vargottama.length) return d9Flag('yellow', 'Vargottama检查', `${vargottama.map(planetName).join('、')}为Vargottama,可作为辅助稳定信号。`);
return d9Flag('red', 'Vargottama检查', '未见Vargottama行星,D1/D9之间需要更多交叉验证。');
}
function evaluatePushkaraFlag(pushkara, dkPlanet, seventhLord) {
if ((dkPlanet && pushkara.includes(dkPlanet)) || pushkara.includes('Venus')) {
const hits = pushkara.filter(pn => pn === dkPlanet || pn === 'Venus').map(planetName).join('、');
return d9Flag('green', 'Pushkara检查', `${hits}落Pushkara Navamsa,婚恋主题有额外缓冲与祝福信号。`);
}
if (seventhLord && pushkara.includes(seventhLord)) {
return d9Flag('yellow', 'Pushkara检查', `D9七宫主${planetName(seventhLord)}落Pushkara,可缓和婚姻运作压力。`);
}
return d9Flag('red', 'Pushkara检查', 'DK、Venus、D9七宫主未落Pushkara,需要依赖其他强项补足。');
}
function isPushkaraNavamsa(planet) {
const sign = planet?.sign;
const deg = safeNumber(planet?.degree_in_sign ?? planet?.degree, NaN);
if (!sign || !Number.isFinite(deg)) return false;
const si = SIGNS.indexOf(sign);
const element = si % 4;
if (element === 0) return deg >= 20 && deg < 23 + (20 / 60);
if (element === 1) return deg >= 13 + (20 / 60) && deg < 16 + (40 / 60);
if (element === 2) return deg >= 6 + (40 / 60) && deg < 10;
return deg >= 26 + (40 / 60) && deg < 30;
}
function signDistance(fromSign, toSign) {
const from = SIGNS.indexOf(fromSign);
const to = SIGNS.indexOf(toSign);
if (from < 0 || to < 0) return 0;
return ((to - from + 12) % 12) + 1;
}
function formatD9Pos(pos) {
if (!pos) return '-';
return `${signName(pos.sign)} · ${houseLabel(pos.house)} · ${pos.dignity.label}`;
}
function d9Zone(score) {
if (score >= 5) return { key: 'green', name: 'Green Zone' };
if (score >= 3) return { key: 'yellow', name: 'Yellow Zone' };
if (score >= 1) return { key: 'orange', name: 'Orange Zone' };
return { key: 'red', name: 'Red Zone' };
}
// ============================================================================
// Dasha 系统总览
// ============================================================================
function parseDashaDate(value) {
if (!value) return null;
const text = String(value).slice(0, 10);
const match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (match) return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function formatDashaDate(value) {
const date = parseDashaDate(value);
if (!date) return value || '-';
return dashaDateToString(date);
}
function dashaDateToString(date) {
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${mm}-${dd}`;
}
function formatDashaLord(lord) {
const value = String(lord || '').trim();
if (!value) return '-';
const houseMatch = value.match(/^House\s+(\d+)\s+\(([^)]+)\)$/i);
if (houseMatch) return `${houseMatch[1]}宫 (${signName(houseMatch[2])})`;
if (PLANET_CN[value] || PLANET_SYMBOLS[value]) return planetName(value);
if (SIGNS.includes(value)) return signName(value);
return value;
}
function formatDashaYears(value) {
if (value == null || value === '') return '-';
const num = Number(value);
return Number.isFinite(num) ? `${num}` : `${value}`;
}
function dashaPrecisionLabel(precision) {
if (precision === 'calculator') return '专用算法';
if (precision === 'generic') return '通用序列';
return 'API时间线';
}
function dashaPrecisionNote(precision) {
if (precision === 'generic') return '当前使用通用序列展开,适合趋势参考;古典细则仍需继续校准。';
if (precision === 'calculator') return '当前系统已调用专用计算器,适合与本命、Transit、分盘交叉验证。';
return '当前系统由本地 API 服务返回时间线。';
}
function addDashaDays(date, days) {
return new Date(date.getTime() + days * 86400000);
}
function getDashaPeriodDays(period) {
const start = parseDashaDate(period.start);
const end = parseDashaDate(period.end);
if (start && end && end > start) return (end.getTime() - start.getTime()) / 86400000;
const years = Number(period.years);
return Number.isFinite(years) && years > 0 ? years * 365.25636 : 0;
}
function extendDashaPeriodsForToday(rawPeriods) {
const base = (Array.isArray(rawPeriods) ? rawPeriods : [])
.filter(period => period && period.start && period.end)
.map(period => ({ ...period, cycle_index: period.cycle_index || 0 }));
if (!base.length) return Array.isArray(rawPeriods) ? rawPeriods : [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const lastEnd = parseDashaDate(base[base.length - 1].end);
if (!lastEnd || today < lastEnd) return base;
const extended = [...base];
let cursor = lastEnd;
let cycleIndex = 1;
let includeOneAfterCurrent = false;
while (cursor && cycleIndex <= 5) {
for (const period of base) {
const days = getDashaPeriodDays(period);
if (!days) return extended;
const start = cursor;
const end = addDashaDays(start, days);
extended.push({
...period,
start: dashaDateToString(start),
end: dashaDateToString(end),
cycle_index: cycleIndex,
repeated_cycle: true,
});
cursor = end;
if (includeOneAfterCurrent) return extended;
if (today >= start && today < end) includeOneAfterCurrent = true;
}
cycleIndex += 1;
}
return extended;
}
function getDashaTimelineState(periods) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const enriched = periods.map((period, index) => ({
...period,
_index: index,
_startDate: parseDashaDate(period.start),
_endDate: parseDashaDate(period.end),
}));
const current = enriched.find(period => (
period._startDate && period._endDate && today >= period._startDate && today < period._endDate
));
const next = current
? enriched.find(period => period._index > current._index && period._startDate)
: enriched.find(period => period._startDate && today < period._startDate);
let progress = 0;
if (current?._startDate && current?._endDate) {
const total = current._endDate.getTime() - current._startDate.getTime();
const elapsed = today.getTime() - current._startDate.getTime();
progress = total > 0 ? Math.max(0, Math.min(100, Math.round((elapsed / total) * 100))) : 0;
}
return { periods: enriched, current, next, progress };
}
function renderDashaSystemOverview(chartData) {
const container = $('dasha-system-section');
if (!container) return;
const dashas = chartData?.available_dashas || chartData?._extended?.available_dashas || [];
if (!dashas.length) {
container.innerHTML = `
<div class="dasha-system-panel">
<div class="dasha-system-empty">
<strong>当前为本地 JS 计算模式</strong>
<p>已展示 Vimshottari 三级大运。启动本地 API 服务后可查看完整 Dasha 系统清单。</p>
</div>
</div>
`;
return;
}
const grouped = dashas.reduce((acc, d) => {
const type = d.type || 'other';
if (!acc[type]) acc[type] = [];
acc[type].push(d);
return acc;
}, {});
const typeLabels = {
nakshatra: 'Nakshatra',
rasi: 'Rashi',
conditional: '条件',
varga: 'Varga',
bhav: 'Bhava',
special: 'Special',
other: 'Other',
};
const typeButtons = `
<button class="dasha-type-btn active" data-dasha-type="all">
全部 <span>${dashas.length}</span>
</button>
` + Object.entries(grouped).map(([type, items]) => `
<button class="dasha-type-btn" data-dasha-type="${escapeAttr(type)}">
${escapeHtml(typeLabels[type] || type)} <span>${items.length}</span>
</button>
`).join('');
const cards = dashas.map((d, index) => `
<button class="dasha-system-card${index === 0 ? ' selected' : ''}" data-dasha-type="${escapeAttr(d.type || 'other')}" data-dasha-key="${escapeAttr(d.key || d.name || '')}">
<strong>${escapeHtml(d.name || d.key || 'Dasha')}</strong>
<span>${escapeHtml(typeLabels[d.type] || d.type || 'Other')} · ${escapeHtml(d.years ?? '-')}年</span>
</button>
`).join('');
container.innerHTML = `
<div class="dasha-system-panel">
<div class="dasha-system-head">
<div>
<h4>多 Dasha 系统</h4>
<p>当前 API 返回 ${dashas.length} 种 Dasha。选择任一系统即可查看主周期时间线、当前周期、下一周期与计算精度提示。</p>
</div>
<div class="dasha-system-count">${dashas.length}</div>
</div>
<div class="dasha-type-filter">${typeButtons}</div>
<div class="dasha-system-layout">
<div class="dasha-system-list">${cards}</div>
<div class="dasha-system-detail" id="dasha-system-detail"></div>
</div>
</div>
`;
bindDashaSystemOverview(dashas, typeLabels, chartData);
}
function bindDashaSystemOverview(dashas, typeLabels, sourceChart) {
const detail = $('dasha-system-detail');
const list = document.querySelector('.dasha-system-list');
const filters = document.querySelectorAll('.dasha-type-btn');
if (!detail || !list) return;
let requestSeq = 0;
const birth = window.__jyotishBirth || {};
const renderTimeline = (result) => {
const rawPeriods = extendDashaPeriodsForToday(result?.periods);
const timeline = getDashaTimelineState(rawPeriods);
const periods = timeline.periods;
const precisionLabel = dashaPrecisionLabel(result?.precision);
const current = timeline.current;
const next = timeline.next;
const analysisHtml = renderVimshottariAnalysisBlock(result?.vimshottari_analysis);
const summaryHtml = `
<div class="dasha-system-summary">
<div class="dasha-system-now${current ? '' : ' muted'}">
<div class="dasha-system-kicker">当前周期</div>
<strong>${escapeHtml(current ? formatDashaLord(current.lord) : '未落入已返回周期')}</strong>
<p>${escapeHtml(current ? `${formatDashaDate(current.start)} ~ ${formatDashaDate(current.end)} · ${formatDashaYears(current.years)}` : '当前日期不在该系统已返回的时间线内。')}</p>
${current ? `<div class="dasha-system-progress" aria-label="当前周期进度 ${timeline.progress}%"><i style="width:${escapeAttr(timeline.progress)}%"></i></div>` : ''}
</div>
<div class="dasha-system-next${next ? '' : ' muted'}">
<div class="dasha-system-kicker">下一周期</div>
<strong>${escapeHtml(next ? formatDashaLord(next.lord) : '暂无后续周期')}</strong>
<p>${escapeHtml(next ? `${formatDashaDate(next.start)} ~ ${formatDashaDate(next.end)} · ${formatDashaYears(next.years)}` : 'API 当前没有返回更靠后的周期。')}</p>
</div>
</div>
`;
const periodRows = periods.map(period => `
<div class="dasha-system-period${current?._index === period._index ? ' current' : ''}${next?._index === period._index ? ' upcoming' : ''}">
<strong>${escapeHtml(formatDashaLord(period.lord))}${current?._index === period._index ? '<small>当前</small>' : ''}${next?._index === period._index ? '<small>下一段</small>' : ''}${period.cycle_index ? `<small>第${escapeHtml(period.cycle_index + 1)}轮</small>` : ''}</strong>
<span>${escapeHtml(formatDashaDate(period.start))} ~ ${escapeHtml(formatDashaDate(period.end))}</span>
<em>${escapeHtml(formatDashaYears(period.years))}</em>
</div>
`).join('');
detail.innerHTML = `
<div class="dasha-system-detail-card">
<div class="dasha-system-meta">${escapeHtml(typeLabels[result?.type] || result?.type || 'Other')} · ${escapeHtml(precisionLabel)}</div>
<strong>${escapeHtml(result?.name || result?.key || 'Dasha')}</strong>
<p>周期:${escapeHtml(result?.cycle_years ?? '-')} 年。${escapeHtml(dashaPrecisionNote(result?.precision))}</p>
${summaryHtml}
${analysisHtml}
<div class="dasha-system-periods">${periodRows || '<p>暂无可显示周期。</p>'}</div>
</div>
`;
};
const showDetail = async (key) => {
const item = dashas.find(d => (d.key || d.name) === key) || dashas[0];
if (!item) return;
const activeSeq = ++requestSeq;
detail.innerHTML = `
<div class="dasha-system-detail-card">
<div class="dasha-system-meta">${escapeHtml(typeLabels[item.type] || item.type || 'Other')}</div>
<strong>${escapeHtml(item.name || item.key || 'Dasha')}</strong>
<p>正在从本地 API 服务读取该 Dasha 的时间线...</p>
</div>
`;
try {
const result = await window.JyotishAPI?.computeDashaSystem?.({
...birth,
dasha: item.key,
planets: sourceChart?.planets || {},
ascendant: sourceChart?.ascendant || {},
});
if (!result || result.success === false) {
throw new Error(result?.error || 'Dasha API 未返回有效时间线');
}
if (activeSeq !== requestSeq) return;
renderTimeline(result);
} catch (err) {
if (activeSeq !== requestSeq) return;
detail.innerHTML = `
<div class="dasha-system-detail-card">
<div class="dasha-system-meta">${escapeHtml(typeLabels[item.type] || item.type || 'Other')}</div>
<strong>${escapeHtml(item.name || item.key || 'Dasha')}</strong>
<p>周期:${escapeHtml(item.years ?? '-')} 年。当前未能连接单系统时间线接口:${escapeHtml(err?.message || '本地 API 服务不可用')}。</p>
</div>
`;
}
};
list.querySelectorAll('.dasha-system-card').forEach(card => {
card.addEventListener('click', () => {
list.querySelectorAll('.dasha-system-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
showDetail(card.dataset.dashaKey);
});
});
filters.forEach(btn => {
btn.addEventListener('click', () => {
filters.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const type = btn.dataset.dashaType;
let firstVisible = null;
list.querySelectorAll('.dasha-system-card').forEach(card => {
const visible = type === 'all' || card.dataset.dashaType === type;
card.hidden = !visible;
card.classList.remove('selected');
if (visible && !firstVisible) firstVisible = card;
});
if (firstVisible) {
firstVisible.classList.add('selected');
showDetail(firstVisible.dataset.dashaKey);
}
});
});
const first = list.querySelector('.dasha-system-card');
if (first) showDetail(first.dataset.dashaKey);
}
function renderVimshottariAnalysisBlock(analysis) {
if (!analysis || !analysis.current) return '';
const md = analysis.current.mahadasha || {};
const ad = analysis.current.antardasha || {};
const levels = analysis.five_levels || {};
const levelLine = ['mahadasha', 'bhukti', 'pratyantar', 'sookshma', 'prana']
.map(key => levels[key]?.lord)
.filter(Boolean)
.join(' / ');
const keywords = (analysis.current.keywords || []).slice(0, 4);
return `
<div class="dasha-analysis-block">
<div class="dasha-analysis-head">
<span>${escapeHtml(analysis.nakshatra?.name || 'Vimshottari')}</span>
<strong>${escapeHtml(analysis.summary?.headline || `${md.lord || '-'} / ${ad.lord || '-'}`)}</strong>
</div>
<div class="dasha-analysis-grid">
<div><span>Mahadasha</span><strong>${escapeHtml(formatDashaLord(md.lord || '-'))}</strong><p>${escapeHtml(formatDashaDate(md.start))} ~ ${escapeHtml(formatDashaDate(md.end))}</p></div>
<div><span>Antardasha</span><strong>${escapeHtml(formatDashaLord(ad.lord || '-'))}</strong><p>剩余约 ${escapeHtml(analysis.current.remaining_days ?? '-')} 天</p></div>
<div><span>五级层级</span><strong>${escapeHtml(levelLine || '-')}</strong><p>${escapeHtml(analysis.current.theme || '以运限作为时间主轴')}</p></div>
</div>
<div class="dasha-analysis-tags">
${keywords.map(item => `<span>${escapeHtml(item)}</span>`).join('')}
<span>${escapeHtml(analysis.source || 'dasha_analyzer.py')}</span>
</div>
</div>
`;
}
// ============================================================================
// Remedies Tab 渲染
// ============================================================================
function renderRemediesTab(chartData) {
const sec = document.getElementById('remedies-section');
if (!sec) return;
const workflow = chartData?._consultationWorkflow;
const auditedRemedies = workflow?.audited_remedies;
const remedies = chartData?.remedies || chartData?._extended?.remedies || (
auditedRemedies?.status === 'ok'
? {
recommendations: auditedRemedies.raw?.recommendations || auditedRemedies.raw || {},
evidence_chain: {
source: auditedRemedies.source || 'strict_audit_gate',
topic: auditedRemedies.topic || 'general',
active_dasha_lord: auditedRemedies.active_dasha_lord || '',
gate: 'strict_audit_gate',
},
}
: null
);
if (!remedies) {
sec.innerHTML = `
<div class="remedies-empty">
<strong>补救建议暂不可用</strong>
<p>启动本地 API 服务并重新排盘后,可根据 Shadbala、当前 Dasha 与 Dosha 自动生成补救计划。</p>
</div>
`;
return;
}
const recommendations = remedies.recommendations || {};
const gems = recommendations.gems || [];
const mantras = recommendations.mantras || [];
const donations = recommendations.donations || [];
const fasting = recommendations.fasting || [];
const lifestyle = recommendations.lifestyle || [];
const doshaRemedies = recommendations.dosha_remedies || [];
const weakPlanets = remedies.weak_planets || [];
const moderatePlanets = remedies.moderate_planets || [];
const primaryMantra = mantras[0];
const primaryDonation = donations[0];
const primaryLifestyle = lifestyle[0];
const totalActions = gems.length + mantras.length + donations.length + fasting.length + lifestyle.length + doshaRemedies.length;
const auditedHeader = auditedRemedies?.status === 'ok'
? `<div class="workflow-summary-card"><strong>统一主链补救</strong><p>来源 ${escapeHtml(auditedRemedies.source || 'strict_audit_gate')} · 主题 ${escapeHtml(auditedRemedies.topic || 'general')} · 当前主运 ${escapeHtml(auditedRemedies.active_dasha_lord || '-')}</p></div>`
: '';
const renderActionCards = (items, mapper) => items.map(item => {
const mapped = mapper(item);
return `
<div class="remedy-action-card remedy-${escapeAttr(mapped.kind)}">
<span>${escapeHtml(mapped.label)}</span>
<strong>${escapeHtml(mapped.title)}</strong>
<p>${escapeHtml(mapped.body)}</p>
${mapped.meta ? `<small>${escapeHtml(mapped.meta)}</small>` : ''}
</div>
`;
}).join('');
const gemstoneCards = renderActionCards(gems.slice(0, 3), g => ({
kind: 'gem',
label: '谨慎',
title: `${g.planet || ''} · ${g.gem || ''}`,
body: g.note || '宝石类补救需要结合完整星盘、体质和预算复核。',
meta: [g.metal, g.finger, g.day].filter(Boolean).join(' · '),
}));
const mantraCards = renderActionCards(mantras.slice(0, 4), m => ({
kind: 'mantra',
label: '低风险',
title: m.mantra || m.planet || 'Mantra',
body: m.note || `每日念诵 ${m.repetitions || 27}`,
meta: m.priority === 'dasha_lord' ? '当前 Dasha 主星优先' : `${m.repetitions || '-'}`,
}));
const donationCards = renderActionCards(donations.slice(0, 3), d => ({
kind: 'donation',
label: '行动',
title: d.planet || 'Dana 捐赠',
body: d.note || '选择能力范围内的小额、持续、真实善行。',
meta: Array.isArray(d.items) ? d.items.slice(0, 3).join(' · ') : '',
}));
const fastingCards = renderActionCards(fasting.slice(0, 2), f => ({
kind: 'fasting',
label: '需评估',
title: `${f.planet || ''} 斋戒`,
body: f.note || '有低血糖、慢病、孕期或饮食限制者不要自行断食。',
meta: f.day || '',
}));
const doshaCards = renderActionCards(doshaRemedies.slice(0, 3), d => ({
kind: 'dosha',
label: '专项',
title: d.dosha || 'Dosha',
body: d.note || '专项补救需结合完整星盘和现实背景判断。',
meta: Array.isArray(d.actions) ? d.actions.slice(0, 2).join(' · ') : '',
}));
const firstWeek = [
primaryMantra ? `每天固定一个安静时段,念诵 ${primaryMantra.mantra || primaryMantra.planet} ${primaryMantra.repetitions || 27} 遍。` : '每天记录一次情绪、睡眠与精力变化,先观察一周。',
primaryDonation ? `选择一天做一次小额捐赠或服务:${primaryDonation.note || primaryDonation.planet || '按能力行善'}` : '做一件可验证的小善行,保持简单、可持续。',
primaryLifestyle ? primaryLifestyle.note : '避免一次性购买高价宝石,先执行低风险补救。',
];
const evidenceCards = (remedies.evidence_chain || []).slice(0, 6).map(item => `
<div class="remedies-evidence-card">
<span>${escapeHtml(item.source || 'evidence')}</span>
<strong>${escapeHtml(item.planet || item.dosha || '规则命中')}</strong>
<p>${escapeHtml(item.reason || '补救建议的来源依据')}</p>
${item.value !== undefined ? `<small>value ${escapeHtml(item.value)} / threshold ${escapeHtml(item.threshold || '-')}</small>` : ''}
</div>
`).join('');
sec.innerHTML = `
${auditedHeader}
<div class="remedies-dashboard">
<div class="remedies-hero">
<div>
<h4>Upaya 补救计划</h4>
<p>${escapeHtml(remedies.summary || '根据当前星盘生成补救建议。')}</p>
</div>
<div class="remedies-metrics">
<span><b>${escapeHtml(totalActions)}</b>条建议</span>
<span><b>${escapeHtml(weakPlanets.length)}</b>极弱行星</span>
<span><b>${escapeHtml(moderatePlanets.length)}</b>偏弱行星</span>
</div>
</div>
<div class="remedies-boundary">
<strong>使用边界</strong>
<p>补救建议只能作为传统文化与自我管理参考,不能替代医疗、法律、投资或心理咨询。宝石、斋戒、仪式类建议请先做现实风险评估。</p>
</div>
<div class="remedies-section">
<h4>建议依据</h4>
<div class="remedies-evidence-grid">${evidenceCards || '<p>暂无明确弱项证据,当前建议以低风险观察为主。</p>'}</div>
<p class="remedies-next-action">${escapeHtml(remedies.next_action || '先执行低风险补救,再根据现实反馈复核。')}</p>
</div>
<div class="remedies-plan">
<h4>前 7 天执行计划</h4>
<ol>${firstWeek.map(step => `<li>${escapeHtml(step)}</li>`).join('')}</ol>
</div>
<div class="remedies-section">
<h4>低风险优先</h4>
<div class="remedies-grid">${mantraCards || '<p>暂无咒语建议。</p>'}${donationCards}${lifestyle.length ? renderActionCards(lifestyle.slice(0, 2), l => ({ kind: 'lifestyle', label: '生活', title: l.type || 'Lifestyle', body: l.note || '', meta: '' })) : ''}</div>
</div>
<div class="remedies-section">
<h4>需要谨慎确认</h4>
<div class="remedies-grid">${gemstoneCards || '<p>暂无宝石建议。</p>'}${fastingCards}${doshaCards}</div>
</div>
<details class="technique-json remedies-json">
<summary>JSON</summary>
<pre>${escapeHtml(JSON.stringify(remedies, null, 2).slice(0, 6000))}</pre>
</details>
</div>
`;
}
// ============================================================================
// 合盘 Tab 渲染
// ============================================================================
function renderSynastryTab(chartData) {
const selfMoon = $('synastry-self-moon');
const partnerMoon = $('synastry-partner-moon');
const result = $('synastry-result');
const runBtn = $('btn-run-synastry');
const runFullBtn = $('btn-run-synastry-full');
const runLibraryBtn = $('btn-run-synastry-library');
if (!result) return;
const moonLon = getChartMoonLongitude(chartData);
if (selfMoon && Number.isFinite(Number(moonLon))) {
selfMoon.value = Number(moonLon).toFixed(2);
}
renderSynastryPartnerLibrary();
renderSynastryPairWorkspace();
setupSynastryPairActions();
setupSynastryPartnerCitySearch();
if (runLibraryBtn && !runLibraryBtn.dataset.bound) {
runLibraryBtn.dataset.bound = 'true';
runLibraryBtn.addEventListener('click', async () => {
try {
const entry = getSelectedSynastryLibraryEntry();
if (!entry?.data) {
result.innerHTML = '<p class="synastry-error">请先在本地星盘库中选择一个对方星盘。</p>';
return;
}
await runSynastryWithPartnerChart(getActiveChartData(), entry.data, result, {
partnerBirth: entry.data?.birth_info || {},
partnerLabel: entry.label || entry.id,
loading: `正在使用「${entry.label || entry.id}」计算完整合盘...`,
});
} catch (e) {
result.innerHTML = renderInlineAPIError('synastry-error', '合盘计算暂不可用', e);
}
});
}
if (runFullBtn && !runFullBtn.dataset.bound) {
runFullBtn.dataset.bound = 'true';
runFullBtn.addEventListener('click', async () => {
try {
const partnerBirth = readSynastryPartnerBirth();
result.innerHTML = '<p>正在为对方排盘并计算完整合盘...</p>';
const partnerChart = await computeChartForBirth(partnerBirth);
await runSynastryWithPartnerChart(getActiveChartData(), partnerChart, result, { partnerBirth });
} catch (e) {
result.innerHTML = renderInlineAPIError('synastry-error', '合盘计算暂不可用', e);
}
});
}
if (runBtn && partnerMoon && selfMoon && !runBtn.dataset.bound) {
runBtn.dataset.bound = 'true';
runBtn.addEventListener('click', async () => {
const maleMoon = Number(selfMoon.value);
const femaleMoon = Number(partnerMoon.value);
if (!Number.isFinite(maleMoon) || !Number.isFinite(femaleMoon)) {
result.innerHTML = '<p class="synastry-error">请输入 0-360 之间的月亮黄经。</p>';
return;
}
if (maleMoon < 0 || maleMoon > 360 || femaleMoon < 0 || femaleMoon > 360) {
result.innerHTML = '<p class="synastry-error">月亮黄经必须在 0-360 度之间。</p>';
return;
}
result.innerHTML = '<p>正在计算 Ashtakoot 合盘...</p>';
try {
const data = await window.JyotishAPI?.computeSynastry?.({ male_moon: maleMoon, female_moon: femaleMoon });
if (!data) throw new Error('本地 API 未返回结果');
recordSynastryWorkflow(data, null, { mode: 'moon_longitude_quick' });
result.innerHTML = renderSynastryResult(data);
bindTerms(result);
} catch (e) {
result.innerHTML = renderInlineAPIError('synastry-error', '合盘计算暂不可用', e);
}
});
}
}
function getActiveChartData() {
return chartData;
}
function renderSynastryPartnerLibrary() {
const select = $('synastry-partner-library');
if (!select) return;
const previous = select.value;
const currentId = buildWorkspaceChartId(chartData);
const legacyCurrentId = buildLegacyWorkspaceChartId(chartData);
const lib = sortChartLibrary(readChartLibrary()).filter(entry => entry?.id && ![currentId, legacyCurrentId].includes(entry.id) && entry?.data);
select.innerHTML = lib.length
? `<option value="">选择保存星盘...</option>${lib.map(entry => `<option value="${escapeAttr(entry.id)}">${escapeHtml(entry.label || entry.id)}</option>`).join('')}`
: '<option value="">暂无可用保存星盘</option>';
if (previous && lib.some(entry => entry.id === previous)) {
select.value = previous;
}
}
function getSelectedSynastryLibraryEntry() {
const id = $('synastry-partner-library')?.value || '';
if (!id) return null;
return readChartLibrary().find(entry => entry?.id === id) || null;
}
async function runSynastryWithPartnerChart(selfChart, partnerChart, result, options = {}) {
const maleMoon = getChartMoonLongitude(selfChart);
if (!Number.isFinite(maleMoon)) {
result.innerHTML = '<p class="synastry-error">本人星盘缺少月亮黄经,请先重新排盘。</p>';
return;
}
const femaleMoon = getChartMoonLongitude(partnerChart);
if (!Number.isFinite(femaleMoon)) throw new Error('对方星盘缺少月亮黄经,无法计算 Ashtakoot');
if (options.loading) {
result.innerHTML = `<p>${escapeHtml(options.loading)}</p>`;
}
const data = await window.JyotishAPI?.computeSynastry?.({ male_moon: maleMoon, female_moon: femaleMoon });
if (!data) throw new Error('本地 API 未返回结果');
const deep = buildSynastryDeepContext(selfChart, partnerChart, options.partnerBirth || partnerChart?.birth_info || {});
recordSynastryWorkflow(data, deep, { mode: options.partnerBirth ? 'full_birth_chart' : 'saved_partner_chart' });
_lastSynastryPairRecord = buildSynastryPairRecord(selfChart, partnerChart, data, deep, options);
result.innerHTML = renderSynastryResult(data, deep);
renderSynastryPairWorkspace();
bindTerms(result);
}
function getChartMoonLongitude(chart) {
const moon = chart?.planets?.Moon || {};
const value = moon.lon ?? moon.longitude ?? moon.degree;
const n = Number(value);
return Number.isFinite(n) ? ((n % 360) + 360) % 360 : NaN;
}
function setupSynastryPartnerCitySearch() {
const cityInput = $('synastry-partner-city');
const suggestions = $('synastry-city-suggestions');
const latInput = $('synastry-partner-lat');
const lonInput = $('synastry-partner-lon');
const tzInput = $('synastry-partner-tz');
if (!cityInput || !suggestions || !latInput || !lonInput || !tzInput || cityInput.dataset.bound) return;
cityInput.dataset.bound = 'true';
let timer = null;
cityInput.addEventListener('input', () => {
latInput.value = '';
lonInput.value = '';
clearTimeout(timer);
timer = setTimeout(() => {
const q = cityInput.value.trim();
if (q.length < 1) { suggestions.classList.add('hidden'); return; }
const results = searchCities(q);
if (!results.length) { suggestions.classList.add('hidden'); return; }
suggestions.innerHTML = results.map(c => {
const lat = safeNumber(c.lat);
const lon = safeNumber(c.lon);
const tz = safeNumber(c.tz);
const label = c.en ? `${c.name} (${c.en})` : c.name;
return `<div class="suggestion-item" data-lat="${escapeAttr(lat)}" data-lon="${escapeAttr(lon)}" data-tz="${escapeAttr(tz)}">${escapeHtml(label)} <small>${lat.toFixed(1)}°, ${lon.toFixed(1)}°</small></div>`;
}).join('');
suggestions.classList.remove('hidden');
suggestions.querySelectorAll('.suggestion-item').forEach(el => {
el.addEventListener('click', () => {
cityInput.value = el.textContent.trim().split(/\s+\(/)[0];
latInput.value = el.dataset.lat;
lonInput.value = el.dataset.lon;
tzInput.value = el.dataset.tz;
suggestions.classList.add('hidden');
});
});
}, 200);
});
document.addEventListener('click', e => {
if (!cityInput.contains(e.target) && !suggestions.contains(e.target)) suggestions.classList.add('hidden');
});
}
function readSynastryPartnerBirth() {
const dateValue = $('synastry-partner-date')?.value || '';
const timeValue = $('synastry-partner-time')?.value || '';
const lat = Number($('synastry-partner-lat')?.value);
const lon = Number($('synastry-partner-lon')?.value);
const tz = Number($('synastry-partner-tz')?.value);
if (!dateValue || !timeValue) throw new Error('请填写对方出生日期和时间。');
if (!Number.isFinite(lat) || !Number.isFinite(lon)) throw new Error('请选择对方出生城市,或通过城市搜索填入经纬度。');
if (!Number.isFinite(tz)) throw new Error('请填写对方出生地时区,例如印度为 5.5,中国为 8。');
const [year, month, day] = dateValue.split('-').map(Number);
const [hour, minute, second = 0] = timeValue.split(':').map(Number);
if (![year, month, day, hour, minute].every(Number.isFinite)) throw new Error('对方出生日期或时间格式不正确。');
return { year, month, day, hour, minute, second, lat, lon, tz };
}
function buildSynastryDeepContext(selfChart, partnerChart, partnerBirth) {
return {
partnerBirth,
partnerSummary: buildSynastryChartSummary(partnerChart),
comparison: buildBiWheelComparisonData(selfChart, partnerChart),
spouseStatus: buildSynastrySpouseStatusContext(selfChart, partnerChart),
ulDkTiming: buildULDKTimingContext(selfChart, partnerChart),
selfD9: buildChartD9MarriageSnapshot(selfChart),
partnerD9: buildChartD9MarriageSnapshot(partnerChart),
kuja: buildKujaComparison(selfChart, partnerChart),
dasha: buildDashaSync(selfChart, partnerChart),
};
}
function buildSynastryChartSummary(chart) {
const asc = chart?.ascendant || {};
const moon = chart?.planets?.Moon || {};
const current = getCurrentDashaSnapshot(chart);
return {
ascendant: asc.sign ? signName(asc.sign) : '-',
moon: moon.sign ? `${signName(moon.sign)} ${formatDegreeInSign(moon.degree_in_sign ?? moon.degree)}` : '-',
nakshatra: moon.nakshatra ? `${moon.nakshatra}${moon.nakshatra_pada ? ` P${moon.nakshatra_pada}` : ''}` : '-',
dasha: current.label,
};
}
function buildSynastrySpouseStatusContext(selfChart, partnerChart) {
return {
self: buildSpouseStatusSnapshot(selfChart),
partner: buildSpouseStatusSnapshot(partnerChart),
};
}
function buildULDKTimingContext(selfChart, partnerChart) {
return {
self: buildULDKTimingSnapshot(selfChart),
partner: buildULDKTimingSnapshot(partnerChart),
};
}
function buildULDKTimingSnapshot(chart) {
if (!chart?.planets || !chart?.ascendant?.sign) return null;
const karaka = computeKaraka(chart.planets);
const arudha = computeArudha(chart.planets, chart.ascendant.sign);
const dk7 = normalizeDKPoint(karaka?.karaka7?.DK, chart);
const dk8 = normalizeDKPoint(karaka?.karaka8?.DK, chart);
const ul = normalizeULPoint(arudha?.UL);
const dasha = getCurrentDashaSnapshot(chart);
const seventhSign = SIGNS[(SIGNS.indexOf(chart.ascendant.sign) + 6 + 12) % 12] || '';
const seventhLord = SIGN_LORDS[seventhSign] || '';
const triggers = [];
const dashaLords = [dasha.maha, dasha.antar, dasha.pratyantar].filter(Boolean);
const addTrigger = text => {
if (text && !triggers.includes(text)) triggers.push(text);
};
dashaLords.forEach(lord => {
if (lord === dk7?.planet) addTrigger(`${planetName(lord)} 运触发 7星制 DK`);
if (lord === dk8?.planet && lord !== dk7?.planet) addTrigger(`${planetName(lord)} 运触发 8星制 DK`);
if (lord === seventhLord) addTrigger(`${planetName(lord)} 运触发 7宫主`);
if (['Venus', 'Jupiter', 'Moon', 'Mars'].includes(lord)) addTrigger(`${planetName(lord)} 运触发关系自然征象星`);
if (lord === ul?.lord) addTrigger(`${planetName(lord)} 运触发 UL 主星`);
});
const ulMatchesDK = Boolean(ul?.sign && [dk7?.sign, dk8?.sign].includes(ul.sign));
if (ulMatchesDK) addTrigger(`UL 与 DK 同落 ${signName(ul.sign)},配偶征象更集中`);
const level = triggers.length >= 3 ? 'strong' : triggers.length >= 1 ? 'moderate' : 'low';
const summary = level === 'strong'
? 'DK/UL 与当前运限多点交叉'
: level === 'moderate'
? 'DK/UL 或关系星有单点触发'
: '当前运限未明显触发 DK/UL';
return {
dk7,
dk8,
ul,
dasha,
triggers,
level,
summary,
evidence: [
dk7 ? `7星制 DK${formatDKPoint(dk7)}` : '7星制 DK 待补充',
dk8 ? `8星制 DK${formatDKPoint(dk8)}` : '8星制 DK 待补充',
ul ? `UL${formatULPoint(ul)}` : 'UL 待补充',
`当前 Dasha${dasha.label || '-'}`,
],
};
}
function normalizeDKPoint(point, chart) {
if (!point?.planet) return null;
const planet = chart?.planets?.[point.planet] || {};
return {
planet: point.planet,
sign: point.sign || planet.sign || '',
house: point.house || planet.house || '',
degree: point.degree,
};
}
function normalizeULPoint(point) {
if (!point?.sign) return null;
return {
sign: point.sign,
pada_house: point.pada_house,
lord: point.lord,
};
}
function buildSpouseStatusSnapshot(chart) {
const ascSign = chart?.ascendant?.sign || '';
if (!ascSign || !chart?.planets) return null;
const seventhSign = SIGNS[(SIGNS.indexOf(ascSign) + 6 + 12) % 12] || '';
const seventhLord = SIGN_LORDS[seventhSign] || '';
const lagnaLord = SIGN_LORDS[ascSign] || '';
const seventhLordPlanet = chart.planets[seventhLord] || {};
const lagnaLordPlanet = chart.planets[lagnaLord] || {};
const seventhOccupants = Object.entries(chart.planets)
.filter(([, planet]) => Number(planet?.house) === 7)
.map(([name]) => name);
const upachayaFromSeventh = [9, 12, 4, 5];
const growthHouses = Object.entries(chart.planets)
.filter(([, planet]) => upachayaFromSeventh.includes(Number(planet?.house)))
.map(([name, planet]) => `${planetName(name)} H${planet.house}`);
const score = [
['exalted', 'own'].includes(seventhLordPlanet.dignity) ? 2 : ['friendly'].includes(seventhLordPlanet.dignity) ? 1 : 0,
[1, 4, 5, 7, 9, 10].includes(Number(seventhLordPlanet.house)) ? 1 : 0,
[3, 6, 10, 11].includes(Number(seventhLordPlanet.house)) ? 1 : 0,
growthHouses.length ? 1 : 0,
].reduce((sum, value) => sum + value, 0);
return {
ascSign,
seventhSign,
seventhLord,
lagnaLord,
score,
level: score >= 4 ? 'strong' : score >= 2 ? 'moderate' : 'low',
verdict: score >= 4 ? '婚后成长/伴侣资源信号强' : score >= 2 ? '有婚后成长或伴侣资源信号' : '未见明显高地位配偶 Yoga',
evidence: [
`7宫 ${signName(seventhSign)}7主 ${planetName(seventhLord)} 位于 H${seventhLordPlanet.house || '-'} ${seventhLordPlanet.dignity || 'unknown'}`,
`Lagna 主 ${planetName(lagnaLord)} 位于 H${lagnaLordPlanet.house || '-'} ${lagnaLordPlanet.dignity || 'unknown'}`,
seventhOccupants.length ? `7宫内行星:${seventhOccupants.map(planetName).join('、')}` : '7宫无主要行星占据',
growthHouses.length ? `从7宫起算成长宫被占据:${growthHouses.join('、')}` : '从7宫起算成长宫暂无明显占据',
],
};
}
function buildChartD9MarriageSnapshot(chart) {
if (!chart?.planets || !chart?.ascendant) return null;
const allV = computeAllVargas(chart.planets);
const karaka = computeKaraka(chart.planets);
const report = buildD9MarriageReport(allV, chart.planets, chart.ascendant, karaka);
if (!report) return null;
return {
score: report.score,
zone: report.zone,
core: report.core,
positives: report.flags.filter(flag => flag.score > 0).slice(0, 3),
risks: report.flags.filter(flag => flag.score < 0).slice(0, 3),
};
}
function buildKujaComparison(selfChart, partnerChart) {
const self = analyzeKujaDosha(selfChart);
const partner = analyzeKujaDosha(partnerChart);
const diff = Math.abs(self.score - partner.score);
let verdict = 'balanced';
let label = '火星煞压力相近';
let note = '双方 Kuja/Manglik 压力相近,可继续综合看 D9 与 Dasha。';
if (diff >= 3) {
verdict = 'hard';
label = '火星煞不平衡';
note = '双方火星煞压力差距较大,关系冲突、节奏和安全感议题需要额外复核。';
} else if (diff >= 2) {
verdict = 'watch';
label = '火星煞需观察';
note = '双方火星煞压力有差异,建议结合现实互动和婚姻宫受克情况判断。';
}
return { self, partner, diff, verdict, label, note };
}
function analyzeKujaDosha(chart) {
const planets = chart?.planets || {};
const mars = planets.Mars || {};
const moon = planets.Moon || {};
const venus = planets.Venus || {};
const lagnaHouse = Number(mars.house);
const moonHouse = houseFromSign(moon.sign, mars.sign);
const venusHouse = houseFromSign(venus.sign, mars.sign);
const refs = [
{ from: 'Lagna', house: lagnaHouse },
{ from: 'Moon', house: moonHouse },
{ from: 'Venus', house: venusHouse },
].filter(item => Number.isFinite(item.house));
const doshaHouses = new Set([1, 2, 4, 7, 8, 12]);
const severeHouses = new Set([7, 8]);
const hits = refs.filter(item => doshaHouses.has(item.house));
const mitigations = [];
if (['Aries', 'Scorpio', 'Capricorn'].includes(mars.sign)) mitigations.push('Mars 入庙/旺相缓解');
if (planets.Jupiter?.house === mars.house) mitigations.push('Jupiter 同宫保护');
let score = hits.reduce((sum, item) => sum + (severeHouses.has(item.house) ? 2 : 1), 0);
if (mitigations.length) score = Math.max(0, score - 1);
const level = score >= 4 ? 'high' : score >= 2 ? 'medium' : score >= 1 ? 'low' : 'none';
const levelLabel = { high: '偏强', medium: '中等', low: '轻微', none: '未见明显' }[level];
return {
score,
level,
levelLabel,
marsSign: mars.sign || '-',
marsHouse: Number.isFinite(lagnaHouse) ? lagnaHouse : '-',
hits,
mitigations,
};
}
function houseFromSign(fromSign, toSign) {
const fromIdx = SIGNS.indexOf(fromSign);
const toIdx = SIGNS.indexOf(toSign);
if (fromIdx < 0 || toIdx < 0) return NaN;
return ((toIdx - fromIdx + 12) % 12) + 1;
}
function buildDashaSync(selfChart, partnerChart) {
const self = getCurrentDashaSnapshot(selfChart);
const partner = getCurrentDashaSnapshot(partnerChart);
const shared = [self.maha, self.antar, self.pratyantar].filter(Boolean)
.filter(lord => [partner.maha, partner.antar, partner.pratyantar].includes(lord));
const relationshipLords = new Set(['Venus', 'Jupiter', 'Moon', 'Mars']);
const selfRelationship = [self.maha, self.antar].some(lord => relationshipLords.has(lord));
const partnerRelationship = [partner.maha, partner.antar].some(lord => relationshipLords.has(lord));
let level = 'neutral';
let label = '节奏中性';
let note = '当前大运/小运没有明显同步,适合继续看行运触发与现实互动。';
if (shared.length) {
level = 'strong';
label = 'Dasha 同步强';
note = `双方当前运限共享 ${shared.map(planetName).join('、')},关系议题更容易被同一类事件激活。`;
} else if (selfRelationship && partnerRelationship) {
level = 'supportive';
label = '关系主题同时被激活';
note = '双方当前运限都触及 Venus/Jupiter/Moon/Mars,亲密关系议题的可见度较高。';
}
return { self, partner, shared, level, label, note };
}
function getCurrentDashaSnapshot(chart) {
const moonLon = getChartMoonLongitude(chart);
const date = chart?.birth_info?.date;
if (!Number.isFinite(moonLon) || !date) return { label: '-', maha: '', antar: '', pratyantar: '' };
const dasha = computeDashaWithPratyantar(moonLon, date, new Date().toISOString().split('T')[0]);
const current = dasha?.current || dasha?.current_dasha || {};
const maha = current.maha_lord || current.maha || current.md || current.lord || '';
const antar = current.antar_lord || current.antar || current.ad || '';
const pratyantar = current.pratyantar_lord || current.pratyantar || current.pd || '';
const label = [maha, antar, pratyantar].filter(Boolean).map(planetName).join(' / ') || '-';
return { label, maha, antar, pratyantar };
}
function formatDegreeInSign(value) {
const n = Number(value);
return Number.isFinite(n) ? `${n.toFixed(2)}°` : '';
}
function buildBiWheelComparisonData(selfChart, partnerChart) {
const axes = [
buildBiWheelAxisRow('上升轴', buildChartAxisPoint(selfChart), buildChartAxisPoint(partnerChart), '身体节奏、第一印象与共同生活入口'),
buildBiWheelAxisRow('月亮轴', buildSynastryPlanetPoint(selfChart, 'Moon'), buildSynastryPlanetPoint(partnerChart, 'Moon'), '情绪安全感、日常照料和亲密反应'),
buildBiWheelAxisRow('金星轴', buildSynastryPlanetPoint(selfChart, 'Venus'), buildSynastryPlanetPoint(partnerChart, 'Venus'), '吸引力、价值交换和关系愉悦感'),
buildBiWheelAxisRow('火星轴', buildSynastryPlanetPoint(selfChart, 'Mars'), buildSynastryPlanetPoint(partnerChart, 'Mars'), '行动节奏、冲突方式和欲望表达'),
].filter(row => row.self?.sign || row.partner?.sign);
const planets = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'];
const rows = planets.map(planet => {
const self = buildSynastryPlanetPoint(selfChart, planet);
const partner = buildSynastryPlanetPoint(partnerChart, planet);
if (!self.sign && !partner.sign) return null;
return {
planet,
label: planetName(planet),
self,
partner,
relation: buildSignRelationship(self.sign, partner.sign),
partnerOverlayHouse: overlayHouseFromChart(selfChart, partner.sign),
selfOverlayHouse: overlayHouseFromChart(partnerChart, self.sign),
theme: relationshipPlanetTheme(planet),
};
}).filter(Boolean);
return {
axes,
rows,
compositeStyle: buildCompositeStyleMidpoints(selfChart, partnerChart),
};
}
function buildBiWheelAxisRow(label, self, partner, theme) {
return {
label,
self,
partner,
relation: buildSignRelationship(self?.sign, partner?.sign),
theme,
};
}
function buildChartAxisPoint(chart) {
const asc = chart?.ascendant || {};
return {
sign: asc.sign || '',
degree: Number.isFinite(Number(asc.degree_in_sign ?? asc.degree)) ? Number(asc.degree_in_sign ?? asc.degree) : null,
house: 1,
label: asc.sign ? `${signName(asc.sign)} ${formatDegreeInSign(asc.degree_in_sign ?? asc.degree)}` : '-',
};
}
function buildSynastryPlanetPoint(chart, planet) {
const raw = chart?.planets?.[planet] || {};
const lon = normalizeLongitude(raw.lon ?? raw.longitude ?? raw.absolute_degree ?? raw.degree);
const sign = raw.sign || signFromLongitude(lon) || '';
const degree = Number.isFinite(Number(raw.degree_in_sign))
? Number(raw.degree_in_sign)
: Number.isFinite(lon)
? lon % 30
: Number.isFinite(Number(raw.degree))
? Number(raw.degree)
: null;
return {
planet,
sign,
degree,
lon: Number.isFinite(lon) ? lon : null,
house: Number.isFinite(Number(raw.house)) ? Number(raw.house) : null,
nakshatra: raw.nakshatra || '',
label: sign ? `${signName(sign)} ${formatDegreeInSign(degree)}` : '-',
};
}
function normalizeLongitude(value) {
const n = Number(value);
return Number.isFinite(n) ? ((n % 360) + 360) % 360 : NaN;
}
function signFromLongitude(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
return SIGNS[Math.floor(normalizeLongitude(n) / 30) % 12] || '';
}
function buildSignRelationship(selfSign, partnerSign) {
const selfIdx = SIGNS.indexOf(selfSign);
const partnerIdx = SIGNS.indexOf(partnerSign);
if (selfIdx < 0 || partnerIdx < 0) {
return { houseDistance: '-', label: '待补充', tone: 'neutral' };
}
const houseDistance = ((partnerIdx - selfIdx + 12) % 12) + 1;
const labels = {
1: '同星座共振',
2: '资源磨合',
3: '沟通学习',
4: '家庭/安全感张力',
5: '三方支持',
6: '调适与服务',
7: '对宫吸引',
8: '深层转化',
9: '三方支持',
10: '目标/责任张力',
11: '社群与愿景支持',
12: '隐性议题',
};
const tone = [1, 5, 9, 11].includes(houseDistance)
? 'support'
: [4, 7, 10].includes(houseDistance)
? 'tension'
: [2, 6, 8, 12].includes(houseDistance)
? 'work'
: 'neutral';
return { houseDistance, label: labels[houseDistance] || '中性', tone };
}
function overlayHouseFromChart(chart, sign) {
const house = houseFromSign(chart?.ascendant?.sign, sign);
return Number.isFinite(house) ? house : null;
}
function buildCompositeStyleMidpoints(selfChart, partnerChart) {
return ['Sun', 'Moon', 'Venus', 'Mars'].map(planet => {
const self = buildSynastryPlanetPoint(selfChart, planet);
const partner = buildSynastryPlanetPoint(partnerChart, planet);
if (!Number.isFinite(self.lon) || !Number.isFinite(partner.lon)) return null;
const lon = midpointLongitude(self.lon, partner.lon);
const sign = signFromLongitude(lon);
return {
planet,
label: planetName(planet),
sign,
degree: lon % 30,
note: `${planetName(planet)} midpoint 指向共同关系场的${relationshipPlanetTheme(planet)}`,
};
}).filter(Boolean);
}
function midpointLongitude(a, b) {
const start = normalizeLongitude(a);
const delta = ((normalizeLongitude(b) - start + 540) % 360) - 180;
return normalizeLongitude(start + delta / 2);
}
function relationshipPlanetTheme(planet) {
return {
Sun: '身份认同与长期方向',
Moon: '情绪安全感',
Mars: '行动、冲突与欲望',
Mercury: '沟通和日常协商',
Jupiter: '信念、承诺和成长',
Venus: '吸引力、价值和愉悦',
Saturn: '责任、边界和时间压力',
Rahu: '新奇、执念和突破常规',
Ketu: '疏离、灵性和旧模式',
}[planet] || '关系互动';
}
function buildRelationshipReportTemplate(data = {}, deep = null) {
const total = Number(data.total_score ?? data.score?.total ?? 0);
const max = Number(data.max_score ?? data.score?.max ?? 36) || 36;
const percentage = max ? Number(((total / max) * 100).toFixed(1)) : 0;
const scores = data.scores || {};
const kutaDetails = Object.entries(scores).map(([name, score]) => {
const maxScore = Number(kutaMax(name));
const value = Number(score);
const ratio = Number.isFinite(value) && Number.isFinite(maxScore) && maxScore > 0 ? value / maxScore : 0;
return { name, score: Number.isFinite(value) ? value : score, max: Number.isFinite(maxScore) ? maxScore : '-', ratio };
});
const strongestKutas = kutaDetails.filter(item => item.ratio >= 0.75).sort((a, b) => b.ratio - a.ratio).slice(0, 3);
const weakKutas = kutaDetails.filter(item => item.ratio <= 0.35).sort((a, b) => a.ratio - b.ratio).slice(0, 3);
const d9Scores = [deep?.selfD9?.score, deep?.partnerD9?.score].map(Number).filter(Number.isFinite);
const d9Average = d9Scores.length ? Number((d9Scores.reduce((sum, value) => sum + value, 0) / d9Scores.length).toFixed(1)) : null;
const kuja = deep?.kuja || null;
const dasha = deep?.dasha || null;
const hasDeep = Boolean(deep?.selfD9 || deep?.partnerD9 || kuja || dasha);
const spouseStatus = deep?.spouseStatus || null;
const ulDkTiming = deep?.ulDkTiming || null;
const strictNarrative = data.ai_prompt_pack?.evidence_snapshot?.relationship_narrative || data.relationship_narrative || deep?.relationshipNarrative || null;
const vedastroOverview = data.ai_prompt_pack?.evidence_snapshot?.vedastro_overview
|| data.vedastro_overview
|| deep?.vedastroOverview
|| null;
const lifeEventGraph = data.life_event_graph || deep?.lifeEventGraph || null;
const strictMarkdown = typeof strictNarrative?.markdown === 'string' ? strictNarrative.markdown : '';
const strictSecondaryContext = strictMarkdown.includes('public_formalization_candidate')
|| (Array.isArray(strictNarrative?.strengths) && strictNarrative.strengths.some(item => String(item).includes('public_formalization_candidate')))
|| (Array.isArray(strictNarrative?.boundaries) && strictNarrative.boundaries.some(item => String(item).includes('public_formalization_candidate')));
const hasPublicFormalizationCandidate = strictSecondaryContext;
const hasConflictWarning = (
Array.isArray(strictNarrative?.risks)
&& strictNarrative.risks.some(item => String(item).includes('不能误读成接近结婚'))
) || (
Array.isArray(strictNarrative?.boundaries)
&& strictNarrative.boundaries.some(item => String(item).includes('不等于法律婚姻'))
);
const approvedFlag = data.is_match_approved ?? data.is_approved;
const conservativePublicFormalizationStatus = hasPublicFormalizationCandidate && hasConflictWarning
? 'needs_context'
: null;
const status = hasPublicFormalizationCandidate && hasConflictWarning ? 'needs_context'
: conservativePublicFormalizationStatus
|| (percentage >= 78 && approvedFlag !== false
? 'strong'
: percentage >= 58 && approvedFlag !== false
? 'workable'
: 'needs_context');
const statusLabel = hasPublicFormalizationCandidate && hasConflictWarning
? '公开化候选,不等于婚姻逼近'
: {
strong: '高兼容,仍需完整复核',
workable: '可推进观察',
needs_context: '需谨慎综合判断',
}[status];
const headline = hasPublicFormalizationCandidate && hasConflictWarning
? '公开化候选浮现,但婚姻承诺与时机仍需保守复核。'
: status === 'strong'
? 'Ashtakoot 基础匹配较强,适合进入 D9、Kuja 与现实互动复核。'
: status === 'workable'
? '基础匹配有可用支撑,但关键风险位需要逐项确认。'
: '当前匹配不能只按总分推进,需要把红旗与时机证据放在前面。';
const strengths = strongestKutas.map(item => `${item.name} ${item.score}/${item.max}:此项支持关系中的${relationshipKutaMeaning(item.name)}`);
if (d9Average !== null && d9Average >= 3) strengths.push(`D9 平均 ${d9Average}/8:双方婚姻分盘至少有可用支撑。`);
if (dasha?.level === 'strong' || dasha?.level === 'supportive') strengths.push(dasha.note || dasha.label);
collectULDKTimingStrengths(ulDkTiming).forEach(item => strengths.push(item));
collectSpouseStatusStrengths(spouseStatus).forEach(item => strengths.push(item));
if (Array.isArray(strictNarrative?.strengths)) strictNarrative.strengths.forEach(item => strengths.push(item));
if (hasPublicFormalizationCandidate) {
strengths.push('public_formalization_candidate 说明当前更偏向公开化/关系可见度候选,而不是法律婚姻本身。');
}
const vedastroNarrative = buildVedAstroOverviewNarrative(vedastroOverview);
if (vedastroNarrative) strengths.push(vedastroNarrative);
if (!strengths.length) strengths.push('尚未出现足够稳定的强项,建议先补完整出生盘与 D9 复核。');
const risks = weakKutas.map(item => `${item.name} ${item.score}/${item.max}:需重点观察${relationshipKutaMeaning(item.name)}`);
if (kuja?.verdict === 'hard' || kuja?.verdict === 'watch') risks.push(kuja.note || kuja.label);
if (d9Average !== null && d9Average < 2) risks.push(`D9 平均 ${d9Average}/8:婚姻分盘质量偏弱,不能只依赖合盘总分。`);
if ((data.exceptions || []).length) risks.push(`存在缓解条件:${(data.exceptions || []).join('')}`);
collectULDKTimingRisks(ulDkTiming).forEach(item => risks.push(item));
collectSpouseStatusRisks(spouseStatus).forEach(item => risks.push(item));
if (Array.isArray(strictNarrative?.risks)) strictNarrative.risks.forEach(item => risks.push(item));
if (hasPublicFormalizationCandidate) {
risks.push('当前即便存在合盘支持与公开化候选,也不能误读成接近结婚;若 weak core promise、dual dasha 或 external timing 未收敛,仍应保持保守。');
}
if (vedastroOverview?.status === 'ok') {
risks.push('VedAstro 概览提示只代表单日外部雷达快照,overview only,不替代长周期精扫与本地主裁决。');
}
if (!hasDeep) risks.push('当前只完成基础 Ashtakoot,缺少 D9、Kuja Dosha 与 Dasha 同步证据。');
if (!risks.length) risks.push('未见明显红旗,但仍需结合双方完整星盘与现实互动。');
const evidence = [
{ label: 'Ashtakoot', value: `${formatReportNumber(total)} / ${formatReportNumber(max)}`, note: `${percentage}% · ${data.assessment || statusLabel}` },
{ label: 'D9 质量', value: d9Average === null ? '待补充' : `${d9Average} / 8`, note: d9Scores.length ? '取双方 D9 婚姻快照平均值' : '需要双方准确出生时间' },
{ label: 'Kuja Dosha', value: kuja?.label || '待补充', note: kuja?.note || '完整出生盘后可判断冲突压力是否平衡' },
{ label: 'Dasha 时机', value: dasha?.label || '待补充', note: dasha?.note || '需要双方 Moon 与出生日期计算当前运限' },
{ label: 'UL/DK 时机', value: formatULDKTimingEvidence(ulDkTiming), note: 'Jaimini 配偶征象与当前运限触发的交叉检查' },
{ label: '配偶/婚后成长', value: formatSpouseStatusEvidence(spouseStatus), note: '来自 7宫/7主/成长宫的个人关系体质快照' },
];
return {
status,
statusLabel,
headline: strictNarrative?.headline || headline,
percentage,
evidence,
strongestKutas,
weakKutas,
strengths: strengths.slice(0, 5),
risks: risks.slice(0, 5),
nextSteps: [
'先确认双方出生时间精度,再复核 D9 与 7宫/7主。',
'把低分 Kuta 对应到现实互动议题,不用单一分数替代沟通。',
'若 Kuja 或 Dasha 出现压力,优先安排边界、节奏和冲突处理观察期。',
...(vedastroOverview?.status === 'ok' ? ['先把 VedAstro 概览提示当作外部补证,再决定是否值得做长周期精扫。'] : []),
...(hasPublicFormalizationCandidate ? [
'若当前更偏向 public_formalization_candidate,请把它理解为关系公开化候选,而不是婚姻逼近。',
'先不要把高 Ashtakoot 分数翻译成婚姻逼近,应先复核 promise、dual dasha 与 external timing。',
] : []),
],
boundaries: [
'合盘报告只提供传统 Jyotish 证据,不替代个人选择。',
'总分通过不等于关系必然稳定;总分偏低也不等于关系不可经营。',
...(vedastroOverview?.status === 'ok' ? ['VedAstro 概览提示属于 main-entry overview only,不替代长周期精扫,也不得越权改写主标签。'] : []),
...(hasPublicFormalizationCandidate ? ['public_formalization_candidate 只表示公开化候选,不得越权抬升 legal_marriage,也不能误读成接近结婚。'] : []),
...((Array.isArray(strictNarrative?.boundaries) ? strictNarrative.boundaries : [])),
].slice(0, 5),
vedastroOverview,
lifeEventGraph,
};
}
function buildVedAstroOverviewNarrative(overview) {
if (!overview || typeof overview !== 'object' || overview.status !== 'ok') return '';
const marriage = overview.top_events_by_domain?.marriage || null;
const label = marriage?.signal_label || marriage?.event_id || '外部婚恋窗口';
const when = marriage?.start || overview.reference_date || '-';
return `VedAstro 概览提示:${label}${when});这是 main-entry overview only,不替代长周期精扫。`;
}
function collectSpouseStatusStrengths(spouseStatus) {
return ['self', 'partner'].map(key => {
const item = spouseStatus?.[key];
if (!item || item.level === 'low') return '';
return `${key === 'self' ? '本人' : '对方'}${item.verdict},可作为关系长期资源/成长证据。`;
}).filter(Boolean);
}
function collectSpouseStatusRisks(spouseStatus) {
return ['self', 'partner'].map(key => {
const item = spouseStatus?.[key];
if (!item || item.level !== 'low') return '';
return `${key === 'self' ? '本人' : '对方'}${item.verdict},需结合 D9 与现实承诺复核。`;
}).filter(Boolean);
}
function collectULDKTimingStrengths(ulDkTiming) {
return ['self', 'partner'].map(key => {
const item = ulDkTiming?.[key];
if (!item || item.level === 'low') return '';
return `${key === 'self' ? '本人' : '对方'}${item.summary},关系时机有可读触发。`;
}).filter(Boolean);
}
function collectULDKTimingRisks(ulDkTiming) {
return ['self', 'partner'].map(key => {
const item = ulDkTiming?.[key];
if (!item || item.level !== 'low') return '';
return `${key === 'self' ? '本人' : '对方'}${item.summary},当前运限未明显触发 DK/UL。`;
}).filter(Boolean);
}
function formatSpouseStatusEvidence(spouseStatus) {
const self = spouseStatus?.self?.verdict || '待补充';
const partner = spouseStatus?.partner?.verdict || '待补充';
return `本人:${self} / 对方:${partner}`;
}
function formatULDKTimingEvidence(ulDkTiming) {
const self = ulDkTiming?.self?.summary || '待补充';
const partner = ulDkTiming?.partner?.summary || '待补充';
return `本人:${self} / 对方:${partner}`;
}
function relationshipKutaMeaning(name) {
return {
Varna: '精神追求与价值层级',
Vashya: '影响力与相处主导权',
Tara: '生活阶段与支持感',
Yoni: '性格/身体语言兼容',
GrahaMaitri: '思维方式与情感理解',
Gana: '气质类型和社交风格',
Bhakoot: '情绪频率与整体关系节奏',
Nadi: '体质、习惯和深层相似性',
}[name] || '关系互动';
}
function formatReportNumber(value) {
const n = Number(value);
if (!Number.isFinite(n)) return value ?? '-';
return Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, '');
}
function renderSynastryResult(data, deep = null) {
const scores = data.scores || {};
const male = data.male_details || data.male || {};
const female = data.female_details || data.female || {};
const approvedFlag = data.is_match_approved ?? data.is_approved;
const percentage = data.match_percentage ?? ((Number(data.total_score || 0) / Number(data.max_score || 36)) * 100).toFixed(1);
const relationshipReport = deep?.relationshipReport || data.relationshipReport || data.relationship_report || buildRelationshipReportTemplate(data, deep);
const rows = Object.entries(scores).map(([name, score]) => `
<tr><td>${escapeHtml(name)}</td><td>${escapeHtml(score)} / ${escapeHtml(kutaMax(name))}</td></tr>
`).join('');
const approved = approvedFlag ? '匹配通过' : '需要谨慎综合判断';
const approvedClass = approvedFlag ? 'synastry-ok' : 'synastry-warn';
const exceptionHtml = (data.exceptions || []).length
? `<div class="synastry-note"><strong>缓解条件</strong><p>${(data.exceptions || []).map(escapeHtml).join('<br>')}</p></div>`
: '';
return `
<div class="synastry-score ${approvedClass}">
<strong>${escapeHtml(data.total_score ?? 0)} / ${escapeHtml(data.max_score ?? 36)}</strong>
<span>${escapeHtml(percentage)}% · ${approved}</span>
</div>
<div class="synastry-details">
${renderPartnerDetails('本人', male)}
${renderPartnerDetails('对方', female)}
</div>
<div class="synastry-table-wrap">
<table class="synastry-table">
<thead><tr><th>Kuta</th><th>得分</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
${exceptionHtml}
${renderRelationshipReport(relationshipReport)}
${deep ? renderSynastryDeepResult(deep) : ''}
${deep ? renderSynastryPairActions() : ''}
<p class="synastry-boundary">合盘分数不能单独决定关系。正式关系判断还需结合双方完整星盘、7宫/7主、D9、Kuja Dosha、Dasha 同步、现实互动与自主选择。</p>
`;
}
function buildSynastryPairRecord(selfChart, partnerChart, data, deep, options = {}) {
const now = new Date().toISOString();
const selfId = buildWorkspaceChartId(selfChart) || 'current-chart';
const partnerId = buildWorkspaceChartId(partnerChart) || `partner-${now}`;
const total = Number(data.total_score ?? 0);
const max = Number(data.max_score ?? 36);
const partnerLabel = options.partnerLabel || buildWorkspaceChartLabel(partnerChart);
const relationshipReport = buildRelationshipReportTemplate(data, deep);
return {
id: `${selfId}__${partnerId}`,
label: `${buildWorkspaceChartLabel(selfChart)}${partnerLabel}`,
group: '伴侣',
relation: 'partner',
tags: ['synastry', 'relationship', 'ashtakoot'],
generatedAt: now,
updatedAt: now,
selfChartId: selfId,
partnerChartId: partnerId,
self: buildSynastryChartSummary(selfChart),
partner: buildSynastryChartSummary(partnerChart),
score: {
total,
max,
percentage: max ? Number(((total / max) * 100).toFixed(1)) : 0,
},
verdict: data.assessment || (data.is_approved ? '匹配通过' : '需要谨慎综合判断'),
scores: data.scores || {},
exceptions: data.exceptions || [],
relationshipReport,
deep: deep ? normalizeSynastryDeepForExport({ ...deep, relationshipReport }) : null,
source: options.partnerLabel ? 'saved_partner_chart' : 'full_birth_chart',
data: {
total_score: data.total_score ?? 0,
max_score: data.max_score ?? 36,
match_percentage: data.match_percentage ?? (max ? Number(((total / max) * 100).toFixed(1)) : 0),
is_match_approved: data.is_match_approved ?? data.is_approved ?? false,
scores: data.scores || {},
exceptions: data.exceptions || [],
male_details: data.male_details || data.male || {},
female_details: data.female_details || data.female || {},
assessment: data.assessment || '',
relationship_report: relationshipReport,
},
};
}
function renderSynastryPairActions() {
return `
<div class="synastry-pair-actions">
<button type="button" class="btn-secondary" data-action="save-synastry-pair">保存配对记录</button>
<button type="button" class="btn-secondary" data-action="export-synastry-pair">导出当前配对</button>
<button type="button" class="btn-secondary" data-action="export-synastry-html">导出 HTML 报告</button>
</div>
`;
}
function setupSynastryPairActions() {
const result = $('synastry-result');
if (result && result.dataset.pairActionsBound !== 'true') {
result.dataset.pairActionsBound = 'true';
result.addEventListener('click', event => {
const btn = event.target.closest('[data-action="save-synastry-pair"], [data-action="export-synastry-pair"], [data-action="export-synastry-html"]');
if (!btn) return;
if (btn.dataset.action === 'save-synastry-pair') {
saveCurrentSynastryPair();
}
if (btn.dataset.action === 'export-synastry-pair') {
exportCurrentSynastryPair();
}
if (btn.dataset.action === 'export-synastry-html') {
exportCurrentSynastryHTMLReport();
}
});
}
const workspace = $('synastry-pair-workspace');
if (!workspace || workspace.dataset.pairWorkspaceBound === 'true') return;
workspace.dataset.pairWorkspaceBound = 'true';
workspace.addEventListener('click', event => {
const btn = event.target.closest('[data-action="save-synastry-pair"], [data-action="export-synastry-pair"]');
const actionBtn = btn || event.target.closest('.mini-action');
if (!actionBtn) return;
if (actionBtn.dataset.action === 'workspace-open-pair') openSavedSynastryPair(actionBtn.dataset.caseId || '');
if (actionBtn.dataset.action === 'workspace-delete-pair') deleteSavedSynastryPair(actionBtn.dataset.caseId || '');
if (!btn) return;
if (btn.dataset.action === 'save-synastry-pair') {
saveCurrentSynastryPair();
}
if (btn.dataset.action === 'export-synastry-pair') {
exportCurrentSynastryPair();
}
});
}
function sortSynastryPairLibrary(lib) {
return [...(Array.isArray(lib) ? lib : [])].sort((a, b) => {
const aTime = Date.parse(a?.updatedAt || a?.generatedAt || '') || 0;
const bTime = Date.parse(b?.updatedAt || b?.generatedAt || '') || 0;
return bTime - aTime;
});
}
function saveCurrentSynastryPair() {
if (!_lastSynastryPairRecord) return;
const now = new Date().toISOString();
const lib = readSynastryPairLibrary();
const existing = lib.find(record => record?.id === _lastSynastryPairRecord.id);
if (existing) {
Object.assign(existing, _lastSynastryPairRecord, { updatedAt: now, generatedAt: existing.generatedAt || now });
} else {
lib.push({ ..._lastSynastryPairRecord, generatedAt: now, updatedAt: now });
}
writeSynastryPairLibrary(sortSynastryPairLibrary(lib).slice(0, 50));
renderSynastryPairWorkspace();
}
function exportCurrentSynastryPair() {
if (!_lastSynastryPairRecord) return;
const safeId = String(_lastSynastryPairRecord.id || 'pair').replace(/[^a-z0-9_-]+/gi, '-');
downloadText(JSON.stringify(_lastSynastryPairRecord, null, 2), `jyotish-synastry-${safeId}.json`, 'application/json;charset=utf-8');
}
async function exportCurrentSynastryHTMLReport() {
if (!chartData || !_lastSynastryPairRecord) return;
const workflows = ensureClientWorkflows();
if (workflows) {
workflows.synastry = buildSynastryWorkflowFromPair(_lastSynastryPairRecord);
}
const { exportHTMLReport } = await loadExportModule();
exportHTMLReport(chartData, buildExportExtras(chartData));
}
function buildSynastryWorkflowFromPair(record) {
const replayDeep = buildSynastryReplayDeep(record);
const report = record.relationshipReport || record.data?.relationship_report || record.deep?.relationshipReport || buildRelationshipReportTemplate(record.data || record, replayDeep);
return {
mode: record.source || 'saved_pair',
generatedAt: record.generatedAt || record.updatedAt || new Date().toISOString(),
total_score: record.score?.total ?? 0,
max_score: record.score?.max ?? 36,
match_percentage: record.score?.percentage ?? 0,
is_match_approved: String(record.verdict || '').includes('通过'),
scores: record.scores || {},
exceptions: record.exceptions || [],
male_details: record.data?.male_details || record.data?.male || {},
female_details: record.data?.female_details || record.data?.female || {},
assessment: record.verdict || record.data?.assessment || '',
relationship_report: report,
deep: record.deep || null,
};
}
function renderSavedSynastryPair(record) {
const data = record?.data || {
total_score: record?.score?.total ?? 0,
max_score: record?.score?.max ?? 36,
match_percentage: record?.score?.percentage ?? 0,
scores: record?.scores || {},
exceptions: record?.exceptions || [],
assessment: record?.verdict || '',
};
return `
<div class="case-replay-banner">
<strong>已打开保存配对</strong>
<span>${escapeHtml(record?.label || record?.id || '未命名配对')} · ${escapeHtml(formatSavedAt(record?.updatedAt || record?.generatedAt))}</span>
</div>
${renderSynastryResult(data, buildSynastryReplayDeep(record))}
`;
}
function buildSynastryReplayDeep(record) {
const deep = record?.deep || {};
const report = deep.relationshipReport || record?.relationshipReport || record?.data?.relationship_report || null;
if (!deep.partnerSummary && !record?.partner) {
return report ? { relationshipReport: report } : null;
}
const dasha = deep.dasha ? {
level: deep.dasha.level || 'saved',
label: deep.dasha.label || 'Dasha 同步',
note: deep.dasha.note || '-',
self: typeof deep.dasha.self === 'object' ? deep.dasha.self : { label: deep.dasha.self || '-' },
partner: typeof deep.dasha.partner === 'object' ? deep.dasha.partner : { label: deep.dasha.partner || '-' },
} : null;
const kuja = deep.kuja ? {
verdict: deep.kuja.verdict || 'balanced',
label: deep.kuja.label || 'Kuja Dosha',
note: deep.kuja.note || '-',
self: typeof deep.kuja.self === 'object' ? deep.kuja.self : { levelLabel: deep.kuja.self || '-', marsHouse: '-', marsSign: '-' },
partner: typeof deep.kuja.partner === 'object' ? deep.kuja.partner : { levelLabel: deep.kuja.partner || '-', marsHouse: '-', marsSign: '-' },
} : null;
return {
partnerSummary: deep.partnerSummary || record.partner || {},
dasha,
kuja,
comparison: normalizeReplayComparison(deep.comparison || record.comparison),
spouseStatus: normalizeReplaySpouseStatus(deep.spouseStatus || record.spouseStatus),
ulDkTiming: normalizeReplayULDKTiming(deep.ulDkTiming || record.ulDkTiming),
selfD9: normalizeReplayD9(deep.selfD9 || deep.d9?.self),
partnerD9: normalizeReplayD9(deep.partnerD9 || deep.d9?.partner),
relationshipReport: report,
};
}
function normalizeReplayComparison(comparison) {
if (!comparison) return null;
return {
axes: Array.isArray(comparison.axes) ? comparison.axes : [],
rows: Array.isArray(comparison.rows) ? comparison.rows : [],
compositeStyle: Array.isArray(comparison.compositeStyle) ? comparison.compositeStyle : [],
};
}
function normalizeReplaySpouseStatus(spouseStatus) {
if (!spouseStatus) return null;
const normalize = item => item ? {
ascSign: item.ascSign || '',
seventhSign: item.seventhSign || '',
seventhLord: item.seventhLord || '',
lagnaLord: item.lagnaLord || '',
score: item.score ?? 0,
level: item.level || 'low',
verdict: item.verdict || '未见明显高地位配偶 Yoga',
evidence: Array.isArray(item.evidence) ? item.evidence : [],
} : null;
return {
self: normalize(spouseStatus.self),
partner: normalize(spouseStatus.partner),
};
}
function normalizeReplayULDKTiming(ulDkTiming) {
if (!ulDkTiming) return null;
const normalize = item => item ? {
dk7: item.dk7 || null,
dk8: item.dk8 || null,
ul: item.ul || null,
dasha: item.dasha || null,
triggers: Array.isArray(item.triggers) ? item.triggers : [],
level: item.level || 'low',
summary: item.summary || 'UL/DK 时机待补充',
evidence: Array.isArray(item.evidence) ? item.evidence : [],
} : null;
return {
self: normalize(ulDkTiming.self),
partner: normalize(ulDkTiming.partner),
};
}
function normalizeReplayD9(snapshot) {
if (!snapshot) return null;
return {
score: snapshot.score ?? '-',
zone: typeof snapshot.zone === 'object' ? snapshot.zone : { name: snapshot.zone || '保存摘要' },
core: snapshot.core || [],
positives: snapshot.positives || [],
risks: snapshot.risks || [],
};
}
function renderSynastryPairWorkspace() {
const container = $('synastry-pair-workspace');
if (!container) return;
const lib = sortSynastryPairLibrary(readSynastryPairLibrary());
if (!lib.length) {
container.innerHTML = '<div class="synastry-pair-empty">暂无保存配对记录。完成一次完整合盘后可保存当前结果。</div>';
return;
}
container.innerHTML = `
<div class="synastry-pair-head">
<strong>保存配对记录</strong>
<span>${lib.length} 组</span>
</div>
<div class="synastry-pair-list">
${lib.slice(0, 3).map(record => `
<div class="synastry-pair-row">
<div>
<strong>${escapeHtml(record.label || record.id || '未命名配对')}</strong>
<span>${escapeHtml(record.score?.total ?? 0)} / ${escapeHtml(record.score?.max ?? 36)} · ${escapeHtml(record.verdict || '-')}</span>
<small>${escapeHtml(formatSavedAt(record.updatedAt || record.generatedAt))}</small>
</div>
<div class="case-row-actions">
<button type="button" class="mini-action" data-action="workspace-open-pair" data-case-id="${escapeAttr(record.id || '')}">打开</button>
<button type="button" class="mini-action danger" data-action="workspace-delete-pair" data-case-id="${escapeAttr(record.id || '')}">删除</button>
</div>
</div>
`).join('')}
</div>
`;
}
function renderSynastryDeepResult(deep) {
const cards = [
deep.partnerSummary ? renderSynastryPartnerSummary(deep.partnerSummary) : '',
deep.dasha ? renderSynastryDashaCard(deep.dasha) : '',
deep.kuja ? renderSynastryKujaCard(deep.kuja) : '',
].filter(Boolean).join('');
const d9Cards = [
deep.selfD9 ? renderSynastryD9Card('本人 D9', deep.selfD9) : '',
deep.partnerD9 ? renderSynastryD9Card('对方 D9', deep.partnerD9) : '',
].filter(Boolean).join('');
return `
${renderBiWheelComparisonView(deep.comparison)}
${renderULDKTimingComparison(deep.ulDkTiming)}
${renderSpouseStatusComparison(deep.spouseStatus)}
${cards ? `<div class="synastry-deep-grid">${cards}</div>` : ''}
${d9Cards ? `<div class="synastry-d9-compare">${d9Cards}</div>` : ''}
`;
}
function renderULDKTimingComparison(ulDkTiming) {
if (!ulDkTiming?.self && !ulDkTiming?.partner) return '';
return `
<div class="uldk-timing-comparison">
<div class="uldk-timing-head">
<strong>UL/DK 与关系时机</strong>
<span>Jaimini · Dasha trigger</span>
</div>
<div class="uldk-timing-grid">
${renderULDKTimingCard('本人', ulDkTiming.self)}
${renderULDKTimingCard('对方', ulDkTiming.partner)}
</div>
</div>
`;
}
function renderULDKTimingCard(label, item) {
if (!item) {
return `
<div class="uldk-timing-card uldk-timing-low">
<strong>${escapeHtml(label)}</strong>
<p>缺少 Jaimini/运限数据,暂无法判断。</p>
</div>
`;
}
const triggers = (item.triggers || []).slice(0, 4);
return `
<div class="uldk-timing-card uldk-timing-${escapeAttr(item.level || 'low')}">
<strong>${escapeHtml(label)} · ${escapeHtml(item.summary || 'UL/DK 时机待补充')}</strong>
<p>DK7 ${escapeHtml(formatDKPoint(item.dk7))} · DK8 ${escapeHtml(formatDKPoint(item.dk8))}</p>
<p>UL ${escapeHtml(formatULPoint(item.ul))} · 当前 ${escapeHtml(item.dasha?.label || '-')}</p>
<ul>
${(triggers.length ? triggers : ['当前运限未明显触发 DK/UL,需要结合行运和现实进展。']).map(text => `<li>${escapeHtml(text)}</li>`).join('')}
</ul>
</div>
`;
}
function formatDKPoint(point) {
if (!point) return '待补充';
return `${planetName(point.planet || '')}${point.sign ? ` ${signName(point.sign)}` : ''}${point.house ? ` H${point.house}` : ''}`;
}
function formatULPoint(point) {
if (!point) return '待补充';
return `${signName(point.sign || '')}${point.pada_house ? ` H${point.pada_house}` : ''}${point.lord ? ` · 主 ${planetName(point.lord)}` : ''}`;
}
function renderSpouseStatusComparison(spouseStatus) {
if (!spouseStatus?.self && !spouseStatus?.partner) return '';
return `
<div class="spouse-status-comparison">
<div class="spouse-status-head">
<strong>配偶/婚后成长 Yoga</strong>
<span>spouse_status_yoga.py</span>
</div>
<div class="spouse-status-grid">
${renderSpouseStatusCard('本人', spouseStatus.self)}
${renderSpouseStatusCard('对方', spouseStatus.partner)}
</div>
</div>
`;
}
function renderSpouseStatusCard(label, item) {
if (!item) {
return `
<div class="spouse-status-card spouse-status-low">
<strong>${escapeHtml(label)}</strong>
<p>缺少出生盘数据,暂无法判断。</p>
</div>
`;
}
return `
<div class="spouse-status-card spouse-status-${escapeAttr(item.level || 'low')}">
<strong>${escapeHtml(label)} · ${escapeHtml(item.verdict || '-')}</strong>
<p>7宫 ${escapeHtml(signName(item.seventhSign || ''))} · 7主 ${escapeHtml(planetName(item.seventhLord || ''))} · ${escapeHtml(item.score ?? 0)} / 5</p>
<ul>
${(item.evidence || []).slice(0, 4).map(text => `<li>${escapeHtml(text)}</li>`).join('')}
</ul>
</div>
`;
}
function renderRelationshipReport(report = {}) {
const evidence = Array.isArray(report.evidence) ? report.evidence : [];
return `
<div class="relationship-report-template relationship-report-${escapeAttr(report.status || 'needs_context')}">
<div class="relationship-report-head">
<div>
<strong>关系报告模板</strong>
<p>${escapeHtml(report.headline || '合盘报告需要结合完整星盘、D9、Kuja 与 Dasha 复核。')}</p>
</div>
<span class="relationship-report-status">${escapeHtml(report.statusLabel || '需谨慎综合判断')}</span>
</div>
<div class="relationship-report-grid">
${evidence.map(item => `
<div class="relationship-evidence-card">
<span>${escapeHtml(item.label || '-')}</span>
<strong>${escapeHtml(item.value || '-')}</strong>
<small>${escapeHtml(item.note || '-')}</small>
</div>
`).join('')}
</div>
${renderLifeEventGraphSummary(report.lifeEventGraph, report.vedastroOverview)}
<div class="relationship-report-sections">
${renderRelationshipReportList('支持证据', report.strengths)}
${renderRelationshipReportList('需要观察', report.risks)}
${renderRelationshipReportList('下一步', report.nextSteps)}
</div>
<div class="relationship-report-boundary">
${(report.boundaries || []).map(item => `<span>${escapeHtml(item)}</span>`).join('')}
</div>
</div>
`;
}
function renderLifeEventGraphSummary(graph, vedastroOverview) {
const nodes = Array.isArray(graph?.event_nodes) ? graph.event_nodes : [];
const overviewNode = nodes.find(node => node?.kind === 'external_overview');
const externalWindow = nodes.find(node => node?.kind === 'external_window');
if (!overviewNode && !externalWindow && vedastroOverview?.status !== 'ok') return '';
const items = [];
if (overviewNode) {
items.push(`VedAstro main-entry overview · ${overviewNode.search_scope || 'single_day_overview'} · ${overviewNode.reference_date || '-'}`);
} else if (vedastroOverview?.status === 'ok') {
items.push(`VedAstro main-entry overview · ${vedastroOverview.search_scope || 'single_day_overview'} · ${vedastroOverview.reference_date || '-'}`);
}
if (externalWindow) {
items.push(`${externalWindow.label || 'external_window'} · ${externalWindow.start || '-'}`);
}
return `
<div class="relationship-boundary relationship-life-graph-summary">
<strong>Life Event Graph 摘要</strong>
${(items.length ? items : ['external_overview']).map(item => `<span>${escapeHtml(item)}</span>`).join('')}
</div>
`;
}
function renderRelationshipReportList(label, items = []) {
const list = Array.isArray(items) && items.length ? items : ['暂无可展示条目。'];
return `
<div class="relationship-report-section">
<strong>${escapeHtml(label)}</strong>
<ul class="relationship-report-list">
${list.map(item => `<li>${escapeHtml(item)}</li>`).join('')}
</ul>
</div>
`;
}
function renderBiWheelComparisonView(comparison) {
if (!comparison || (!comparison.axes?.length && !comparison.rows?.length)) return '';
return `
<div class="biwheel-comparison-view">
<div class="biwheel-comparison-head">
<strong>双人比较视图</strong>
<span>bi-wheel / composite-style</span>
</div>
<div class="biwheel-axis-grid">
${(comparison.axes || []).map(row => `
<div class="biwheel-axis-card biwheel-tone-${escapeAttr(row.relation?.tone || 'neutral')}">
<span>${escapeHtml(row.label || '-')}</span>
<strong>${escapeHtml(row.relation?.label || '-')}</strong>
<p>本人 ${escapeHtml(row.self?.label || '-')} · 对方 ${escapeHtml(row.partner?.label || '-')}</p>
<small>${escapeHtml(row.theme || '-')}</small>
</div>
`).join('')}
</div>
<div class="biwheel-table-wrap">
<table class="biwheel-comparison-table">
<thead><tr><th>行星</th><th>本人</th><th>对方</th><th>互动</th><th>Overlay</th></tr></thead>
<tbody>
${(comparison.rows || []).map(row => `
<tr>
<td>${escapeHtml(row.label || row.planet || '-')}</td>
<td>${escapeHtml(row.self?.label || '-')}</td>
<td>${escapeHtml(row.partner?.label || '-')}</td>
<td>${escapeHtml(row.relation?.label || '-')}</td>
<td>对方落本人${escapeHtml(row.partnerOverlayHouse || '-')}宫 / 本人落对方${escapeHtml(row.selfOverlayHouse || '-')}宫</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
${renderCompositeStyleStrip(comparison.compositeStyle)}
</div>
`;
}
function renderCompositeStyleStrip(items = []) {
if (!Array.isArray(items) || !items.length) return '';
return `
<div class="composite-style-strip">
${items.map(item => `
<div>
<span>${escapeHtml(item.label || '-')} midpoint</span>
<strong>${escapeHtml(signName(item.sign || ''))} ${escapeHtml(formatDegreeInSign(item.degree))}</strong>
<small>${escapeHtml(item.note || '-')}</small>
</div>
`).join('')}
</div>
`;
}
function renderSynastryPartnerSummary(summary = {}) {
return `
<div class="synastry-deep-card">
<strong>对方星盘摘要</strong>
<p>上升:${escapeHtml(summary.ascendant || '-')}</p>
<p>月亮:${escapeHtml(summary.moon || '-')} · ${escapeHtml(summary.nakshatra || '-')}</p>
<p>当前运限:${escapeHtml(summary.dasha || '-')}</p>
</div>
`;
}
function renderSynastryDashaCard(dasha) {
return `
<div class="synastry-deep-card synastry-dasha-${escapeAttr(dasha.level)}">
<strong>${escapeHtml(dasha.label)}</strong>
<p>本人:${escapeHtml(dasha.self.label)}</p>
<p>对方:${escapeHtml(dasha.partner.label)}</p>
<small>${escapeHtml(dasha.note)}</small>
</div>
`;
}
function renderSynastryKujaCard(kuja) {
return `
<div class="synastry-deep-card synastry-kuja-${escapeAttr(kuja.verdict)}">
<strong>${escapeHtml(kuja.label)}</strong>
<p>本人:${escapeHtml(kuja.self.levelLabel)} · Mars ${escapeHtml(kuja.self.marsHouse)}${escapeHtml(signName(kuja.self.marsSign))}</p>
<p>对方:${escapeHtml(kuja.partner.levelLabel)} · Mars ${escapeHtml(kuja.partner.marsHouse)}${escapeHtml(signName(kuja.partner.marsSign))}</p>
<small>${escapeHtml(kuja.note)}</small>
</div>
`;
}
function renderSynastryD9Card(label, report) {
if (!report) {
return `
<div class="synastry-deep-card">
<strong>${escapeHtml(label)}</strong>
<p>D9 信息不足,请确认出生时间精度。</p>
</div>
`;
}
const core = (report.core || []).slice(0, 2).map(item => `<p>${escapeHtml(item.label)}${escapeHtml(item.value)}</p>`).join('');
const risks = (report.risks || []).map(flag => escapeHtml(flag.name)).join('、') || '无明显红旗';
const positives = (report.positives || []).map(flag => escapeHtml(flag.name)).join('、') || '暂无突出绿旗';
return `
<div class="synastry-deep-card synastry-d9-card">
<div class="synastry-d9-head">
<strong>${escapeHtml(label)}</strong>
<span>${escapeHtml(report.zone.name)} · ${escapeHtml(report.score)} / 8</span>
</div>
${core}
<small>绿旗:${positives}</small>
<small>风险:${risks}</small>
</div>
`;
}
function renderPartnerDetails(label, details = {}) {
return `
<div class="synastry-person">
<strong>${escapeHtml(label)}</strong>
<span>${escapeHtml(details.moon_sign || '-')} · ${escapeHtml(details.nakshatra || '-')}${details.nakshatra_pada ? ` P${escapeHtml(details.nakshatra_pada)}` : ''}</span>
<small>Gana ${escapeHtml(details.gana || '-')} · Nadi ${escapeHtml(details.nadi || '-')} · Yoni ${escapeHtml(details.yoni || '-')}</small>
</div>
`;
}
function kutaMax(name) {
return {
Varna: 1,
Vashya: 2,
Tara: 3,
Yoni: 4,
GrahaMaitri: 5,
Gana: 6,
Bhakoot: 7,
Nadi: 8,
}[name] ?? '-';
}
// ============================================================================
// Prashna 问事 Tab 渲染
// ============================================================================
function renderPrashnaTab(chartData) {
const category = $('prashna-category');
const question = $('prashna-question');
const result = $('prashna-result');
const runBtn = $('btn-run-prashna');
const timestamp = $('prashna-timestamp');
const lat = $('prashna-lat');
const lon = $('prashna-lon');
const timezone = $('prashna-timezone');
if (!category || !question || !result || !runBtn) return;
const workflow = chartData?._consultationWorkflow;
renderPrashnaCaseWorkspace();
setupPrashnaCaseActions();
if (workflow?.prashna) {
result.innerHTML = renderPrashnaResult(workflow.prashna, workflow.prashna.question || workflow.question || '');
bindTerms(result);
}
if (!runBtn.dataset.bound) {
runBtn.dataset.bound = 'true';
runBtn.addEventListener('click', async () => {
const questionText = question.value.trim();
const questionType = category.value || 'general';
if (!questionText) {
result.innerHTML = '<p class="prashna-error">请填写一个明确问题。</p>';
return;
}
if (!timestamp?.value || !lat?.value || !lon?.value || !timezone?.value) {
result.innerHTML = '<p class="prashna-error">请填写提问时刻、纬度、经度与 UTC 时区。</p>';
return;
}
if (questionText.length > 120) {
result.innerHTML = '<p class="prashna-error">问题请控制在 120 字以内。</p>';
return;
}
result.innerHTML = '<p>正在铸造 Prashna 问事盘...</p>';
try {
const data = await window.JyotishAPI?.computePrashna?.({
question_text: questionText,
question_timestamp: timestamp.value,
lat: Number(lat.value),
lon: Number(lon.value),
timezone: Number(timezone.value),
ayanamsa: 'lahiri',
node_mode: 'mean',
location_convention: 'wgs84',
});
if (!data) throw new Error('本地 API 未返回结果');
recordPrashnaWorkflow(data, questionText, questionType);
_lastPrashnaCaseRecord = buildPrashnaCaseRecord(data, questionText, questionType);
result.innerHTML = renderPrashnaResult(data, questionText);
renderPrashnaCaseWorkspace();
bindTerms(result);
} catch (e) {
result.innerHTML = renderInlineAPIError('prashna-error', '问事计算暂不可用', e);
}
});
}
}
function ensureClientWorkflows() {
const active = getActiveChartData();
if (!active) return null;
if (!active._client_workflows || typeof active._client_workflows !== 'object') {
active._client_workflows = {};
}
return active._client_workflows;
}
function recordSynastryWorkflow(data, deep = null, options = {}) {
const workflows = ensureClientWorkflows();
if (!workflows) return;
const relationshipReport = deep?.relationshipReport || data.relationship_report || buildRelationshipReportTemplate(data, deep);
workflows.synastry = {
mode: options.mode || 'synastry',
generatedAt: new Date().toISOString(),
total_score: data.total_score ?? 0,
max_score: data.max_score ?? 36,
match_percentage: data.match_percentage ?? ((Number(data.total_score || 0) / Number(data.max_score || 36)) * 100),
is_match_approved: data.is_match_approved ?? data.is_approved ?? false,
scores: data.scores || {},
exceptions: data.exceptions || [],
male_details: data.male_details || data.male || {},
female_details: data.female_details || data.female || {},
relationship_report: relationshipReport,
deep: deep ? normalizeSynastryDeepForExport({ ...deep, relationshipReport }) : null,
};
}
function normalizeSynastryDeepForExport(deep) {
return {
partnerSummary: deep.partnerSummary || {},
relationshipReport: deep.relationshipReport || null,
comparison: deep.comparison ? normalizeReplayComparison(deep.comparison) : null,
spouseStatus: deep.spouseStatus ? normalizeReplaySpouseStatus(deep.spouseStatus) : null,
dasha: deep.dasha ? {
label: deep.dasha.label,
note: deep.dasha.note,
self: deep.dasha.self?.label || '-',
partner: deep.dasha.partner?.label || '-',
} : null,
kuja: deep.kuja ? {
label: deep.kuja.label,
note: deep.kuja.note,
self: deep.kuja.self?.levelLabel || '-',
partner: deep.kuja.partner?.levelLabel || '-',
} : null,
d9: {
self: deep.selfD9 ? { score: deep.selfD9.score, zone: deep.selfD9.zone?.name || '' } : null,
partner: deep.partnerD9 ? { score: deep.partnerD9.score, zone: deep.partnerD9.zone?.name || '' } : null,
},
};
}
function recordPrashnaWorkflow(data, questionText, questionType) {
const workflows = ensureClientWorkflows();
if (!workflows) return;
workflows.prashna = {
generatedAt: new Date().toISOString(),
question_text: questionText || '',
question_type: questionType || 'general',
summary: data.summary || {},
prashna_chart: data.prashna_chart || {},
kp_horary: data.kp_horary || {},
kp_answer_v2: data.kp_answer_v2 || data.kp_answer || {},
kp_answer: data.kp_answer || {},
arudha: data.arudha || {},
nadi: data.nadi || {},
timing: data.timing || {},
sphutas: data.sphutas || {},
life_sphutas: data.life_sphutas || {},
sahams: data.sahams || {},
lost_item: data.lost_item || {},
kunda: data.kunda || {},
};
}
function buildPrashnaCaseRecord(data, questionText, questionType) {
const now = new Date().toISOString();
const summary = data.summary || {};
const answer = data.kp_answer_v2 || data.kp_answer || {};
const chart = data.prashna_chart || {};
const conclusion = summary.conclusion || answer.kp_answer || 'MAYBE — 需要更多信息确认';
const questionLabel = questionText || questionType || 'Prashna';
return {
id: `${buildWorkspaceChartId(getActiveChartData()) || 'chart'}__prashna__${now.replace(/[:.]/g, '-')}`,
label: `${questionLabel.slice(0, 42)} · ${conclusion}`,
group: '研究',
relation: 'research',
tags: ['prashna', questionType || 'general'],
generatedAt: now,
updatedAt: now,
chartId: buildWorkspaceChartId(getActiveChartData()),
chartLabel: buildWorkspaceChartLabel(getActiveChartData()),
question_text: questionText || '',
question_type: questionType || 'general',
conclusion,
confidence: summary.confidence || answer.confidence || '中',
primary_house: answer.primary_house || summary.primary_house || '',
kp_horary: data.kp_horary || {},
horary_number: data.kp_horary?.horary_number || '',
sub_lord: answer.kp_sub_lord || '',
asc: chart.asc_sign ? `${chart.asc_sign} ${chart.asc_degree ?? ''}`.trim() : '',
next_action: summary.next_action || data.timing?.recommendation || '',
data,
};
}
function renderPrashnaCaseActions() {
return `
<div class="prashna-case-actions">
<button type="button" class="btn-secondary" data-action="save-prashna-case">保存问事案例</button>
<button type="button" class="btn-secondary" data-action="export-prashna-case">导出当前问事</button>
</div>
`;
}
function setupPrashnaCaseActions() {
const result = $('prashna-result');
if (result && result.dataset.caseActionsBound !== 'true') {
result.dataset.caseActionsBound = 'true';
result.addEventListener('click', event => {
const btn = event.target.closest('[data-action="save-prashna-case"], [data-action="export-prashna-case"]');
if (!btn) return;
if (btn.dataset.action === 'save-prashna-case') saveCurrentPrashnaCase();
if (btn.dataset.action === 'export-prashna-case') exportCurrentPrashnaCase();
});
}
const workspace = $('prashna-case-workspace');
if (!workspace || workspace.dataset.caseWorkspaceBound === 'true') return;
workspace.dataset.caseWorkspaceBound = 'true';
workspace.addEventListener('click', event => {
const btn = event.target.closest('.mini-action');
if (!btn) return;
if (btn.dataset.action === 'workspace-open-prashna') openSavedPrashnaCase(btn.dataset.caseId || '');
if (btn.dataset.action === 'workspace-delete-prashna') deleteSavedPrashnaCase(btn.dataset.caseId || '');
});
}
function saveCurrentPrashnaCase() {
if (!_lastPrashnaCaseRecord) return;
const now = new Date().toISOString();
const lib = readPrashnaCaseLibrary();
const existing = lib.find(record => record?.id === _lastPrashnaCaseRecord.id);
if (existing) {
Object.assign(existing, _lastPrashnaCaseRecord, { updatedAt: now, generatedAt: existing.generatedAt || now });
} else {
lib.push({ ..._lastPrashnaCaseRecord, generatedAt: now, updatedAt: now });
}
writePrashnaCaseLibrary(sortCaseLibrary(lib).slice(0, 80));
renderPrashnaCaseWorkspace();
}
function exportCurrentPrashnaCase() {
if (!_lastPrashnaCaseRecord) return;
const safeId = String(_lastPrashnaCaseRecord.id || 'prashna').replace(/[^a-z0-9_-]+/gi, '-');
downloadText(JSON.stringify(_lastPrashnaCaseRecord, null, 2), `jyotish-prashna-${safeId}.json`, 'application/json;charset=utf-8');
}
function renderPrashnaCaseWorkspace() {
const container = $('prashna-case-workspace');
if (!container) return;
const lib = sortCaseLibrary(readPrashnaCaseLibrary());
if (!lib.length) {
container.innerHTML = '<div class="prashna-case-empty">暂无保存问事案例。完成一次问事分析后可保存当前结果。</div>';
return;
}
container.innerHTML = `
<div class="prashna-case-head">
<strong>保存问事案例</strong>
<span>${lib.length} 条</span>
</div>
<div class="prashna-case-list">
${lib.slice(0, 4).map(record => `
<div class="prashna-case-row">
<div>
<strong>${escapeHtml(record.question_text || record.question_type || '未命名问事')}</strong>
<span>${escapeHtml(record.conclusion || '-')} · ${escapeHtml(record.confidence || '-')}</span>
<small>${escapeHtml(formatSavedAt(record.updatedAt || record.generatedAt))}</small>
</div>
<div class="case-row-actions">
<button type="button" class="mini-action" data-action="workspace-open-prashna" data-case-id="${escapeAttr(record.id || '')}">打开</button>
<button type="button" class="mini-action danger" data-action="workspace-delete-prashna" data-case-id="${escapeAttr(record.id || '')}">删除</button>
</div>
</div>
`).join('')}
</div>
`;
}
function renderPrashnaResult(data, questionText) {
const chart = data.prashna_chart || {};
const answer = data.kp_answer_v2 || data.kp_answer || {};
const oldAnswer = data.kp_answer || {};
const arudha = data.arudha || {};
const nadi = data.nadi || {};
const timing = data.timing || {};
const summary = data.summary || {};
const answerText = summary.conclusion || answer.kp_answer || oldAnswer.kp_answer || 'MAYBE — 需要更多信息确认';
const tone = answerText.includes('YES') ? 'prashna-yes' : answerText.includes('NO') ? 'prashna-no' : 'prashna-maybe';
const planetRows = Object.entries(chart.planet_houses || {}).map(([planet, house]) => `
<tr><td>${escapeHtml(planet)}</td><td>${escapeHtml(house)}宫</td></tr>
`).join('');
const factors = (timing.factors || []).slice(0, 5).map(item => `<li>${escapeHtml(item)}</li>`).join('');
return `
<div class="prashna-answer ${tone}">
<strong>${escapeHtml(answerText)}</strong>
<span>置信度:${escapeHtml(summary.confidence || answer.confidence || oldAnswer.confidence || '中')}</span>
</div>
${questionText ? `<p class="prashna-question-readout">${escapeHtml(questionText)}</p>` : ''}
<div class="prashna-grid">
<div><span>问事上升</span><strong>${escapeHtml(chart.asc_sign || '-')} ${escapeHtml(chart.asc_degree ?? '-')}°</strong></div>
<div><span>上升主星</span><strong>${escapeHtml(chart.prashna_lagna_lord || '-')}</strong></div>
<div><span>问题宫</span><strong>${escapeHtml(answer.primary_house || oldAnswer.primary_house || summary.primary_house || '-')}宫</strong></div>
<div><span>问题宫主</span><strong>${escapeHtml(answer.question_lord || oldAnswer.question_lord || summary.question_lord || '-')}</strong></div>
<div><span>Sub Lord</span><strong>${escapeHtml(answer.kp_sub_lord || '-')}</strong></div>
<div><span>Karaka</span><strong>${escapeHtml(answer.karaka || oldAnswer.karaka || '-')}</strong></div>
</div>
<div class="prashna-insight-grid">
<div class="prashna-note">
<strong>KP 三层判定</strong>
<p>${escapeHtml(answer.reason || oldAnswer.note || '基于问题宫、宫主与 KP Prashna 信号综合判断。')}</p>
${answer.timing_note ? `<small>${escapeHtml(answer.timing_note)}</small>` : ''}
</div>
<div class="prashna-note">
<strong>Arudha 镜像</strong>
<p>问题宫 ${escapeHtml(arudha.question_house || '-')},宫主 ${escapeHtml(arudha.lord || '-')}${escapeHtml(arudha.lord_house || '-')}宫,镜像点在 ${escapeHtml(arudha.arudha_house || '-')}宫。</p>
<small>${escapeHtml(arudha.note || 'Arudha 用于观察问题背后的真实关注点。')}</small>
</div>
<div class="prashna-note">
<strong>Nadi 侧写</strong>
<p>${escapeHtml(nadi.moon_jupiter_relation || '-')}</p>
<small>${escapeHtml(nadi.nadi_interpretation || '').replace(/\n/g, ' / ')}</small>
</div>
<div class="prashna-note">
<strong>时机评分</strong>
<p>${escapeHtml(timing.score ?? '-')} / 100 · ${escapeHtml(timing.rating || '-')}</p>
${factors ? `<ul>${factors}</ul>` : ''}
</div>
</div>
${planetRows ? `<div class="prashna-table-wrap"><table class="prashna-table"><thead><tr><th>行星</th><th>问事宫位</th></tr></thead><tbody>${planetRows}</tbody></table></div>` : ''}
${renderKPHoraryEvidence(data.kp_horary || {})}
${renderPrashnaAdvancedEvidence(data)}
${renderPrashnaCaseActions()}
<div class="prashna-next-action">${escapeHtml(summary.next_action || timing.recommendation || '把这个结论与现实证据交叉验证后再行动。')}</div>
<p class="prashna-boundary">Prashna 适合回答具体、单一、当下的问题。重大决定仍需结合出生盘、大运、过境和现实信息,不应只凭单次问事结论行动。</p>
`;
}
function renderKPHoraryEvidence(kpHorary = {}) {
const ruling = kpHorary.ruling_planets || {};
const cusp = kpHorary.cuspal_sub_lord || {};
const question = kpHorary.question_houses || {};
const significators = kpHorary.house_significators || {};
const judgement = kpHorary.judgement_matrix || [];
const sigRows = Object.entries(significators).map(([house, sig]) => `
<tr>
<td>H${escapeHtml(house)}</td>
<td>${escapeHtml(formatKPList(sig.A))}</td>
<td>${escapeHtml(formatKPList(sig.B))}</td>
<td>${escapeHtml(formatKPList(sig.C))}</td>
<td>${escapeHtml(formatKPList(sig.D))}</td>
</tr>
`).join('');
const judgementCards = judgement.map(row => `
<div>
<span>H${escapeHtml(row.house)} · ${escapeHtml(row.role || '-')}</span>
<strong>${escapeHtml(row.signal || '-')}</strong>
<small>score ${escapeHtml(row.score ?? '-')}</small>
</div>
`).join('');
if (!kpHorary.method && !sigRows && !judgementCards) return '';
return `
<details class="prashna-advanced kp-horary-evidence" open>
<summary>KP HoraryRuling Planets / Sub Lord / Significators</summary>
<div class="prashna-advanced-grid">
<div class="prashna-advanced-card">
<strong>Ruling Planets</strong>
<p>Asc ${escapeHtml(ruling.ascendant_lord || '-')} · Moon Star ${escapeHtml(ruling.moon_star_lord || '-')}</p>
<small>Moon Sub ${escapeHtml(ruling.moon_sub_lord || '-')} · Horary ${escapeHtml(kpHorary.horary_number || '-')}</small>
</div>
<div class="prashna-advanced-card">
<strong>Cuspal Sub Lord</strong>
<p>H${escapeHtml(cusp.house || question.primary || '-')} · ${escapeHtml(cusp.kp_lords?.sub_lord || '-')}</p>
<small>Star ${escapeHtml(cusp.kp_lords?.nakshatra_lord || '-')} · Sub-Sub ${escapeHtml(cusp.kp_lords?.sub_sub_lord || '-')}</small>
</div>
<div class="prashna-advanced-card">
<strong>Question Houses</strong>
<p>Primary H${escapeHtml(question.primary || '-')} · ${escapeHtml(formatKPList(question.secondary))}</p>
<small>Karaka ${escapeHtml(question.karaka || '-')}</small>
</div>
<div class="prashna-advanced-card">
<strong>Judgement Matrix</strong>
<div class="prashna-mini-grid">${judgementCards || '<p>暂无 judgement_matrix。</p>'}</div>
</div>
</div>
${sigRows ? `<div class="prashna-table-wrap"><table class="prashna-table"><thead><tr><th>宫</th><th>A</th><th>B</th><th>C</th><th>D</th></tr></thead><tbody>${sigRows}</tbody></table></div>` : ''}
<p class="prashna-boundary">${escapeHtml(kpHorary.next_action || '用 KP Horary 证据复核结论,不单独替代出生盘和现实信息。')}</p>
</details>
`;
}
function renderPrashnaAdvancedEvidence(data = {}) {
const sphutas = data.sphutas || {};
const life = data.life_sphutas || {};
const sahams = data.sahams || {};
const lost = data.lost_item || {};
const kunda = data.kunda || {};
const points = ['trisphuta', 'catusphuta', 'pancasphuta']
.map(key => sphutas[key])
.filter(Boolean)
.map(point => `<div><span>${escapeHtml(point.name)}</span><strong>${escapeHtml(point.sign)} · ${escapeHtml(point.house)}宫</strong><small>${escapeHtml(point.longitude)}°</small></div>`)
.join('');
const sahamCards = (sahams.points || []).slice(0, 6)
.map(point => `<div><span>${escapeHtml(point.note || point.name)}</span><strong>${escapeHtml(point.sign)} · ${escapeHtml(point.house)}宫</strong><small>${escapeHtml(point.name)} ${escapeHtml(point.longitude)}°</small></div>`)
.join('');
return `
<details class="prashna-advanced">
<summary>进阶证据:Sphuta / Saham / 失物 / Kunda</summary>
<div class="prashna-advanced-grid">
<div class="prashna-advanced-card">
<strong>Sphuta 组合</strong>
<div class="prashna-mini-grid">${points || '<p>暂无 Sphuta 数据。</p>'}</div>
</div>
<div class="prashna-advanced-card">
<strong>Prana / Deha / Mrityu</strong>
<p>${escapeHtml(life.signal || '-')}</p>
<small>${escapeHtml(life.note || '生命点仅作压力提示。')}</small>
</div>
<div class="prashna-advanced-card">
<strong>失物线索</strong>
<p>${escapeHtml(lost.summary || '-')}</p>
<small>${escapeHtml(lost.likely_direction || '-')}</small>
</div>
<div class="prashna-advanced-card">
<strong>Kunda 验证</strong>
<p>${escapeHtml(kunda.nakshatra || '-')} P${escapeHtml(kunda.pada || '-')} · ${escapeHtml(kunda.strength || '-')}</p>
<small>${escapeHtml(kunda.note || '')}</small>
</div>
</div>
<div class="prashna-saham-strip">${sahamCards || '<p>暂无 Saham 数据。</p>'}</div>
</details>
`;
}
// ============================================================================
// KP Tab 渲染
// ============================================================================
function renderKPTab(chartData) {
const sec = document.getElementById('kp-section');
if (!sec) return;
const ext = chartData._extended;
if (!ext?.kp?.houses) {
sec.innerHTML = '<p style="text-align:center;color:#999;padding:40px">KP分析将在API返回后展示</p>';
return;
}
const focus = window.__kpFocus || 'career';
const domains = kpDomains();
const domain = domains[focus] || domains.career;
const focusRows = domain.houses.map(h => renderKPHouseCard(h, ext.kp.houses[h], true)).join('');
const otherRows = Array.from({ length: 12 }, (_, i) => i + 1)
.filter(h => !domain.houses.includes(h))
.map(h => renderKPHouseCard(h, ext.kp.houses[h], false))
.join('');
const planets = ext.kp.planets || {};
const planetRows = Object.entries(planets).slice(0, 9).map(([pn, pd]) => {
const kl = pd.kp_lords || {};
const sig = pd.significators || {};
return `
<tr>
<td>${escapeHtml(planetName(pn))}</td>
<td>${escapeHtml(kl.nakshatra || '-')}</td>
<td>${escapeHtml(kl.sub_lord || '-')}</td>
<td>${escapeHtml(kl.sub_sub_lord || '-')}</td>
<td>${escapeHtml(formatKPList(sig.A))}</td>
<td>${escapeHtml(formatKPList(sig.B))}</td>
</tr>
`;
}).join('');
sec.innerHTML = `
<div class="kp-dashboard">
<div class="kp-head">
<div>
<h4>KP Sublord 事件判断</h4>
<p>用 ABCD significator 看具体问题是否容易兑现:A/B 权重更高,C/D 作背景补充。</p>
</div>
<select id="kp-focus-select" class="kp-focus-select">
${Object.entries(domains).map(([key, item]) => `<option value="${escapeAttr(key)}" ${key === focus ? 'selected' : ''}>${escapeHtml(item.label)}</option>`).join('')}
</select>
</div>
<div class="kp-verdict">
<strong>${escapeHtml(domain.label)}</strong>
<p>${escapeHtml(domain.note)}</p>
<small>重点宫位:${domain.houses.map(h => `${h}`).join(' / ')}</small>
</div>
<div class="kp-house-grid kp-focus-grid">${focusRows}</div>
<details class="kp-all-houses">
<summary>查看全部 12 宫 KP Significator</summary>
<div class="kp-house-grid">${otherRows}</div>
</details>
<div class="kp-table-wrap">
<table class="kp-table">
<thead><tr><th>行星</th><th>Nakshatra</th><th>Sub</th><th>Sub-Sub</th><th>A</th><th>B</th></tr></thead>
<tbody>${planetRows}</tbody>
</table>
</div>
<p class="kp-boundary">KP 适合回答边界清楚的事件问题。先选问题域,再看对应宫位的强 significator;不要把全盘 KP 表当作泛性格解读。</p>
</div>
`;
const select = document.getElementById('kp-focus-select');
if (select && !select.dataset.bound) {
select.dataset.bound = 'true';
select.addEventListener('change', () => {
window.__kpFocus = select.value;
renderKPTab(chartData);
});
}
}
function renderKPHouseCard(house, data = {}, focused = false) {
const sig = data.significators || {};
const kp = data.kp_lords || {};
return `
<div class="kp-house-card ${focused ? 'kp-house-focus' : ''}">
<div class="kp-house-title">
<strong>${escapeHtml(house)}宫 · ${escapeHtml(signName(data.sign || '-'))}</strong>
<span>${escapeHtml(planetName(kp.sub_lord || ''))}</span>
</div>
<p>A ${escapeHtml(formatKPList(sig.A))}</p>
<p>B ${escapeHtml(formatKPList(sig.B))}</p>
<small>C ${escapeHtml(formatKPList(sig.C))} · D ${escapeHtml(formatKPList(sig.D))}</small>
</div>
`;
}
function formatKPList(value) {
if (Array.isArray(value)) return value.length ? value.map(item => typeof item === 'number' ? `${item}` : planetName(item)).join('、') : '-';
if (value === null || value === undefined || value === '') return '-';
return typeof value === 'number' ? `${value}` : planetName(value);
}
function kpDomains() {
return {
career: { label: '事业/工作', houses: [10, 6, 2, 11], note: '10宫看职位与社会结果,6宫看劳动/竞争,2与11宫看收入和兑现。' },
relationship: { label: '关系/婚姻', houses: [7, 2, 5, 11], note: '7宫看伴侣与契约,2宫看家庭延续,5宫看恋爱,11宫看愿望兑现。' },
finance: { label: '财务/收益', houses: [2, 11, 5, 9], note: '2宫现金与储蓄,11宫收益,5宫投机,9宫机会与贵人。' },
health: { label: '健康/恢复', houses: [1, 6, 8, 12], note: '1宫身体状态,6宫疾病,8宫风险,12宫消耗和住院。' },
property: { label: '房产/家宅', houses: [4, 2, 11, 12], note: '4宫房产家宅,2/11宫付款与收益,12宫支出和迁移成本。' },
travel: { label: '旅行/迁移', houses: [3, 9, 12, 4], note: '3宫短途,9宫远行,12宫海外/离开,4宫原居所。' },
general: { label: '一般问题', houses: [1, 10, 11, 12], note: '一般问题先看问事者、事件结果、愿望兑现与损耗。' },
};
}
// ============================================================================
// 🔍 验证Tab渲染 (v6.9.0)
// ============================================================================
function renderVerifyTab(chartData) {
const pastSec = document.getElementById('past-events-result');
if (pastSec && chartData._extended?.past_events) {
const pe = chartData._extended.past_events;
pastSec.innerHTML = `<p style="color:#666;margin-bottom:8px">${escapeHtml(pe.summary || '基于Dasha/Transit反推')}</p>
${(pe.events || []).map(e => `<div style="background:#e8f5e9;padding:8px 12px;border-radius:6px;margin:4px 0;font-size:13px">
<strong>${escapeHtml(e.period || '')}</strong>: ${escapeHtml(e.description || '')} <span style="color:#888">(${escapeHtml(e.confidence || '')})</span></div>`).join('')}`;
}
const caseSec = document.getElementById('case-match-result');
if (caseSec && chartData._extended?.case_matches) {
const cm = chartData._extended.case_matches;
caseSec.innerHTML = `<p style="color:#666;margin-bottom:8px">匹配到${cm.length || 0}个相似案例</p>
${(cm || []).slice(0,5).map(c => `<div style="background:#e3f2fd;padding:8px 12px;border-radius:6px;margin:4px 0;font-size:13px">
<strong>${escapeHtml(c.name || '')}</strong>: ${escapeHtml(c.match || '')} <span style="color:#666">吻合度:${escapeHtml(c.accuracy || '?')}</span></div>`).join('')}`;
}
const fallSec = document.getElementById('fallacy-warn-result');
if (fallSec) {
let warnings = [];
const interps = chartData._extended?.interpretations || {};
for (const [k, v] of Object.entries(interps)) {
if (typeof v === 'string' && (v.includes('毁灭') || v.includes('落陷') || v.includes('必坏'))) {
warnings.push({text: v.slice(0, 60), fix: '需多配置综合判断'});
}
}
if (warnings.length === 0) warnings.push({text: '未检测到常见误区', fix: ''});
fallSec.innerHTML = warnings.map(w => `<div style="background:#fff3e0;padding:6px 12px;border-radius:6px;margin:4px 0;font-size:12px">
⚠️ ${escapeHtml(w.text)}${w.fix ? ' → '+escapeHtml(w.fix) : ''}</div>`).join('');
}
}
// ============================================================================
// 🪐 过境对比 Tab渲染 (v6.9.0)
// ============================================================================
async function parseTransitCompareResponse(resp) {
const raw = await resp.text();
if (!raw) return {};
try {
return JSON.parse(raw);
} catch (_error) {
return { error: raw.slice(0, 240) };
}
}
function buildTransitCompareRecoveryMessage(error) {
const message = error?.message || String(error || '本地 API 未连接');
return `过境数据需本地 API 服务:${message}。请到 Trust Center 运行健康检查;如本地 API 未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。`;
}
function buildTransitRenderRecoveryMessage(error) {
const message = error?.message || String(error || '本地计算异常');
return `Transit 计算暂不可用:${message}。请到 Trust Center 运行健康检查;如本地 API 或高级计算服务未连接,请按 README 的普通用户启动路径启动网页服务和本地 API 服务后重试。`;
}
function renderTransitCompareTab(chartData) {
const transitBtn = document.getElementById('btn-run-transit');
if (!transitBtn) return;
transitBtn.onclick = async () => {
const start = document.getElementById('transit-start').value;
const end = document.getElementById('transit-end').value;
if (!start || !end) return;
const rd = document.getElementById('transit-result');
rd.innerHTML = '<p style="text-align:center;color:#999;padding:20px">搜索过境触发点...</p>';
try {
const base = window.JyotishAPI?.apiBase || '';
const resp = await fetch(`${base}/api/transit`, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({start, end, planets: Object.keys(chartData.planets || {}).slice(0, 9)})
});
const data = await parseTransitCompareResponse(resp);
if (!resp.ok || data.success === false) {
throw new Error(data.error || data.message || `Transit API HTTP ${resp.status}`);
}
if (data.triggers?.length) {
rd.innerHTML = `<p style="color:#666">发现${data.triggers.length}个触发点</p>
${data.triggers.slice(0, 20).map(t => `<div style="background:#f9f9f9;padding:6px 12px;border-radius:6px;margin:3px 0;font-size:12px">
<strong>${escapeHtml(t.planet || '')}</strong> → ${escapeHtml(t.event || t.description || '')} · ${escapeHtml(t.start_date || t.date || '')}</div>`).join('')}`;
} else {
rd.innerHTML = '<p style="text-align:center;color:#999;padding:20px">无显著过境触发点</p>';
}
} catch(e) {
rd.innerHTML = `<p style="text-align:center;color:#7a4b00;padding:20px">${escapeHtml(buildTransitCompareRecoveryMessage(e))}</p>`;
}
};
}
// ============================================================================
// AI 解读触发 (v6.9.4)
// ============================================================================
async function triggerAIReading(chartData) {
if (!window.JyotishAPI?.aiFullReading) return;
const summaryEl = document.getElementById('chart-summary');
if (summaryEl) summaryEl.innerHTML += '<p style="color:#1565c0;font-size:13px">🤖 AI 正在生成个性化解读...</p>';
try {
const result = await window.JyotishAPI.aiFullReading(chartData);
if (result.success) {
if (summaryEl) {
summaryEl.innerHTML = '<div style="background:#e3f2fd;padding:14px;border-radius:10px;margin-top:10px;font-size:14px;line-height:1.7">'
+ escapeHtml(result.reading).replace(/\n/g, '<br>') + '</div>';
}
console.log('[AI] ✅ 解读生成成功');
}
} catch(e) {
console.warn('[AI] 解读失败:', e.message);
if (summaryEl) summaryEl.innerHTML += '<p style="color:#999;font-size:12px">AI解读不可用,请查看结构化数据</p>';
}
}
// ============================================================================
// 星盘中心摘要构建
// ============================================================================
function buildChartSummary(ascendant, moonP, dashaData, karaka, allYogas) {
// 当前 Dasha 信息
let dasha = null;
if (dashaData?.current_dasha) {
const md = dashaData.current_dasha;
const ad = md.antardasha?.find(a => a.is_current);
dasha = {
maha: md.lord_cn || md.lord,
antar: ad ? (ad.lord_cn || ad.lord) : '',
period: ad ? `${ad.start}${ad.end}` : `${md.start || ''}${md.end || ''}`,
};
}
// AK / DK
let karakaSummary = null;
if (karaka) {
const k7 = karaka.karaka7 || {};
karakaSummary = {
AK: k7.AK?.planet_cn || k7.AK?.planet || null,
DK: k7.DK?.planet_cn || k7.DK?.planet || null,
};
}
// 关键 Yogas(按强度排序取前4)
const strengthOrder = { '极强': 5, '强': 4, '中强': 3, '中': 2, '中负': 1, '负面': 0 };
const topYogas = allYogas
.filter(y => !y.negative)
.sort((a, b) => (strengthOrder[b.strength] || 2) - (strengthOrder[a.strength] || 2))
.slice(0, 4)
.map(y => ({ name_cn: y.name_cn, negative: y.negative }));
return {
moonNakshatra: formatMoonNakshatra(moonP) || null,
dasha,
karaka: karakaSummary,
topYogas,
};
}
// ============================================================================
// 宫位分析
// ============================================================================
function renderHouseAnalysis(planets, ascSign) {
const container = $('house-grid');
container.innerHTML = '';
const houses = computeHouseAnalysis(planets, ascSign);
for (const h of houses) {
const meaning = HOUSE_MEANINGS[h.house];
const card = document.createElement('div');
card.className = 'house-card';
const occHTML = h.occupants.map(o => {
const reading = PLANET_IN_HOUSE[o.name]?.[h.house] || '';
const detail = PLANET_HOUSE_DETAIL[o.name]?.[h.house];
return `<div class="occ-item" data-planet="${o.name}" data-house="${h.house}">
<span class="occupant-badge">${PLANET_SYMBOLS[o.name]||''} ${planetName(o.name)}</span>
${detail ? '<span class="occ-expand-hint">▼</span>' : ''}
${detail ? `<div class="occ-detail" style="display:none;">
<div class="occ-line"><span class="occ-tag occ-pos">${t('detail.pos')}</span><span class="occ-text">${detail.p}</span></div>
<div class="occ-line"><span class="occ-tag occ-neg">${t('detail.neg')}</span><span class="occ-text">${detail.n}</span></div>
<div class="occ-line"><span class="occ-tag occ-adv">${t('detail.adv')}</span><span class="occ-text">${detail.a}</span></div>
<div class="occ-line"><span class="occ-tag occ-special">${t('detail.special')}</span><span class="occ-text">${detail.s}</span></div>
</div>` : ''}
</div>`;
}).join('');
let lordReading = '';
if (h.lordHouse && meaning?.lordInHouse) lordReading = meaning.lordInHouse[h.lordHouse] || '';
card.innerHTML = `
<div class="house-card-header"><span class="house-card-num">${houseLabel(h.house)}</span><span class="house-card-sign">${signName(h.sign)} ${h.sign}</span></div>
<div class="house-card-lord">${t('house.lord')}: ${planetName(h.lord)}${houseLabel(h.lordHouse||'?')}</div>
${occHTML ? `<div class="house-card-occupants">${occHTML}</div>` : ''}
<div class="house-card-reading">${meaning ? `${meaning.name}${meaning.themes}` : ''}${lordReading ? `<br/>${t('house.inHouse')}: ${lordReading}` : ''}</div>
`;
// Bind expand/collapse for occupant details
card.querySelectorAll('.occ-item').forEach(item => {
const badge = item.querySelector('.occupant-badge');
const detail = item.querySelector('.occ-detail');
const hint = item.querySelector('.occ-expand-hint');
if (badge && detail) {
badge.style.cursor = 'pointer';
badge.addEventListener('click', () => {
const open = detail.style.display !== 'none';
detail.style.display = open ? 'none' : 'block';
if (hint) hint.textContent = open ? '▼' : '▲';
});
}
});
container.appendChild(card);
}
}
// ============================================================================
// 相位
// ============================================================================
function renderAspects(planets) {
const container = $('aspects-grid');
container.innerHTML = '';
const aspects = computeAspects(planets);
if (aspects.length === 0) { container.innerHTML = `<p style="color:var(--text-muted);">${t('aspect.none')}</p>`; return; }
for (const a of aspects) {
const friendlyClass = a.friendly === 'friendly' ? 'aspect-friendly' : a.friendly === 'hostile' ? 'aspect-hostile' : '';
const row = document.createElement('div');
row.className = `aspect-row ${friendlyClass}`;
const aspType = a.type === 'opposition' ? t('aspect.opposition') : a.type === 'trine' ? t('aspect.trine') : t('aspect.special');
row.innerHTML = `
<span class="aspect-from">${PLANET_SYMBOLS[a.from]||''} ${planetName(a.from)}</span>
<span class="aspect-arrow">→ ${houseLabel(a.toHouse)} →</span>
<span class="aspect-to">${PLANET_SYMBOLS[a.to]||''} ${planetName(a.to)}</span>
<span class="aspect-type ${a.type}">${aspType}</span>
`;
container.appendChild(row);
}
}
// ============================================================================
// Yoga(扩展版 — 接受预计算结果)
// ============================================================================
function renderYogasWith(allYogas) {
const container = $('yoga-cards'), summary = $('yoga-summary');
container.innerHTML = ''; summary.innerHTML = '';
const positive = allYogas.filter(y => !y.negative).length;
const negative = allYogas.filter(y => y.negative).length;
summary.innerHTML = `<div class="yoga-stat"><strong>${allYogas.length}</strong> ${t('yoga.count')}</div><div class="yoga-stat"><strong>${positive}</strong> ${t('yoga.bene')}</div>${negative > 0 ? `<div class="yoga-stat"><strong>${negative}</strong> ${t('yoga.male')}</div>` : ''}`;
if (allYogas.length === 0) { container.innerHTML = `<p style="color:var(--text-muted);">${t('yoga.none')}</p>`; return; }
for (const y of allYogas) {
const card = document.createElement('div');
card.className = 'yoga-card';
if (y.negative) card.style.borderLeft = '3px solid #f87171';
const detail = YOGA_DETAILS[y.id];
let detailHTML = '';
if (detail) {
const eff = detail.e || {};
detailHTML = `<div class="yoga-detail" style="display:none;">
<div class="yd-section"><span class="yd-label">${t('yoga.formation')}</span><span class="yd-text">${detail.f}</span></div>
${eff.c ? `<div class="yd-section"><span class="yd-label">${t('detail.career')}</span><span class="yd-text">${eff.c}</span></div>` : ''}
${eff.w ? `<div class="yd-section"><span class="yd-label">${t('detail.wealth')}</span><span class="yd-text">${eff.w}</span></div>` : ''}
${eff.m ? `<div class="yd-section"><span class="yd-label">${t('detail.marriage')}</span><span class="yd-text">${eff.m}</span></div>` : ''}
${eff.h ? `<div class="yd-section"><span class="yd-label">${t('detail.health')}</span><span class="yd-text">${eff.h}</span></div>` : ''}
${detail.x ? `<div class="yd-section"><span class="yd-label">${t('yoga.cancel')}</span><span class="yd-text">${detail.x}</span></div>` : ''}
${detail.t?.length ? `<div class="yd-tags">${detail.t.map(tag=>`<span class="yd-tag">${tag}</span>`).join('')}</div>` : ''}
</div>`;
}
card.innerHTML = `
<div class="yoga-header" style="cursor:pointer;">
<div class="yoga-name">${y.name}</div><div class="yoga-name-cn">${y.name_cn}${y.category ? ` <small style="color:var(--text-muted);font-size:10px;">[${y.category}]</small>` : ''}</div>
<div class="yoga-combo">${y.combination}</div>
<div style="font-size:11px;color:var(--text-secondary);margin:4px 0;">${y.effects||''}</div>
<span class="yoga-strength">${y.strength}</span>
${detail ? `<span class="yoga-expand-hint">${t('yoga.expand')}</span>` : ''}
</div>
${detailHTML}
`;
if (detail) {
const header = card.querySelector('.yoga-header');
const detailEl = card.querySelector('.yoga-detail');
const hint = card.querySelector('.yoga-expand-hint');
header.addEventListener('click', () => {
const open = detailEl.style.display !== 'none';
detailEl.style.display = open ? 'none' : 'block';
if (hint) hint.textContent = open ? t('yoga.expand') : t('yoga.collapse');
});
}
container.appendChild(card);
}
}
// ============================================================================
// Yoga(原版 — 兼容保留)
// ============================================================================
function renderYogas(planets, ascSign) {
const container = $('yoga-cards'), summary = $('yoga-summary');
container.innerHTML = ''; summary.innerHTML = '';
const baseYogas = detectYogas(planets, ascSign);
let allYogas = [...baseYogas.yogas];
for (const yd of ALL_YOGA_DEFS) {
try {
const combo = yd.check(planets, ascSign);
if (combo) allYogas.push({ name: yd.name, name_cn: yd.name_cn, combination: combo, effects: yd.effects, strength: yd.strength, negative: yd.negative || false });
} catch (e) {}
}
const positive = allYogas.filter(y => !y.negative).length;
const negative = allYogas.filter(y => y.negative).length;
summary.innerHTML = `<div class="yoga-stat"><strong>${allYogas.length}</strong> ${t('yoga.count')}</div><div class="yoga-stat"><strong>${positive}</strong> ${t('yoga.bene')}</div>${negative > 0 ? `<div class="yoga-stat"><strong>${negative}</strong> ${t('yoga.male')}</div>` : ''}`;
if (allYogas.length === 0) { container.innerHTML = `<p style="color:var(--text-muted);">${t('yoga.none')}</p>`; return; }
for (const y of allYogas) {
const card = document.createElement('div');
card.className = 'yoga-card';
if (y.negative) card.style.borderLeft = '3px solid #f87171';
card.innerHTML = `
<div class="yoga-name">${y.name}</div><div class="yoga-name-cn">${y.name_cn}${y.category ? ` <small style="color:var(--text-muted);font-size:10px;">[${y.category}]</small>` : ''}</div>
<div class="yoga-combo">${y.combination}</div>
<div style="font-size:11px;color:var(--text-secondary);margin:4px 0;">${y.effects||''}</div>
<span class="yoga-strength">${y.strength}</span>
`;
container.appendChild(card);
}
}
// ============================================================================
// Transit 推运(v2.0 增强)
// ============================================================================
let _lastTransitData = null;
async function renderTransit(natalPlanets, ascSign, avData, ascDegree = 0) {
$('transit-date').textContent = t('transit.computing');
try {
// 读取用户选择的日期(如果有)
const dateInput = $('transit-date-input');
const timeInput = $('transit-time-input');
const dateStr = dateInput?.value || null;
const timeStr = timeInput?.value || null;
const tz = chartData?.birth_info?.tz || 0;
const transit = await computeTransit(dateStr, timeStr, tz);
_lastTransitData = { transit, natalPlanets, ascSign };
$('transit-date').textContent = `${transit.date} ${transit.time} UT · Ayanamsa: ${transit.ayanamsa}°`;
// Transit 星盘渲染
const tChartData = { ascendant: { sign: ascSign }, planets: {} };
for (const [pn, pi] of Object.entries(transit.planets)) {
tChartData.planets[pn] = { ...pi, status: '中性', house: 1 };
}
renderSouthIndianChart($('transit-chart'), tChartData);
// 全行星 Transit 表格
const overlay = computeTransitOverlay(natalPlanets, ascSign, transit.planets);
const tableContainer = $('transit-all-table');
if (tableContainer) {
let html = `<table class="planets-table transit-planets-table">
<thead><tr><th>行星</th><th>Transit星座</th><th>度数</th><th>本命宫位</th><th>逆行</th><th>星宿</th><th>本命星座</th><th>换座</th></tr></thead><tbody>`;
for (const p of overlay.all) {
const changeClass = p.sign_changed ? 'style="color:var(--red);font-weight:600"' : '';
html += `<tr>
<td><span class="planet-symbol">${p.symbol}</span> ${planetName(p.planet)}</td>
<td>${signName(p.transit_sign)}</td>
<td>${p.transit_degree_in_sign.toFixed(2)}°</td>
<td><span class="transit-house-badge">H${p.transit_house}</span></td>
<td>${p.retrograde ? '<span style="color:var(--red)">℞</span>' : ''}</td>
<td>${p.transit_nakshatra} P${p.transit_nakshatra_pada}</td>
<td>${p.natal_sign ? signName(p.natal_sign) : '-'}</td>
<td ${changeClass}>${p.sign_changed ? '⚡' + t('col.change') : ''}</td>
</tr>`;
}
html += '</tbody></table>';
tableContainer.innerHTML = html;
}
// 慢星详细分析
const slowContainer = $('transit-overlay');
slowContainer.innerHTML = '';
for (const item of overlay.slow) {
const effect = TRANSIT_EFFECTS[item.planet]?.[item.transit_house] || '';
const cjStr = item.conjunctions.length > 0
? `<div class="transit-conj">${t('transit.conj')}: ${item.conjunctions.map(c => `${planetName(c.planet)}(${c.degree_diff}°)`).join(', ')}</div>` : '';
const aspStr = item.aspects.length > 0
? `<div class="transit-asp">${t('transit.asp')}: ${item.aspects.map(a => `${planetName(a.to)}[H${a.to_house}](${a.type})`).join(', ')}</div>` : '';
const div = document.createElement('div');
div.className = 'transit-overlay-item';
div.innerHTML = `<span class="planet-symbol">${item.symbol}</span>
<div class="transit-planet-info">
<div class="transit-planet-name">${planetName(item.planet)} ${item.retrograde ? '℞' : ''}${houseLabel(item.transit_house)}</div>
<div class="transit-planet-detail">${signName(item.transit_sign)} ${item.transit_degree_in_sign.toFixed(2)}° ${item.retrograde ? t('ss.retro') : ''}</div>
${effect ? `<div class="transit-effect">${effect}</div>` : ''}
${cjStr}${aspStr}
</div>`;
slowContainer.appendChild(div);
}
// Double Transit
const doubleTransits = computeDoubleTransit(transit.planets, ascSign);
const dtSection = $('double-transit-section');
dtSection.innerHTML = '';
if (doubleTransits.length > 0) {
dtSection.innerHTML = `<h4 class="sub-title">${t('dt.title')}</h4>`;
for (const dt of doubleTransits) {
const detail = dt.aspectedBy.map(a => `${planetName(a.planet)}(H${a.from_house}→H${a.offset})`).join(' + ');
dtSection.innerHTML += `<div class="dt-card"><div class="dt-house">${dt.desc}</div><div class="dt-detail">${detail}</div></div>`;
}
}
// Sade Sati(增强版)
const ss = checkSadeSati(natalPlanets.Moon?.sign || '', transit.planets);
const ssSection = $('sade-sati-section');
ssSection.innerHTML = '';
if (ss) {
ssSection.innerHTML = `<h4 class="sub-title">${t('ss.detect')}</h4>
<div class="sade-sati-badge ${ss.active ? 'active' : 'inactive'}">${ss.active ? `[!] ${ss.desc}` : (ss.phase === 0 ? `[~] ${ss.desc}` : `[OK] ${t('ss.active')}`)}</div>
${ss.detail ? `<div class="sade-sati-detail">${ss.detail}</div>` : ''}
<div class="sade-sati-info">${t('ss.moon')}: ${signName(ss.natal_moon_sign)} · ${t('ss.saturn')}: ${signName(ss.saturn_sign)} ${ss.saturn_degree_in_sign.toFixed(2)}° ${ss.saturn_retrograde ? t('ss.retro') : ''}</div>`;
}
// ── 新功能: Double Transit PAC + D9 ──
const dtpac = computeDoubleTransitPAC(transit.planets, natalPlanets, ascSign, ascDegree, 7);
const dtpacSection = $('double-transit-pac-section');
if (dtpacSection) {
dtpacSection.innerHTML = '';
const summaryIcon = dtpac.summary.startsWith('✅') ? '✅' : dtpac.summary.startsWith('⚠️') ? '⚠️' : '❌';
let html = `<h4 class="sub-title">KN Rao Double Transit PAC + D9</h4>`;
html += `<div class="dt-pac-summary">${summaryIcon} ${dtpac.summary.replace(/[✅⚠️❌]/g, '')}</div>`;
// D1 层
const d1j = Object.keys(dtpac.d1.jupiter);
const d1s = Object.keys(dtpac.d1.saturn);
if (d1j.length || d1s.length) {
html += `<div class="dt-pac-layer"><b>D1层</b> | Jupiter→[${d1j.join(', ')}] Saturn→[${d1s.join(', ')}]</div>`;
}
// D9 层
const d9j = Object.keys(dtpac.d9.jupiter);
const d9s = Object.keys(dtpac.d9.saturn);
if (d9j.length || d9s.length) {
html += `<div class="dt-pac-layer"><b>D9层</b> | Jupiter→[${d9j.join(', ')}] Saturn→[${d9s.join(', ')}]</div>`;
}
// Double Transit 命中
if (dtpac.double_transit.length > 0) {
html += '<div class="dt-pac-hits">';
for (const dt of dtpac.double_transit) {
html += `<div class="dt-pac-hit">[${dt.layer}] ${dt.target} (${dt.strength})</div>`;
}
html += '</div>';
}
dtpacSection.innerHTML = html;
}
// ── 新功能: Transit LL/7L ──
const ll7l = computeTransitLL7L(transit.planets, natalPlanets, ascSign);
const ll7lSection = $('transit-ll7l-section');
if (ll7lSection) {
ll7lSection.innerHTML = '';
let html = `<h4 class="sub-title">Transit LL/7L 连接</h4>`;
html += `<div class="ll7l-info">LL: ${planetName(ll7l.lagna_lord)} | 7L: ${planetName(ll7l.seventh_lord)}</div>`;
if (ll7l.p5.hit) {
html += `<div class="ll7l-badge badge-hit">P5(98%): ${ll7l.p5.details.map(d => `${d.direction}${d.connections.map(c => c.type).join('+')}`).join(' | ')}</div>`;
} else {
html += `<div class="ll7l-badge badge-miss">P5(98%): 未触发</div>`;
}
if (ll7l.p8.hit) {
html += `<div class="ll7l-badge badge-hit">P8: ${ll7l.p8.details.join(' | ')}</div>`;
}
if (ll7l.parivartana.hit) {
html += `<div class="ll7l-badge badge-hit">互换: ${ll7l.parivartana.details.join(' | ')}</div>`;
}
ll7lSection.innerHTML = html;
}
// ── 新功能: 行星聚集 ──
const cong = computePlanetaryCongregation(natalPlanets, ascSign, transit.planets, 7);
const congSection = $('planetary-congregation-section');
if (congSection) {
congSection.innerHTML = '';
let html = `<h4 class="sub-title">行星聚集检测</h4>`;
html += `<div class="cong-natal">本命 Lagna: [${cong.natal.lagna.map(planetName).join(', ')}] (${cong.natal.lagna.length}) | 7H: [${cong.natal.house_7.map(planetName).join(', ')}] (${cong.natal.house_7.length})</div>`;
if (cong.transit) {
const t7 = cong.transit[7] || [];
html += `<div class="cong-transit">Transit 7H: [${t7.map(planetName).join(', ')}] (${t7.length})</div>`;
}
html += `<div class="cong-summary">${cong.summary}</div>`;
congSection.innerHTML = html;
}
// ── 新功能: Vivah Saham ──
const vs = computeVivahSaham(natalPlanets, ascDegree, transit.planets);
const vsSection = $('vivah-saham-section');
if (vsSection) {
vsSection.innerHTML = '';
let html = `<h4 class="sub-title">Vivah Saham 婚姻敏感点</h4>`;
html += `<div class="vs-position">${vs.vivah_saham.sign_cn} ${vs.vivah_saham.degree_in_sign.toFixed(2)}°`;
if (vs.vivah_saham.nakshatra) html += ` | ${vs.vivah_saham.nakshatra} P${vs.vivah_saham.pada}`;
html += '</div>';
if (vs.transit_activation) {
const ta = vs.transit_activation;
html += `<div class="vs-activation">Jupiter PAC: [${ta.jupiter.map(c => c.type).join(', ') || '-'}] | Saturn PAC: [${ta.saturn.map(c => c.type).join(', ') || '-'}]</div>`;
html += `<div class="vs-double ${ta.double_activation ? 'badge-hit' : 'badge-miss'}">双星激活: ${ta.double_activation ? '✅ 是' : '❌ 否'}</div>`;
if (ta.venus_in_saham_sign) html += `<div class="vs-venus">Venus 在 Saham 星座 ✅</div>`;
}
vsSection.innerHTML = html;
}
// Ashtakavarga Transit 评分
if (avData) {
const avScores = computeTransitAVScore(transit.planets, ascSign, avData);
const avSection = $('transit-av-section');
if (avSection && avScores) {
let avHTML = `<h4 class="sub-title">${t('tav.title')}</h4><div class="transit-av-grid">`;
for (const s of avScores) {
const qClass = s.transit_quality === 'favorable' ? 'av-fav' : s.transit_quality === 'challenging' ? 'av-chal' : 'av-neu';
avHTML += `<div class="transit-av-item ${qClass}">
<span class="av-planet">${planetName(s.planet)}</span>
<span class="av-sign">${signName(s.transit_sign)}</span>
<span class="av-score">SAV: ${s.sav_score ?? '-'}</span>
<span class="av-quality">${s.transit_quality === 'favorable' ? t('quality.fav') : s.transit_quality === 'challenging' ? t('quality.chal') : t('quality.neu')}</span>
</div>`;
}
avHTML += '</div>';
avSection.innerHTML = avHTML;
}
}
// 宫位影响摘要
const houseImpact = computeTransitHouseImpact(transit.planets, ascSign);
const hiSection = $('transit-house-impact');
if (hiSection) {
let hiHTML = `<h4 class="sub-title">${t('thi.title')}</h4><div class="house-impact-grid">`;
for (let h = 1; h <= 12; h++) {
const hi = houseImpact[h];
const total = hi.transiting.length + hi.aspected_by.length;
const intensity = total >= 3 ? 'intense' : total >= 2 ? 'moderate' : 'light';
hiHTML += `<div class="hi-card hi-${intensity}">
<div class="hi-house">H${h} <small>${houseAreaName(h)}</small></div>
<div class="hi-planets">${hi.transiting.map(t => `<span class="hi-transit">${planetName(t.planet)}${t.retrograde?'℞':''}</span>`).join('')}</div>
<div class="hi-aspects">${hi.aspected_by.map(a => `<span class="hi-asp">${planetName(a.planet)}(${a.aspect_type})</span>`).join('')}</div>
</div>`;
}
hiHTML += '</div>';
hiSection.innerHTML = hiHTML;
}
} catch (err) {
console.error('[Transit] Error:', err);
$('transit-date').textContent = buildTransitRenderRecoveryMessage(err);
}
}
// ============================================================================
// 页面切换 + 初始化
// ============================================================================
function showPage(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
if (name === 'input') $('page-input').classList.add('active');
else if (name === 'chart') {
$('page-chart').classList.add('active');
// 延迟触发 Tab 滚动提示检查(需等 DOM 渲染完毕)
setTimeout(() => { if (window.__checkTabScrollHint) window.__checkTabScrollHint(); }, 100);
}
}
document.addEventListener('DOMContentLoaded', () => {
initPWAInstallability();
// 初始化 i18n
initI18N();
// 语言变化时重新渲染
onLangChange(() => {
if (chartData) renderAll();
});
initDateSelects();
initChartImport();
setupCitySearch();
setupForm();
setupFirstUsePanel();
setupSavedChartPanel();
$('btn-back').addEventListener('click', () => showPage('input'));
// 初始化术语 Tooltip
initTooltip();
setGlossaryTerminologyMode(readTerminologyMode());
// 初始化认证系统
initAuth();
// 初始化订阅系统
initSubscription();
// 初始化 AI 聊天
initAIChat();
document.addEventListener('jyotish:apply-rectified-birth', event => {
applyRectifiedBirth(event.detail?.birth);
});
// Transit 更新按钮
const transitBtn = $('btn-transit-update');
if (transitBtn) {
transitBtn.addEventListener('click', async () => {
if (!chartData) return;
const { planets, ascendant, birth_info } = chartData;
const allV = computeAllVargas(planets);
const av = computeAshtakavarga(planets, ascendant.sign);
await renderTransit(planets, ascendant.sign, av, ascendant.degree);
});
}
// 导出功能
setupExport();
// 星盘风格切换
setupChartStyle();
});
// ============================================================================
// 导出辅助函数
// ============================================================================
function _getBirthNakIdx(planets) {
const moonLon = planets.Moon?.lon ?? planets.Moon?.longitude ?? planets.Moon?.degree ?? 0;
return Math.floor(moonLon / (360 / 27)) % 27;
}
async function loadExportModule() {
return import('./export.js');
}
function buildExportExtras(sourceChart) {
const { planets, ascendant, birth_info } = sourceChart;
const vargas = computeAllVargas(planets);
const karaka = computeKaraka(planets);
const av = computeAshtakavarga(planets, ascendant.sign);
const sb = computeShadbala(planets, ascendant.sign, birth_info, vargas);
const moonLon = planets.Moon?.lon ?? planets.Moon?.longitude ?? planets.Moon?.degree ?? 0;
const dasha = computeDashaWithPratyantar(moonLon, birth_info.date, new Date().toISOString().split('T')[0]);
const baseYogas = detectYogas(planets, ascendant.sign);
const yogas = [...(baseYogas?.yogas || [])];
try { yogas.push(...detectExtendedYogas(planets, ascendant.sign)); } catch(e) {}
const aspects = _buildAspectsModule(planets);
const panchanga = computeTithiYoga(planets, birth_info);
const nakshatraAdvanced = computeNakshatraAdvanced(planets, _getBirthNakIdx(planets));
const charaDasha = computeCharaDasha(planets, ascendant.sign);
const karakamsha = computeKarakamsha(planets);
const ascIdx = SIGNS.indexOf(ascendant.sign);
const planetSignIdx = buildPlanetSignIndex(planets);
const argala = computeArgala(planetSignIdx, ascIdx);
const birthYear = parseInt((birth_info.date || '').split('-')[0]) || 1990;
const birthMonth = parseInt((birth_info.date || '').split('-')[1]) || 1;
const tajika = computeTajika(planets, ascendant.sign, birthYear, birthMonth);
const validation = computeValidation(planets, ascendant, av, dasha);
const audit = computeAudit(planets, ascendant, av, validation);
const actionableContext = computeActionableContext(planets, ascendant);
const jaimini = _buildJaiminiModule(karaka, charaDasha, karakamsha);
const relationshipNarrative = sourceChart?.ai_prompt_pack?.evidence_snapshot?.relationship_narrative
|| sourceChart?.relationship_narrative
|| sourceChart?.modules?.relationship_strict_evidence?.user_narrative
|| null;
const careerNarrative = sourceChart?.ai_prompt_pack?.evidence_snapshot?.career_narrative
|| sourceChart?.career_narrative
|| sourceChart?.modules?.career_strict_evidence?.user_narrative
|| null;
const financeNarrative = sourceChart?.ai_prompt_pack?.evidence_snapshot?.finance_narrative
|| sourceChart?.finance_narrative
|| sourceChart?.modules?.finance_strict_evidence?.user_narrative
|| null;
const vimsopakaSemanticSummary = sourceChart?.ai_prompt_pack?.evidence_snapshot?.vimsopaka_semantic_summary
|| sourceChart?.vimsopaka_semantic_summary
|| null;
const functionalBeneficMalefic = sourceChart?.ai_prompt_pack?.evidence_snapshot?.functional_benefic_malefic
|| sourceChart?.functional_benefic_malefic
|| null;
const vedastroOverview = sourceChart?.ai_prompt_pack?.evidence_snapshot?.vedastro_overview
|| sourceChart?.vedastro_overview
|| null;
const techniqueAuditTable = sourceChart?.ai_prompt_pack?.evidence_snapshot?.technique_audit_table
|| sourceChart?.technique_audit_table
|| null;
return {
dasha, yogas, vargas, aspects, jaimini,
nakshatraAdvanced, argala, tajika,
shadbala: sb, ashtakavarga: _buildAVModule(av),
panchanga, validation, audit, actionableContext,
relationship_narrative: relationshipNarrative,
career_narrative: careerNarrative,
finance_narrative: financeNarrative,
vimsopaka_semantic_summary: vimsopakaSemanticSummary,
functional_benefic_malefic: functionalBeneficMalefic,
vedastro_overview: vedastroOverview,
technique_audit_table: techniqueAuditTable,
provenance: sourceChart._client_audit?.provenance,
workflows: buildWorkflowExportExtras(sourceChart),
};
}
function buildWorkflowExportExtras(sourceChart) {
const workflows = { ...(sourceChart?._client_workflows || {}) };
if (sourceChart?._extended?.kp) {
workflows.kp = buildKPReportSummary(sourceChart._extended.kp, window.__kpFocus || 'career');
}
workflows.case_library = {
synastry_pairs: sortCaseLibrary(readSynastryPairLibrary()).slice(0, 12),
prashna_cases: sortCaseLibrary(readPrashnaCaseLibrary()).slice(0, 12),
};
return workflows;
}
function buildKPReportSummary(kp, focus = 'career') {
const domains = kpDomains();
const domain = domains[focus] || domains.career;
const houses = {};
domain.houses.forEach(house => {
houses[house] = kp?.houses?.[house] || {};
});
return {
focus,
label: domain.label,
note: domain.note,
focus_houses: domain.houses,
houses,
planets: kp?.planets || {},
};
}
function _buildAspectsModule(planets) {
const rawAspects = computeAspects(planets);
const aspects = (rawAspects || []).map(a => ({
planet1: a.from, planet2: a.to,
type: a.offset === 7 ? 'opposition' : (a.offset === 5 || a.offset === 9) ? 'trine' : `aspect_${a.offset}`,
aspect_degree: a.offset * 30,
actual_diff: Math.abs((planets[a.from]?.degree || 0) - (planets[a.to]?.degree || 0)),
orb: 0, orb_category: 'standard',
applying: false, strength: 0,
description: `${a.from}${a.to}(${a.offset}宫相位)`,
friendly: a.friendly,
}));
// Conjunctions
const names = Object.keys(planets).filter(p => !planets[p].error);
const conjunctions = [];
for (let i = 0; i < names.length; i++) {
for (let j = i + 1; j < names.length; j++) {
const d1 = planets[names[i]].degree, d2 = planets[names[j]].degree;
const diff = Math.min(Math.abs(d1 - d2), 360 - Math.abs(d1 - d2));
if (diff < 10) conjunctions.push({ planet1: names[i], planet2: names[j], orb: Math.round(diff * 100) / 100 });
}
}
return { aspects, conjunctions, summary: `${aspects.length} aspects, ${conjunctions.length} conjunctions` };
}
function _buildAVModule(av) {
if (!av) return null;
return {
method: 'Parashara BPHS',
bav: av.bav_raw,
sav: { scores: av.sav },
sav_total: av.sav_total,
sav_by_house: av.sav_by_house,
bav_by_house: av.bav_by_house,
};
}
function _buildJaiminiModule(karaka, charaDasha, karakamsha) {
const k7 = karaka?.karaka7 || {};
const k8 = karaka?.karaka8 || {};
const DOMAIN = {
AK: '灵魂使命、核心自我、人生最高目标', AmK: '事业方向、主要谋士、权力代理',
BK: '兄弟姐妹、勇气、冒险精神', MK: '母亲、家庭根基、情感安全感',
PK: '子女、创造力、学生、智能成果', GK: '竞争对手、疾病、障碍、转化力量',
DK: '配偶特质、伴侣关系、婚姻质量', PiK: '父亲、祖先、遗产',
};
const CN = {
AK: '灵魂星AK', AmK: '事业星AmK', BK: '兄弟星BK', MK: '母亲星MK',
PK: '子女星PK', GK: '障碍星GK', DK: '配偶星DK', PiK: '父亲星PiK',
};
const kt7 = {}, kt8 = {};
let rank = 1;
for (const [k, v] of Object.entries(k7)) {
kt7[k] = { ...v, rank: rank++, domain: DOMAIN[k] || '', cn_name: CN[k] || '' };
}
rank = 1;
for (const [k, v] of Object.entries(k8)) {
kt8[k] = { ...v, rank: rank++, domain: DOMAIN[k] || '', cn_name: CN[k] || '' };
}
return {
chara_karaka_7: {
karaka_table: kt7,
summary: {
AK: k7.AK ? `${k7.AK.planet} (${k7.AK.degree}°)` : '',
DK: k7.DK ? `${k7.DK.planet} (${k7.DK.degree}°)` : '',
AK_domain: DOMAIN.AK, DK_domain: DOMAIN.DK,
},
},
chara_karaka_8: { karaka_table_8: kt8 },
chara_dasha: charaDasha,
karakamsha,
};
}
// ============================================================================
// 导出功能
// ============================================================================
function getExportFormatLabel(format) {
return {
json: 'JSON 数据',
html: 'HTML 报告',
pdf: 'PDF 报告',
svg: '星盘 SVG',
png: '星盘 PNG',
}[format] || '报告';
}
function setExportStatus(message, tone = 'working') {
const status = $('export-status');
if (!status) return;
status.textContent = message || '';
status.classList.toggle('hidden', !message);
status.classList.toggle('is-working', tone === 'working');
status.classList.toggle('is-success', tone === 'success');
status.classList.toggle('is-error', tone === 'error');
}
function getPDFExportRecoveryMessage(error = null) {
const reason = error?.message || 'PDF 渲染器不可用';
return `PDF 渲染器不可用:${reason}。已改为导出 HTML 报告;也可先导出 HTML 报告并用浏览器打印为 PDF。请先到 Trust Center 运行健康检查。若本地 API 未连接,启动网页服务和本地 API 服务后重试 PDF。`;
}
function getGenericExportRecoveryMessage(format, error = null) {
const label = getExportFormatLabel(format);
const reason = error?.message || '导出模块加载失败';
return `导出失败:${label} 未完成。${reason}。请刷新页面后重试;若仍失败,先导出 JSON 保存数据,再检查浏览器下载权限。`;
}
function buildExportRecoveryMessage(format, error) {
return format === 'pdf'
? getPDFExportRecoveryMessage(error)
: getGenericExportRecoveryMessage(format, error);
}
function clearExportStatusSoon(delay = 4200) {
window.setTimeout(() => {
if (!_exportInProgress) setExportStatus('');
}, delay);
}
function setExportBusy(isBusy, activeItem = null) {
_exportInProgress = Boolean(isBusy);
const btnExport = $('btn-export');
const items = document.querySelectorAll('.export-item');
if (btnExport) {
btnExport.disabled = _exportInProgress;
btnExport.classList.toggle('is-exporting', _exportInProgress);
btnExport.setAttribute('aria-busy', String(_exportInProgress));
}
items.forEach(item => {
item.disabled = _exportInProgress;
item.classList.toggle('is-exporting', _exportInProgress && item === activeItem);
item.setAttribute('aria-busy', String(_exportInProgress && item === activeItem));
});
}
function setupExport() {
const btnExport = $('btn-export');
const menu = $('export-menu');
if (!btnExport || !menu) return;
btnExport.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('hidden');
});
document.addEventListener('click', () => menu.classList.add('hidden'));
menu.querySelectorAll('.export-item').forEach(item => {
item.addEventListener('click', async () => {
menu.classList.add('hidden');
if (!chartData) { alert(t('alert.chart')); return; }
if (_exportInProgress) return;
const format = item.dataset.format;
const label = getExportFormatLabel(format);
try {
setExportBusy(true, item);
setExportStatus(`正在准备${label}...`, 'working');
const exportModule = await loadExportModule();
if (format === 'json' || format === 'html' || format === 'pdf') {
const extras = buildExportExtras(chartData);
if (format === 'json') {
exportModule.exportJSON(chartData, extras);
setExportStatus('JSON 数据已开始下载。', 'success');
} else if (format === 'html') {
exportModule.exportHTMLReport(chartData, extras);
setExportStatus('HTML 报告已开始下载,可直接打开或打印。', 'success');
} else {
const result = await exportModule.exportPDFReport(chartData, extras);
if (result?.fallback === 'html') {
const artifactStatus = exportModule.formatReportArtifactStatus?.(result);
setExportStatus(artifactStatus || getPDFExportRecoveryMessage(result?.error ? new Error(result.error) : null), 'error');
} else {
setExportStatus(exportModule.formatReportArtifactStatus?.(result) || 'PDF 报告已生成并开始下载。', 'success');
}
}
} else if (format === 'svg') {
const chartEl = $('rasi-chart');
exportModule.exportSVG(chartEl, `jyotish-${chartData.birth_info?.date || 'chart'}`);
setExportStatus('星盘 SVG 已开始下载。', 'success');
} else if (format === 'png') {
const chartEl = $('rasi-chart');
await exportModule.exportPNG(chartEl, `jyotish-${chartData.birth_info?.date || 'chart'}`);
setExportStatus('星盘 PNG 已开始下载。', 'success');
}
} catch (error) {
setExportStatus(buildExportRecoveryMessage(format, error), 'error');
} finally {
setExportBusy(false);
clearExportStatusSoon();
}
});
});
}
// ============================================================================
// 星盘风格切换
// ============================================================================
function setupChartStyle() {
const btns = document.querySelectorAll('.style-btn');
btns.forEach(btn => {
btn.addEventListener('click', () => {
const style = btn.dataset.style;
setChartStyle(style);
btns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// 重新渲染本命盘
if (chartData) {
const fn = style === 'north' ? renderNorthIndianChart : renderSouthIndianChart;
fn($('rasi-chart'), chartData, { summary: _lastChartSummary });
}
});
});
}