/* ═══════════════════════════════════════════════════════════
SOUL BLUEPRINT MATRIX - UI CONTROLLER & INTERACTION ENGINE
Module: src/js/ui_controller.js
═══════════════════════════════════════════════════════════ */
function highlightCurrentAgeNode(ageKey) {
clearAgeNodeHighlights();
const activeNode = document.querySelector(`.age-node[data-pos="${ageKey}"]`);
if (activeNode) {
const circle = activeNode.querySelector('circle.rg');
if (circle) {
circle.style.stroke = 'var(--goldb)';
circle.style.strokeWidth = '2';
}
const label = activeNode.querySelector('.age-range-label');
if (label) {
label.style.fill = 'var(--goldb)';
label.style.fontWeight = '700';
}
activeNode.classList.add('current-age-highlight');
}
}
function clearAgeNodeHighlights() {
document.querySelectorAll('.age-node').forEach(node => {
node.classList.remove('current-age-highlight');
const circle = node.querySelector('circle.rg');
if (circle) {
circle.style.stroke = '#E0D8FF';
circle.style.strokeWidth = '1.2';
}
const label = node.querySelector('.age-range-label');
if (label) {
label.style.fill = 'rgba(201, 184, 240, 0.6)';
label.style.fontWeight = '500';
}
});
}
function renderForecast(v) {
const fcCol = document.getElementById('dv-forecast-col');
const fcBanner = document.getElementById('forecast-banner');
if (!fcCol) return;
if (readingMode === 'single' && v.forecast) {
fcCol.style.display = 'block';
document.getElementById('fc-title').innerHTML = `Personal Forecast Current Age: ${v.forecast.age} years old`;
currentVals['FORECAST_CURRENT'] = v.forecast.currentEnergy;
currentVals['FORECAST_KEY'] = v.forecast.keyEnergy;
currentVals['FORECAST_OUTCOME'] = v.forecast.outcome;
const rowsEl = document.getElementById('fc-rows');
rowsEl.innerHTML = `
Energy of the Period
${v.forecast.currentEnergy}
Event Line Age ${v.forecast.secondAge}
${v.forecast.keyEnergy}
Outcome of the Period
${v.forecast.outcome}
`;
highlightCurrentAgeNode(v.forecast.currentEnergyKey);
// Update the prominent triple-key banner
if (fcBanner) {
fcBanner.style.display = 'block';
document.getElementById('fc-banner-title').textContent = `Personal Forecast Triple Key · Age ${v.forecast.age}`;
document.getElementById('fc-num-1').textContent = v.forecast.currentEnergy;
document.getElementById('fc-num-2').textContent = v.forecast.keyEnergy;
document.getElementById('fc-num-3').textContent = v.forecast.outcome;
document.getElementById('fc-sub-1').textContent = `Current Age ${v.forecast.age}`;
document.getElementById('fc-sub-2').textContent = `Age ${v.forecast.secondAge}`;
document.getElementById('fc-btn-1').onclick = () => openPanel('FORECAST_CURRENT');
document.getElementById('fc-btn-2').onclick = () => openPanel('FORECAST_KEY');
document.getElementById('fc-btn-3').onclick = () => openPanel('FORECAST_OUTCOME');
}
} else if (readingMode === 'compat' && v.compatForecast) {
fcCol.style.display = 'block';
document.getElementById('fc-title').innerHTML = `Couple Forecast Shared Year Energies`;
currentVals['COMPAT_FORECAST_CURRENT'] = v.compatForecast.currentEnergy;
currentVals['COMPAT_FORECAST_KEY'] = v.compatForecast.keyEnergy;
currentVals['COMPAT_FORECAST_OUTCOME'] = v.compatForecast.outcome;
const name1 = document.getElementById('i-name1').value.trim() || 'Partner 1';
const name2 = document.getElementById('i-name2').value.trim() || 'Partner 2';
const rowsEl = document.getElementById('fc-rows');
rowsEl.innerHTML = `
Energy of the Period
${v.compatForecast.currentEnergy}
Event Line
${v.compatForecast.keyEnergy}
Outcome of the Period
${v.compatForecast.outcome}
${name1} Forecast Key
${v.compatForecast.p1.currentEnergy} → ${v.compatForecast.p1.keyEnergy} → ${v.compatForecast.p1.outcome}
${name2} Forecast Key
${v.compatForecast.p2.currentEnergy} → ${v.compatForecast.p2.keyEnergy} → ${v.compatForecast.p2.outcome}
`;
clearAgeNodeHighlights();
// Update the prominent triple-key banner for couple
if (fcBanner) {
fcBanner.style.display = 'block';
document.getElementById('fc-banner-title').textContent = `Couple Forecast Triple Key`;
document.getElementById('fc-num-1').textContent = v.compatForecast.currentEnergy;
document.getElementById('fc-num-2').textContent = v.compatForecast.keyEnergy;
document.getElementById('fc-num-3').textContent = v.compatForecast.outcome;
document.getElementById('fc-sub-1').textContent = `${name1}: ${v.compatForecast.p1.currentEnergy} + ${name2}: ${v.compatForecast.p2.currentEnergy}`;
document.getElementById('fc-sub-2').textContent = `${name1}: ${v.compatForecast.p1.keyEnergy} + ${name2}: ${v.compatForecast.p2.keyEnergy}`;
document.getElementById('fc-btn-1').onclick = () => openPanel('COMPAT_FORECAST_CURRENT');
document.getElementById('fc-btn-2').onclick = () => openPanel('COMPAT_FORECAST_KEY');
document.getElementById('fc-btn-3').onclick = () => openPanel('COMPAT_FORECAST_OUTCOME');
}
} else {
fcCol.style.display = 'none';
if (fcBanner) fcBanner.style.display = 'none';
}
}
// ═══════════════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════════════
let currentVals = {};
let activePos = null;
let activeModId = 'core';
let editMode = false;
let readingMode = 'single';
function setReadingMode(mode) {
readingMode = mode;
const singleBar = document.getElementById('input-bar-single');
const compatBar = document.getElementById('input-bar-compat');
const btnSingle = document.getElementById('btn-mode-single');
const btnCompat = document.getElementById('btn-mode-compat');
if (mode === 'single') {
singleBar.style.display = 'grid';
compatBar.style.display = 'none';
btnSingle.classList.add('active');
btnCompat.classList.remove('active');
} else {
singleBar.style.display = 'none';
compatBar.style.display = 'grid';
btnSingle.classList.remove('active');
btnCompat.classList.add('active');
}
if (mode === 'single') {
document.getElementById('go-btn').click();
} else {
document.getElementById('go-compat-btn').click();
}
}
function updateMatrix(v) {
currentVals = v;
document.querySelectorAll('.nv').forEach(el => {
const p = el.dataset.pos;
if (p && v[p] !== undefined) el.textContent = v[p];
});
renderDerived(v.derived);
renderForecast(v);
renderActiveProgramsDashboard();
highlightActiveProgramNodes();
broadcastSyncData();
}
function renderDerived(d) {
if (!d) return;
const set = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
set('dv-earth', d.earth); set('dv-sky', d.sky); set('dv-personal', d.personalPurpose);
set('dv-pat', d.paternalP); set('dv-mat', d.maternalP); set('dv-social', d.socialPurpose);
set('dv-spirit', d.spiritualPurpose); set('dv-planetary', currentVals.PLANETARY_MISSION || '–');
const spTitleEl = document.getElementById('sp-title');
if (spTitleEl) {
spTitleEl.innerHTML = (readingMode === 'compat') ? 'Purpose of the Pair Click to read' : 'Soul Potentials Click to read';
}
// Soul Potentials / Purpose of the Pair U1/U2/U3 & Emotional Closeness O/P/OP
set('dv-u1', currentVals.U1 || '–');
set('dv-u2', currentVals.U2 || '–');
set('dv-u3', currentVals.U3 || '–');
set('dv-o', currentVals.O || '–');
set('dv-p', currentVals.P || '–');
set('dv-op', currentVals.OP || '–');
const tb = document.querySelector('#dv-chakra-tbl tbody');
if (tb) {
tb.innerHTML = d.chakras.map((c, i) => {
const cNum = 7 - i;
const keyPhysical = `CK_${cNum}_PHYSICAL`;
const keyEnergy = `CK_${cNum}_ENERGY`;
const keyEmotional = `CK_${cNum}_EMOTIONAL`;
const keyChakra = `CK_${cNum}`;
return `
| ${c.name}${c.zone} |
${c.physical} |
${c.energy} |
${c.emotional} |
`;
}).join('');
}
set('ck-tp', d.chakraTotals.physical); set('ck-te', d.chakraTotals.energy); set('ck-tm', d.chakraTotals.emotional);
}
// ═══════════════════════════════════════════════════════════════════
// BUILD DYNAMIC MODULE LIST
// Merges discovered-from-CSV modules with any defaults,
// preserving natural CSV order. New columns = new tabs automatically.
// ═══════════════════════════════════════════════════════════════════
function getModules() {
const defaults = [
{id:'core', label:' Core', sections:[
{key:'meaning',title:'What This Position Means'},
{key:'positive',title:'Positive Expression'},
{key:'shadow',title:'Shadow Expression'},
{key:'healing',title:'Healing Path'},
]},
{id:'relationships', label:' Love', sections:[
{key:'attraction',title:'Attraction Pattern'},
{key:'lesson',title:'Relationship Lesson'},
{key:'wound',title:'Core Wound'},
{key:'partner',title:'What They Attract'},
]},
{id:'karma', label:' Karma', sections:[
{key:'karmic',title:'Karmic Pattern'},
{key:'past_life',title:'Past Life Theme'},
{key:'resolution',title:'Resolution Path'},
]},
{id:'money', label:'$ Money', sections:[
{key:'money_flow',title:'Money Flow'},
{key:'block',title:'Financial Block'},
{key:'activation',title:'Activation'},
]},
{id:'purpose', label:' Purpose', sections:[
{key:'life_purpose',title:'Life Purpose'},
{key:'gifts',title:'Natural Gifts'},
{key:'mission',title:'Soul Mission'},
]},
{id:'forecast', label:'◎ Forecast',sections:[
{key:'theme',title:'Energy of the Period'},
{key:'recommendations',title:'Event Line'},
{key:'watch_out',title:'Outcome of the Period'},
]},
{id:'compat_general', label:' General', sections:[
{key:'meaning',title:'General Compatibility'},
]},
{id:'compat_love', label:' Love Dynamics', sections:[
{key:'meaning',title:'Love Alignment'},
]},
{id:'compat_karma', label:' Relationship Karma', sections:[
{key:'meaning',title:'Karmic Relationship'},
]},
{id:'compat_finance', label:'$ Shared Finance', sections:[
{key:'meaning',title:'Financial Compatibility'},
]},
{id:'compat_forecast', label:'◎ Couple Forecast', sections:[
{key:'theme',title:'Energy of the Period'},
{key:'recommendations',title:'Event Line'},
{key:'watch_out',title:'Outcome of the Period'},
]},
];
const result = defaults.map(d => ({...d, sections: [...d.sections]}));
Object.values(DISCOVERED_MODULES).forEach(discovered => {
const existing = result.find(m => m.id === discovered.id);
if (!existing) {
result.push({...discovered});
} else {
discovered.sections.forEach(sec => {
if (!existing.sections.find(s => s.key === sec.key)) {
existing.sections.push(sec);
}
});
}
});
return result;
}
// ═══════════════════════════════════════════════════════════════════
// PANEL
// ═══════════════════════════════════════════════════════════════════
function openPanel(pos, syncToBoard = true) {
activePos = pos;
if (syncToBoard) {
bc.postMessage({ type: 'select_position', pos: pos });
}
const meta = (typeof getPositionMeta === 'function') ? getPositionMeta(pos) : (POSITIONS[pos] || {name: pos, formula: '', zone: '', color: '#B088F0'});
const val = currentVals[pos] ?? '–';
const col = meta.color || '#B088F0';
const titleText = meta.name ? `Position ${pos} — ${meta.name}` : `Position ${pos}`;
document.getElementById('panel-pos-label').textContent = `${pos} · ${meta.zone.toUpperCase()}`;
document.getElementById('panel-pos-label').style.color = col;
document.getElementById('p-num').textContent = val;
document.getElementById('p-num').style.color = col;
document.getElementById('p-num').style.textShadow = `0 0 24px ${col}66`;
document.getElementById('p-name').textContent = titleText;
document.getElementById('p-formula').textContent = `Formula: ${meta.formula}`;
const badge = document.getElementById('p-zone-badge');
badge.textContent = meta.zone.toUpperCase();
badge.style.color = col;
// ── Panel Forecast Triple ──────────────────────────────────────
const pft = document.getElementById('panel-forecast-triple');
if (pft) {
const isForecast = pos.startsWith('age') || pos.startsWith('FORECAST_') || pos.startsWith('COMPAT_FORECAST_');
if (isForecast) {
// Determine which energies and which panel keys to use
let e1, e2, e3, key1, key2, key3;
if (pos.startsWith('COMPAT_FORECAST_') || (pos.startsWith('age') && readingMode === 'compat')) {
e1 = currentVals['COMPAT_FORECAST_CURRENT'];
e2 = currentVals['COMPAT_FORECAST_KEY'];
e3 = currentVals['COMPAT_FORECAST_OUTCOME'];
key1 = 'COMPAT_FORECAST_CURRENT'; key2 = 'COMPAT_FORECAST_KEY'; key3 = 'COMPAT_FORECAST_OUTCOME';
} else {
e1 = currentVals['FORECAST_CURRENT'];
e2 = currentVals['FORECAST_KEY'];
e3 = currentVals['FORECAST_OUTCOME'];
key1 = 'FORECAST_CURRENT'; key2 = 'FORECAST_KEY'; key3 = 'FORECAST_OUTCOME';
}
if (e1 != null) {
pft.style.display = 'block';
document.getElementById('pft-n1').textContent = e1;
document.getElementById('pft-n2').textContent = e2;
document.getElementById('pft-n3').textContent = e3;
// Highlight the currently active position
['pft-btn-1','pft-btn-2','pft-btn-3'].forEach(id => document.getElementById(id).classList.remove('active-pft'));
if (pos === key1 || (pos.startsWith('age') && activeModId !== 'compat_forecast')) document.getElementById('pft-btn-1').classList.add('active-pft');
if (pos === key2) document.getElementById('pft-btn-2').classList.add('active-pft');
if (pos === key3) document.getElementById('pft-btn-3').classList.add('active-pft');
document.getElementById('pft-btn-1').onclick = () => openPanel(key1);
document.getElementById('pft-btn-2').onclick = () => openPanel(key2);
document.getElementById('pft-btn-3').onclick = () => openPanel(key3);
} else {
pft.style.display = 'none';
}
} else {
pft.style.display = 'none';
}
}
const modules = getModules();
const activeModules = modules.filter(m => {
if (readingMode === 'compat') {
return m.id.startsWith('compat_') || m.id === 'compatibility';
} else {
return !m.id.startsWith('compat_') && m.id !== 'compatibility' && m.id !== 'programs' && m.id !== 'compat_programs';
}
});
const tabsEl = document.getElementById('mod-tabs');
tabsEl.innerHTML = '';
if (pos.startsWith('age') || pos.startsWith('FORECAST_')) {
activeModId = readingMode === 'compat' ? 'compat_forecast' : 'forecast';
} else if (pos.startsWith('COMPAT_FORECAST_')) {
activeModId = 'compat_forecast';
} else {
// Check preferred module map
const prefMapSingle = {
R1: 'relationships', L: 'relationships', M: 'relationships', S: 'relationships', R: 'relationships',
R2: 'money', Q: 'money',
N: 'karma', H2: 'karma', I2: 'karma',
K: 'purpose', T: 'purpose'
};
const prefMapCompat = {
R1: 'compat_love', L: 'compat_love', M: 'compat_love', S: 'compat_love', R: 'compat_love',
R2: 'compat_finance', Q: 'compat_finance',
N: 'compat_karma', H2: 'compat_karma', I2: 'compat_karma'
};
const prefMap = readingMode === 'compat' ? prefMapCompat : prefMapSingle;
// Check if current activeModId has data for this position & value
let hasDataInActiveMod = false;
if (INTERP[pos] && INTERP[pos][activeModId]) {
for (const secKey in INTERP[pos][activeModId]) {
if (INTERP[pos][activeModId][secKey] && INTERP[pos][activeModId][secKey][val]) {
hasDataInActiveMod = true;
break;
}
}
}
// If activeModId has no data, find which module DOES have data for this position
if (!hasDataInActiveMod) {
let foundModWithData = null;
if (INTERP[pos]) {
for (const modKey in INTERP[pos]) {
const isModActive = activeModules.some(m => m.id === modKey);
if (isModActive) {
for (const secKey in INTERP[pos][modKey]) {
if (INTERP[pos][modKey][secKey] && INTERP[pos][modKey][secKey][val]) {
foundModWithData = modKey;
break;
}
}
}
if (foundModWithData) break;
}
}
if (foundModWithData) {
activeModId = foundModWithData;
} else if (prefMap[pos] && activeModules.some(m => m.id === prefMap[pos])) {
activeModId = prefMap[pos];
} else if (!activeModules.find(m => m.id === activeModId)) {
activeModId = activeModules[0]?.id || (readingMode === 'compat' ? 'compat_general' : 'core');
}
}
}
activeModules.forEach(mod => {
const btn = document.createElement('button');
btn.className = 'mod-tab' + (mod.id === activeModId ? ' active' : '');
btn.textContent = mod.label;
btn.onclick = () => { activeModId = mod.id; openPanel(pos); };
tabsEl.appendChild(btn);
});
renderModContent(pos, val, activeModId, activeModules);
document.querySelectorAll('.ng').forEach(g => g.classList.remove('active'));
const activeNode = document.querySelector(`.ng[data-pos="${pos}"]`);
if (activeNode) activeNode.classList.add('active');
document.getElementById('panel').classList.add('open');
}
function renderModContent(pos, val, modId, modules) {
const mods = modules || getModules();
const mod = mods.find(m => m.id === modId);
if (!mod) return;
let targetPos = pos;
let targetModId = modId;
if (pos.startsWith('age') || pos.startsWith('FORECAST_')) {
targetPos = 'FORECAST';
targetModId = 'forecast';
} else if (pos.startsWith('COMPAT_FORECAST_')) {
targetPos = 'COMPAT_FORECAST';
targetModId = 'compat_forecast';
}
const meta = POSITIONS[pos] || {name: pos, formula: '', zone: '', color: '#B088F0'};
const el = document.getElementById('mod-content');
el.innerHTML = '';
const activeProgs = getActivePrograms();
const nodeProgs = activeProgs.filter(p => p.positions.split('-').includes(pos));
nodeProgs.forEach(prog => {
const card = document.createElement('div');
card.className = 'interp-card';
card.style.borderColor = 'var(--gold)';
card.style.background = 'rgba(212,175,110,0.05)';
const h = document.createElement('h3');
h.textContent = `Active Program: ${prog.name} (${prog.positions} · ${prog.code})`;
h.style.color = 'var(--goldb)';
card.appendChild(h);
const p = document.createElement('p');
p.textContent = prog.text;
card.appendChild(p);
el.appendChild(card);
});
// Compute 3-energy forecast for age nodes and forecast positions
let energies = null;
if (pos.startsWith('age')) {
energies = readingMode === 'compat'
? getAgeForecastEnergiesCompat(pos, currentVals)
: getAgeForecastEnergiesSingle(pos, currentVals);
} else if (pos === 'FORECAST_CURRENT' || pos === 'FORECAST_KEY' || pos === 'FORECAST_OUTCOME') {
energies = [
currentVals['FORECAST_CURRENT'],
currentVals['FORECAST_KEY'],
currentVals['FORECAST_OUTCOME']
];
} else if (pos.startsWith('COMPAT_FORECAST_')) {
energies = [
currentVals['COMPAT_FORECAST_CURRENT'],
currentVals['COMPAT_FORECAST_KEY'],
currentVals['COMPAT_FORECAST_OUTCOME']
];
}
const ctx = document.createElement('div');
ctx.className = 'section-label';
if (energies && energies[0]) {
ctx.textContent = `Forecast Key: ${energies[0]} — ${energies[1]} — ${energies[2]}`;
} else {
ctx.textContent = `Number ${val} in position ${pos}`;
}
el.appendChild(ctx);
// Determine which single section to show for specific forecast position clicks.
// Age-node clicks show all 3 sections (overview). Direct position clicks show just 1.
const sectionFilter =
(pos === 'FORECAST_CURRENT' || pos === 'COMPAT_FORECAST_CURRENT') ? 'theme' :
(pos === 'FORECAST_KEY' || pos === 'COMPAT_FORECAST_KEY') ? 'recommendations' :
(pos === 'FORECAST_OUTCOME' || pos === 'COMPAT_FORECAST_OUTCOME') ? 'watch_out' :
null; // null = show all sections (age nodes, core positions, etc.)
// Check if any section in this module has interpretation text for this position
let hasAnyTextInModule = false;
mod.sections.forEach(sec => {
let sVal = parseInt(val);
if (energies) {
if (sec.key === 'theme') sVal = energies[0];
else if (sec.key === 'recommendations') sVal = energies[1];
else if (sec.key === 'watch_out') sVal = energies[2];
}
if (INTERP[targetPos]?.[targetModId]?.[sec.key]?.[sVal]) {
hasAnyTextInModule = true;
}
});
let renderedCardCount = 0;
mod.sections.forEach(sec => {
// Skip sections that don't match the filter for direct forecast position clicks
if (sectionFilter && sec.key !== sectionFilter) return;
// Each section maps to its own energy number
let sectionVal = parseInt(val);
if (energies) {
if (sec.key === 'theme') {
sectionVal = energies[0]; // 1. Energy of the Period
} else if (sec.key === 'recommendations') {
sectionVal = energies[1]; // 2. Event Line
} else if (sec.key === 'watch_out') {
sectionVal = energies[2]; // 3. Outcome of the Period
}
}
const text = INTERP[targetPos]?.[targetModId]?.[sec.key]?.[sectionVal] || null;
// Skip empty sections completely to keep reading board clean (Item 9)
if (!text) return;
const card = document.createElement('div');
card.className = 'interp-card';
const h = document.createElement('h3');
h.textContent = energies ? `${sec.title} · Arcana ${sectionVal}` : sec.title;
card.appendChild(h);
const editBtn = document.createElement('button');
editBtn.className = 'edit-btn';
editBtn.textContent = 'Edit';
editBtn.onclick = () => startEdit(card, targetPos, targetModId, sec.key, sectionVal, text || '');
card.appendChild(editBtn);
const p = document.createElement('p');
p.textContent = text;
card.appendChild(p);
el.appendChild(card);
renderedCardCount++;
});
if (renderedCardCount === 0) {
const card = document.createElement('div');
card.className = 'interp-card';
const p = document.createElement('p');
p.className = 'empty';
p.textContent = `No interpretation entered yet for ${POSITIONS[pos]?.name || pos} · Arcana ${val}. Click Edit to add, or update interpretations.csv.`;
card.appendChild(p);
el.appendChild(card);
}
const note = document.createElement('div');
note.className = 'export-note';
note.textContent = ' Add interpretations by editing interpretations.csv in Excel';
el.appendChild(note);
}
function startEdit(card, pos, modId, secKey, numVal, currentText) {
const p = card.querySelector('p');
const editBtn = card.querySelector('.edit-btn');
if (!p) return;
p.style.display = 'none';
editBtn.style.display = 'none';
const ta = document.createElement('textarea');
ta.className = 'edit-area';
ta.value = currentText;
ta.placeholder = `Enter your interpretation for number ${numVal} here…`;
const hint = document.createElement('div');
hint.style.cssText = 'font-size:10px;color:rgba(212,175,110,.4);margin:4px 0 2px;';
hint.textContent = 'Session edit only. To save permanently, copy this text into your interpretations.csv file.';
const actions = document.createElement('div');
actions.className = 'edit-actions';
const saveBtn = document.createElement('button');
saveBtn.className = 'edit-save';
saveBtn.textContent = 'Apply';
saveBtn.onclick = () => {
const newText = ta.value.trim();
if (!INTERP[pos]) INTERP[pos] = {};
if (!INTERP[pos][modId]) INTERP[pos][modId] = {};
if (!INTERP[pos][modId][secKey]) INTERP[pos][modId][secKey] = {};
INTERP[pos][modId][secKey][numVal] = newText;
renderModContent(pos, String(numVal), modId);
};
const cancelBtn = document.createElement('button');
cancelBtn.className = 'edit-cancel';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => {
ta.remove(); hint.remove(); actions.remove();
p.style.display = '';
editBtn.style.display = '';
};
actions.appendChild(saveBtn);
actions.appendChild(cancelBtn);
card.appendChild(hint);
card.appendChild(ta);
card.appendChild(actions);
ta.focus();
}
function toggleEditMode() {
editMode = !editMode;
document.body.classList.toggle('edit-mode', editMode);
const btn = document.getElementById('panel-edit-toggle');
btn.style.background = editMode ? 'rgba(92,222,200,.18)' : '';
btn.style.borderColor = editMode ? 'var(--teal)' : '';
btn.style.color = editMode ? 'var(--teal)' : '';
btn.textContent = editMode ? ' Exit Edit' : ' Edit Mode';
}
function closePanel() {
document.getElementById('panel').classList.remove('open');
document.querySelectorAll('.ng').forEach(g => g.classList.remove('active'));
activePos = null;
}
// ═══════════════════════════════════════════════════════════════════
// NODE INTERACTION
// ═══════════════════════════════════════════════════════════════════
document.querySelectorAll('.ng').forEach(g => {
g.addEventListener('click', () => openPanel(g.dataset.pos));
});
document.getElementById('msv').addEventListener('click', e => {
if (!e.target.closest('.ng')) closePanel();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closePanel(); });
// ═══════════════════════════════════════════════════════════════════
// CALCULATE BUTTON
// ═══════════════════════════════════════════════════════════════════
document.getElementById('go-btn').addEventListener('click', () => {
const name = document.getElementById('i-name').value.trim() || 'Client';
const day = parseInt(document.getElementById('i-day').value) || 1;
const mon = parseInt(document.getElementById('i-mon').value) || 1;
const yr = parseInt(document.getElementById('i-yr').value) || 2000;
document.getElementById('client-display').textContent =
`${name} · ${String(day).padStart(2,'0')} / ${String(mon).padStart(2,'0')} / ${yr}`;
const v = calc(day, mon, yr);
v.forecast = getPersonalForecast(v, day, mon, yr);
updateMatrix(v);
if (activePos) openPanel(activePos);
});
document.getElementById('go-compat-btn').addEventListener('click', () => {
const name1 = document.getElementById('i-name1').value.trim() || 'Partner 1';
const day1 = parseInt(document.getElementById('i-day1').value) || 1;
const mon1 = parseInt(document.getElementById('i-mon1').value) || 1;
const yr1 = parseInt(document.getElementById('i-yr1').value) || 2000;
const name2 = document.getElementById('i-name2').value.trim() || 'Partner 2';
const day2 = parseInt(document.getElementById('i-day2').value) || 1;
const mon2 = parseInt(document.getElementById('i-mon2').value) || 1;
const yr2 = parseInt(document.getElementById('i-yr2').value) || 2000;
const nameDisplay = `${name1} & ${name2}`;
const dobDisplay = `${String(day1).padStart(2,'0')}/${String(mon1).padStart(2,'0')}/${yr1} & ${String(day2).padStart(2,'0')}/${String(mon2).padStart(2,'0')}/${yr2}`;
document.getElementById('client-display').textContent = `${nameDisplay} · ${dobDisplay}`;
const v_comb = calcCompatibility(day1, mon1, yr1, day2, mon2, yr2);
v_comb.compatForecast = getCompatibilityForecast(day1, mon1, yr1, day2, mon2, yr2);
updateMatrix(v_comb);
if (activePos) openPanel(activePos);
});
document.getElementById('nav-forecast-btn').addEventListener('click', () => {
if (readingMode === 'single') {
openPanel('FORECAST_CURRENT');
} else {
openPanel('COMPAT_FORECAST_CURRENT');
}
});
// ═══════════════════════════════════════════════════════════════════
// EXPORT
// ═══════════════════════════════════════════════════════════════════
document.getElementById('export-btn').addEventListener('click', () => window.print());
// ═══════════════════════════════════════════════════════════════════
// MODULE NAV (top bar)
// ═══════════════════════════════════════════════════════════════════
document.querySelectorAll('.mnav-btn:not(.soon)').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mnav-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// ═══════════════════════════════════════════════════════════════════
// STARS BACKGROUND
// ═══════════════════════════════════════════════════════════════════
(() => {
const c = document.getElementById('stars');
const ctx = c.getContext('2d');
const rng = (s => () => {
s |= 0; s = s + 0x6D2B79F5 | 0;
let t = Math.imul(s ^ s >>> 15, 1 | s);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
})(42);
function draw() {
c.width = innerWidth; c.height = innerHeight;
ctx.clearRect(0, 0, c.width, c.height);
for (let i = 0; i < 320; i++) {
const x = rng() * c.width, y = rng() * c.height, r = rng() * 1.5 + .2, a = rng() * .5 + .08;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${a})`; ctx.fill();
}
for (let i = 0; i < 14; i++) {
const x = rng() * c.width, y = rng() * c.height, s = rng() * 9 + 4, a = rng() * .22 + .12;
ctx.strokeStyle = `rgba(255,255,255,${a})`; ctx.lineWidth = .5;
[[x-s,y,x+s,y],[x,y-s,x,y+s]].forEach(([x1,y1,x2,y2]) => {
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
});
}
}
draw(); window.addEventListener('resize', draw);
})();
function generateOuterAgeNodes() {
const group = document.getElementById('age-nodes-group');
if (!group) return;
group.innerHTML = '';
const vertices = {
A: { x: 130, y: 450 },
F: { x: 218, y: 238 },
B: { x: 430, y: 150 },
G: { x: 642, y: 238 },
C: { x: 730, y: 450 },
H: { x: 642, y: 662 },
D: { x: 430, y: 750 },
I: { x: 218, y: 662 }
};
const sectorCorners = [
['A', 'F'], ['F', 'B'], ['B', 'G'], ['G', 'C'],
['C', 'H'], ['H', 'D'], ['D', 'I'], ['I', 'A']
];
const tValues = [0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875];
const ageOffsets = [1.25, 2.5, 3.75, 5, 6.25, 7.5, 8.75];
sectorCorners.forEach(([c1, c2], sectorIdx) => {
const startAge = sectorIdx * 10;
const p1 = vertices[c1];
const p2 = vertices[c2];
tValues.forEach((t, i) => {
const ageOffset = ageOffsets[i];
const age = startAge + ageOffset;
const ageKey = `age${age}`;
const x = p1.x + t * (p2.x - p1.x);
const y = p1.y + t * (p2.y - p1.y);
const vx = x - 430;
const vy = y - 450;
const len = Math.sqrt(vx * vx + vy * vy);
const ux = vx / len;
const uy = vy / len;
const textDist = 25;
const tx = x + ux * textDist;
const ty = y + uy * textDist;
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('class', 'ng age-node');
g.setAttribute('data-pos', ageKey);
g.style.cursor = 'pointer';
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
const meta = POSITIONS[ageKey] || {name: `Age Node ${age}`};
title.textContent = `${ageKey} · ${meta.name}`;
g.appendChild(title);
const hl = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
hl.setAttribute('cx', x);
hl.setAttribute('cy', y);
hl.setAttribute('r', 15);
hl.setAttribute('fill', 'rgba(167, 139, 250, 0.2)');
hl.setAttribute('class', 'hl');
hl.setAttribute('filter', 'url(#fb)');
hl.setAttribute('opacity', '0');
hl.style.transition = 'opacity 0.15s ease';
g.appendChild(hl);
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', x);
circle.setAttribute('cy', y);
circle.setAttribute('r', 10);
circle.setAttribute('fill', '#0D082E');
circle.setAttribute('stroke', '#E0D8FF');
circle.setAttribute('stroke-width', '1.2');
circle.setAttribute('class', 'rg');
circle.style.transition = 'stroke 0.15s ease, stroke-width 0.15s ease';
g.appendChild(circle);
const textNum = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textNum.setAttribute('x', x);
textNum.setAttribute('y', y);
textNum.setAttribute('text-anchor', 'middle');
textNum.setAttribute('dominant-baseline', 'central');
textNum.setAttribute('font-family', 'Cinzel, serif');
textNum.setAttribute('font-weight', '700');
textNum.setAttribute('font-size', '9.5');
textNum.setAttribute('fill', '#FFF');
textNum.setAttribute('class', 'nv');
textNum.setAttribute('data-pos', ageKey);
textNum.textContent = '·';
g.appendChild(textNum);
const textRange = document.createElementNS('http://www.w3.org/2000/svg', 'text');
textRange.setAttribute('x', tx);
textRange.setAttribute('y', ty);
textRange.setAttribute('text-anchor', 'middle');
textRange.setAttribute('dominant-baseline', 'central');
textRange.setAttribute('font-family', 'Raleway, sans-serif');
textRange.setAttribute('font-size', '7.5');
textRange.setAttribute('font-weight', '500');
textRange.setAttribute('fill', 'rgba(201, 184, 240, 0.6)');
textRange.setAttribute('class', 'age-range-label');
textRange.style.transition = 'fill 0.15s ease, font-weight 0.15s ease';
const rangeMatch = meta.name.match(/\(([^)]+)\)/);
textRange.textContent = rangeMatch ? rangeMatch[1] : age;
g.appendChild(textRange);
g.addEventListener('mouseenter', () => {
hl.setAttribute('opacity', '1');
circle.setAttribute('stroke', 'var(--goldb)');
circle.setAttribute('stroke-width', '1.8');
textRange.setAttribute('fill', 'var(--goldb)');
textRange.setAttribute('font-weight', '700');
});
g.addEventListener('mouseleave', () => {
if (activePos !== ageKey && !g.classList.contains('current-age-highlight')) {
hl.setAttribute('opacity', '0');
circle.setAttribute('stroke', '#E0D8FF');
circle.setAttribute('stroke-width', '1.2');
textRange.setAttribute('fill', 'rgba(201, 184, 240, 0.6)');
textRange.setAttribute('font-weight', '500');
}
});
g.addEventListener('click', (e) => {
e.stopPropagation();
openPanel(ageKey);
});
group.appendChild(g);
});
});
}
updateDbModeButton();
loadCSV().then(() => {
generateOuterAgeNodes();
document.getElementById('go-btn').click();
});