';
}
/* Round 2 Fix 2: one chart() helper renders each card = header (.chart-h: h3 + caption)
+ SVG (+ legend). The "swipe →" hint is appended inside .chart-h — in the header row it
can never overlap SVG content (the old .chartbox::before overlay sat on the top y-tick).
Card titles/captions live here (i18n keys) so every render keeps the header in the
active language. */
function chartSwipe() { return '' + t('chart.swipe') + ''; }
var CHART_HDR = {
'#g1': function () { return '
' + t('g1.h') + '
' + t('g1.cap') + '
'; },
'#g2': function () { return '
' + t('g2.h') + '
' + t('g2.cap') + '
'; },
'#g3': function () { return '
' + t('g3.h') + '
' + t('g3.cap') + '
'; },
'#g4': function () { return '
' + t('g4.h') + '
' + t('g4.cap', { age: state.a.lifeExp }) + '
'; }
};
function chartH(sel) {
var hd = typeof CHART_HDR[sel] === 'function' ? CHART_HDR[sel]() : (CHART_HDR[sel] || '');
return '
' + hd + '
' + chartSwipe() + '
';
}
function chart(sel, inner, legendItems) {
$(sel).innerHTML = chartH(sel) + (inner || '') + (legendItems ? legend(legendItems) : '');
}
var COL = { bal: '#2563eb', cpp: '#2563eb', oas: '#dc2626', gis: '#f59e0b', wdr: '#10b981', cash: '#0ea5e9', need: '#dc2626' };
function g1_balance(proj) {
var yrs = proj.years, ages = yrs.map(function (r) { return r.age; });
var vals = yrs.map(function (r) { return r.totalBal; });
var a0 = ages[0], a1 = ages[ages.length - 1];
var X = sc(ages, a0, a1), ymax = Math.max(10000, Math.max.apply(null, vals) * 1.08), Y = sy(ymax);
var inner = axes(ages, ymax, X, Y);
inner += '' +
'';
if (proj.depletionAge) {
var x = X(proj.depletionAge);
inner += '' +
'' + t('chart.runsOut', { age: fmtInt(proj.depletionAge) }) + '';
}
var ret = Math.max(a0, inputs().retireAge);
inner += '' +
'' + t('chart.retire') + '';
chart('#g1', svgWrap(inner), [[t('g1.legend'), COL.bal]]);
}
function g2_income(proj, p) {
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
if (!yrs.length) { chart('#g2', '
' + t('g2.empty') + '
'); return; }
var ages = yrs.map(function (r) { return r.age; });
var X = sc(ages, ages[0], ages[ages.length - 1]);
var series = [
[t('g2.cpp'), COL.cpp, yrs.map(function (r) { return r.cpp; })],
[t('g2.oas'), COL.oas, yrs.map(function (r) { return Math.max(0, r.oasGross - r.oasClawback); })],
[t('g2.gis'), COL.gis, yrs.map(function (r) { return r.gis; })],
[t('g2.savings'), COL.wdr, yrs.map(function (r) { return r.rrifWithdrawal + r.nonregW + r.tfsaW; })]
];
var n = ages.length, tot = [], i, acc;
for (i = 0; i < n; i++) tot[i] = series.reduce(function (s, se) { return s + se[2][i]; }, 0);
var ymax = Math.max(1000, Math.max.apply(null, tot) * 1.08), Y = sy(ymax);
var inner = axes(ages, ymax, X, Y);
var lower = new Array(n).fill(0);
series.forEach(function (se) {
var upper = se[2].map(function (v, j) { return lower[j] + v; });
inner += '';
lower = upper;
});
chart('#g2', svgWrap(inner), series.map(function (s) { return [s[0], s[1]]; }));
}
function g3_cash(proj) {
var yrs = proj.years.filter(function (r) { return r.phase === 'draw'; });
if (!yrs.length) { chart('#g3', ''); return; }
var ages = yrs.map(function (r) { return r.age; });
var X = sc(ages, ages[0], ages[ages.length - 1]);
var inc = yrs.map(function (r) { return r.cash + r.unfunded; });
var need = yrs.map(function (r) { return r.need; });
var ymax = Math.max(1000, Math.max(Math.max.apply(null, inc), Math.max.apply(null, need)) * 1.12), Y = sy(ymax);
var inner = axes(ages, ymax, X, Y);
inner += '';
inner += '';
var first = yrs.map(function (r, i) { return r.unfunded > 1 ? ages[i] : null; }).filter(function (x) { return x; })[0];
if (first) {
inner += '' +
'' + t('g3.shortfall', { age: fmtInt(first) }) + '';
}
chart('#g3', svgWrap(inner), [[t('g3.cash'), COL.cash], [t('g3.need'), COL.need]]);
}
var g4cache = null;
function g4_bars(p, fast) {
if (g4cache && fast) { drawG4(g4cache); $('#g4-note').textContent = t('g4.computed'); return; }
chart('#g4', '
' + t('g4.calc') + '
');
setTimeout(function () {
var scen = [
[t('g4.s0'), p],
[t('g4.s1'), Object.assign({}, p, { monthlyContribution: p.monthlyContribution + 200, employerMatchMonthly: p.employerMatchMonthly })],
[t('g4.s2'), Object.assign({}, p, { retireAge: Math.min(p.retireAge + 2, 75) })],
[t('g4.s3'), Object.assign({}, p, { cppStartAge: 70 })]
];
var res = scen.map(function (s) { return [s[0], E.sustainableSpending(s[1]).annual]; });
g4cache = res; drawG4(res);
var best = res.slice().sort(function (a, b) { return b[1] - a[1]; })[0];
$('#g4-note').textContent = t('g4.best', { name: best[0], money: money(best[1]) });
}, 30);
}
function drawG4(res) {
var ymax = Math.max(1000, Math.max.apply(null, res.map(function (r) { return r[1]; })) * 1.15);
var bw = (W - L - R) / res.length, inner = '';
for (var i = 0; i <= 4; i++) {
var v = ymax * i / 4, y = sy(ymax)(v);
inner += '' +
'' + k(v) + '';
}
res.forEach(function (r, i) {
var hgt = (H - T - B) * r[1] / ymax, x = L + i * bw + bw * 0.16, y = H - B - hgt;
var col = i === 0 ? '#94a3b8' : (r[1] >= res[0][1] ? '#059669' : '#d97706');
inner += '' +
'' + k(r[1]) + '' +
'' + r[0] + '';
});
chart('#g4', svgWrap(inner));
}
/* ---------- vision ---------- */
var visionManifest = null;
/* Fix 6: a fresh/direct load into results can render before the manifest lands.
When it resolves (or fails, or times out at 2.5s — then we just keep the gradient),
re-render the vision card if results is on screen, so real images replace the fallback. */
var visionReady = (function () {
var p = fetch('vision/manifest.json').then(function (r) { return r.ok ? r.json() : null; });
var timeout = new Promise(function (res) { setTimeout(function () { res(null); }, 2500); });
return Promise.race([p, timeout]);
})();
visionReady
.then(function (m) { visionManifest = m || null; })
.catch(function () { visionManifest = visionManifest || null; })
.then(function () { if (state.screen === 'results') renderVision(state.a); });
function capText(slug) {
var cap = CAPTIONS[slug] || CAPTIONS.hero;
return cap[getLang()] || cap.en;
}
function renderVision(a) {
var el = $('#vision-card');
var provSlugs = PROV_SLUGS[a.province] || [];
var picks = a.interests || [];
function score(slug) {
var s = 0;
if (picks.indexOf(slug) >= 0) s += 3;
if (provSlugs.indexOf(slug) >= 0) s += 2;
if (slug === 'hero' && s === 0) s += 0.5;
return s;
}
var keys = visionManifest ? Object.keys(visionManifest) : null;
if (!keys) { keys = Object.keys(CAPTIONS); }
var best = keys.map(function (s) { return [s, score(s)]; }).sort(function (x, y) { return y[1] - x[1]; })[0];
if (!best) best = ['hero', 0];
var slug = best[1] > 0 ? best[0] : 'hero';
var cap = capText(slug);
var label = t('vision.label', { slug: slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) });
var img = visionManifest && visionManifest[slug] ? 'vision/' + visionManifest[slug] : null;
if (img) {
el.className = 'vision';
el.innerHTML = '' +
'
' + label + ' ' + cap +
' ' + t('vision.ai') + '
';
var im = el.querySelector('img');
im.onerror = function () { grad(el, slug, cap); };
} else grad(el, slug, cap);
}
function grad(el, slug, cap) {
el.className = 'vision grad';
var h = 0; for (var i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) >>> 0;
el.style.background = GRADS[h % GRADS.length];
el.innerHTML = '
' + t('vision.label', { slug: slug.replace(/^\w/, function (c) { return c.toUpperCase(); }) }) + ' ' + cap + '
';
}
/* ---------- results ---------- */
function renderResults(fast) {
var p = inputs(), proj = E.project(p), rec;
try { rec = E.recommendations(p, proj); } catch (e) { rec = { gapAnnual: 0 }; }
var ss = E.sustainableSpending(p).annual;
var v = $('#verdict'), vh = $('#verdict-h');
vh.textContent = t('r.verdictH');
if (proj.depletionAge) {
v.className = 'verdict bad';
v.innerHTML = t('r.depletion', { age: fmtInt(proj.depletionAge), ss: money(ss), le: p.lifeExp, sp: money(p.spendingAnnual) });
} else {
v.className = 'verdict good';
v.innerHTML = t('r.good', { le: p.lifeExp, bal: k(proj.finalBal), ss: money(ss), hr: money(ss - p.spendingAnnual) });
}
/* lifeexp-lbl now lives inside the g4 card header (rendered below via chart('#g4'));
the header bakes in state.a.lifeExp, so just sync if the node exists (re-renders). */
var le = $('#lifeexp-lbl'); if (le) le.textContent = p.lifeExp;
var cards = '';
if (rec.gapAnnual > 0) {
cards += '