← Files MorningstarARCHIVED FILE
skills/fund-comparison/assets/template.html
63.8 KB · Oct 5, 2026 · 18:07 UTC
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fund Comparison</title>
<style>
:root {
--c-mstar: #FC0412;
--c-primary: #0F3D7C;
--c-text: #3D3B39;
--c-muted: #7F7D7A;
--c-bg: #F6F5F4;
--c-card: #FFFFFF;
--c-border: #ECEBEA;
--c-pos: #039649;
--c-neg: #E42513;
--c-fund-a: #2364B9;
--c-fund-b: #B2013C;
--c-grid: #DAD9D8;
--c-axis: #A6A4A1;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "MORN Intrinsic", "Aptos Light", Aptos, Calibri, Georgia, serif;
font-size: 13px;
background: var(--c-bg);
color: var(--c-text);
line-height: 1.5;
}
/* ── Header ── */
.header {
background: var(--c-mstar);
color: white;
padding: 2% 5%;
}
.header-eyebrow {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
opacity: 0.75;
margin-bottom: 6px;
}
.header-funds {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.header-funds h1 { font-size: 20px; font-weight: 700; }
.header-funds .vs {
font-size: 13px;
opacity: 0.7;
font-weight: 400;
}
.header-ticker {
font-size: 13px;
font-weight: 600;
padding: 2px 10px;
border-radius: 4px;
}
.ticker-a { background: rgba(255,255,255,0.18); }
.ticker-b { background: rgba(255,255,255,0.10); }
/* ── Layout ── */
.main {
padding: 18px 24px 36px;
max-width: 1200px;
margin: 0 auto;
}
.section {
background: var(--c-card);
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,.08);
margin-bottom: 16px;
padding: 16px 20px;
}
.section-title {
font-size: 14px;
font-weight: 700;
color: var(--c-mstar);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.section-subtitle {
font-size: 11px;
font-weight: 700;
color: var(--c-primary);
text-transform: uppercase;
letter-spacing: 0.03em;
margin: 16px 0 8px;
}
.section-subtitle:first-of-type { margin-top: 0; }
.composition-block {
margin-top: 22px;
}
.composition-block:first-of-type {
margin-top: 0;
}
.composition-group {
margin-top: 28px;
padding-top: 16px;
border-top: 1px solid var(--c-border);
}
.composition-group-title {
font-size: 12px;
font-weight: 700;
color: var(--c-mstar);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 12px;
}
.composition-group .section-subtitle:first-of-type {
margin-top: 0;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
/* ── Warning banner ── */
.warning-banner {
background: #FFF8E1;
border: 1px solid #F5C72C;
border-radius: 5px;
padding: 10px 14px;
font-size: 12px;
margin-bottom: 14px;
display: none;
}
.warning-banner.visible { display: block; }
.warning-banner strong { color: #7B5800; }
/* ── Tables (general) ── */
table { width: 100%; border-collapse: collapse; font-size: 12px; }
thead th {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--c-muted);
padding: 7px 10px;
text-align: right;
border-bottom: 2px solid var(--c-primary);
}
thead th:first-child { text-align: left; }
tbody td {
padding: 7px 10px;
text-align: right;
border-bottom: 1px solid var(--c-border);
font-variant-numeric: tabular-nums;
}
tbody td:first-child { text-align: left; font-weight: 600; }
.table-footnote {
font-size: 10px;
color: var(--c-muted);
margin-top: 6px;
font-style: italic;
}
.val-positive { color: var(--c-pos); }
.val-negative { color: var(--c-neg); }
.val-muted { color: var(--c-muted); }
.delta-pill {
display: inline-block;
box-sizing: border-box;
min-width: 62px;
padding: 2px 0 2px 7px;
border-radius: 999px;
text-align: right;
font-weight: 700;
}
.delta-pill.val-positive {
background: rgba(3,150,73,0.10);
color: var(--c-pos);
}
.delta-pill.val-negative {
background: rgba(228,37,19,0.10);
color: var(--c-neg);
}
/* fund-a / fund-b column headers */
.th-a { color: var(--c-fund-a) !important; }
.th-b { color: var(--c-fund-b) !important; }
/* ── Overview table ── */
.overview-table td:nth-child(2) { color: var(--c-fund-a); font-weight: 600; }
.overview-table td:nth-child(3) { color: var(--c-fund-b); font-weight: 600; }
.overview-table .overview-group-row td {
background: var(--c-bg);
color: var(--c-primary);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: left !important;
padding: 6px 10px;
border-top: 1px solid var(--c-border);
border-bottom: 1px solid var(--c-border);
}
/* ── Style box container (shared wrapper) ── */
.style-box-container {
border: 1px solid var(--c-border);
border-radius: 6px;
overflow: hidden;
margin-bottom: 4px;
}
.style-box-container-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: var(--c-bg);
border-bottom: 1px solid var(--c-border);
}
.style-box-container-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--c-primary);
}
.sb-legend {
display: flex;
align-items: center;
gap: 10px;
font-size: 9px;
color: var(--c-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.sb-legend-item { display: flex; align-items: center; gap: 4px; }
.sb-legend-swatch {
width: 12px; height: 12px;
border-radius: 2px;
border: 1px solid rgba(0,0,0,0.08);
}
.style-box-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.style-box-pair.single {
grid-template-columns: minmax(280px, 420px);
}
.style-box-wrap {
padding: 14px 16px;
}
.style-box-pair:not(.single) .style-box-wrap:first-child {
border-right: 1px solid var(--c-border);
}
.style-box-fund-label {
font-size: 11px;
font-weight: 700;
margin-bottom: 8px;
}
.style-box-fund-label.label-a { color: var(--c-fund-a); }
.style-box-fund-label.label-b { color: var(--c-fund-b); }
.style-box { width: 100%; border-collapse: collapse; font-size: 12px; }
.style-box thead th {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--c-muted);
padding: 4px 6px;
text-align: center;
border-bottom: 1px solid var(--c-border);
}
.style-box thead th:first-child { text-align: left; width: 44px; }
.style-box tbody td {
padding: 10px 6px;
text-align: center;
border: 1px solid rgba(0,0,0,0.06);
font-weight: 600;
}
.style-box tbody td:first-child {
text-align: left;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--c-muted);
background: var(--c-bg);
border-left: none;
border-color: var(--c-border);
padding: 10px 8px;
}
/* Heat-map tiers — Fund A (blue) */
.sb-a-4 { background: rgba(35,100,185,0.75); color: #fff; }
.sb-a-3 { background: rgba(35,100,185,0.42); color: var(--c-text); }
.sb-a-2 { background: rgba(35,100,185,0.18); color: var(--c-text); }
.sb-a-1 { background: rgba(35,100,185,0.05); color: var(--c-muted); }
/* Heat-map tiers — Fund B (red) */
.sb-b-4 { background: rgba(178,1,60,0.75); color: #fff; }
.sb-b-3 { background: rgba(178,1,60,0.42); color: var(--c-text); }
.sb-b-2 { background: rgba(178,1,60,0.18); color: var(--c-text); }
.sb-b-1 { background: rgba(178,1,60,0.05); color: var(--c-muted); }
/* ── Sector table ── */
.sector-group-row td {
background: var(--c-bg);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--c-primary);
padding: 5px 10px;
text-align: left !important;
border-bottom: 1px solid var(--c-border);
}
.sector-material-row td {
background: rgba(255, 246, 220, 0.55);
}
.sector-material-row td:first-child {
color: var(--c-primary);
}
.sector-material-row .bar-cell-fill.bc-a { background: rgba(35,100,185,0.20); }
.sector-material-row .bar-cell-fill.bc-b { background: rgba(178,1,60,0.20); }
.sector-material-row .bar-cell-text {
font-weight: 700;
color: var(--c-text);
}
/* ── Chart ── */
.chart-legend {
display: flex;
gap: 20px;
margin-bottom: 10px;
font-size: 12px;
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-swatch {
width: 18px; height: 3px;
border-radius: 2px;
flex-shrink: 0;
}
.chart-wrap { width: 100%; overflow: hidden; }
.chart-wrap svg { width: 100%; height: auto; display: block; }
.chart-point .hover-target { cursor: crosshair; }
.chart-point .data-dot,
.chart-point .tick-line,
.chart-point .chart-tooltip-box { display: none; }
.chart-point:hover .data-dot,
.chart-point:hover .tick-line,
.chart-point:hover .chart-tooltip-box { display: block; }
/* ── Tabs ── */
.tabs {
display: flex;
gap: 0;
margin: 14px 0 0;
border-bottom: 2px solid var(--c-border);
}
.tab-btn {
padding: 7px 16px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
color: var(--c-muted);
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
}
.tab-btn.active { color: var(--c-primary); border-bottom-color: var(--c-primary); }
.tab-panel { display: none; padding-top: 14px; }
.tab-panel.active { display: block; }
/* ── Narrative ── */
.analyst-block {
background: var(--c-bg);
border-radius: 5px;
padding: 12px 16px;
margin-bottom: 10px;
font-size: 12px;
line-height: 1.6;
}
.analyst-block .fund-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 4px;
}
.analyst-block .fund-label.label-a { color: var(--c-fund-a); }
.analyst-block .fund-label.label-b { color: var(--c-fund-b); }
#narrative-body { margin-top: 18px; }
.narrative-section + .narrative-section { margin-top: 14px; }
.narrative-heading {
color: var(--c-primary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
margin: 0 0 4px;
text-transform: uppercase;
}
.narrative-p { font-size: 13px; line-height: 1.6; margin: 0; }
/* ── Footer ── */
.footer {
font-size: 11px;
color: var(--c-muted);
padding: 12px 24px;
text-align: center;
}
/* ── Asset allocation bar chart ── */
.alloc-chart { padding: 2px 0; }
.alloc-row {
display: grid;
grid-template-columns: 110px 1fr;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.alloc-row:last-child { margin-bottom: 0; }
.alloc-label { font-size: 12px; font-weight: 600; }
.alloc-bars { display: flex; flex-direction: column; gap: 4px; }
.alloc-bar-track { display: flex; align-items: center; gap: 8px; }
.alloc-fund-tag {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
width: 26px;
flex-shrink: 0;
}
.alloc-fund-tag.tag-a { color: var(--c-fund-a); }
.alloc-fund-tag.tag-b { color: var(--c-fund-b); }
.alloc-bar-outer {
flex: 1;
height: 14px;
background: #F1F4F7;
border: 1px solid #DDE4EC;
border-radius: 3px;
overflow: hidden;
}
.alloc-bar-fill { height: 100%; border-radius: 2px; }
.alloc-bar-fill.fill-a { background: rgba(35,100,185,0.60); }
.alloc-bar-fill.fill-b { background: rgba(178,1,60,0.60); }
.alloc-bar-pct {
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
width: 38px;
text-align: right;
flex-shrink: 0;
}
.alloc-bar-delta {
font-size: 10px;
font-weight: 600;
width: 42px;
text-align: right;
flex-shrink: 0;
color: var(--c-muted);
}
.alloc-bar-delta.val-positive { color: var(--c-pos); }
.alloc-bar-delta.val-negative { color: var(--c-neg); }
.alloc-legend {
display: flex;
gap: 16px;
margin-bottom: 12px;
font-size: 11px;
}
.alloc-legend-item { display: flex; align-items: center; gap: 6px; }
.alloc-legend-swatch {
width: 20px; height: 10px;
border-radius: 2px;
}
.alloc-legend-swatch.ls-a { background: rgba(35,100,185,0.60); }
.alloc-legend-swatch.ls-b { background: rgba(178,1,60,0.60); }
/* ── Sector inline bar ── */
.bar-cell {
position: relative;
text-align: right;
padding: 0 !important;
border-bottom: 1px solid var(--c-border);
}
.bar-cell-track {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 7px 10px;
overflow: hidden;
}
.bar-cell-fill {
position: absolute;
right: 0;
top: 0;
bottom: 0;
border-radius: 3px 0 0 3px;
}
.bar-cell-fill.bc-a { background: rgba(35,100,185,0.12); }
.bar-cell-fill.bc-b { background: rgba(178,1,60,0.12); }
.bar-cell-text { position: relative; z-index: 1; font-variant-numeric: tabular-nums; }
/* ── Stat cards (Cost & Ratings) ── */
.stat-group-header {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-primary);
border-left: 3px solid var(--c-primary);
padding: 2px 0 2px 10px;
margin-bottom: 10px;
}
.stat-group + .stat-group { margin-top: 18px; }
.stat-card-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--c-border);
border: 1px solid var(--c-border);
border-radius: 5px;
overflow: hidden;
}
.stat-card { background: var(--c-card); padding: 10px 12px; }
.stat-card-label {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-muted);
margin-bottom: 8px;
}
.stat-card-vals { display: flex; flex-direction: column; gap: 3px; }
.stat-card-val { display: flex; align-items: baseline; gap: 6px; }
.stat-card-ticker {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
width: 28px;
flex-shrink: 0;
}
.stat-card-ticker.sct-a { color: var(--c-fund-a); }
.stat-card-ticker.sct-b { color: var(--c-fund-b); }
.stat-card-num { font-size: 13px; font-weight: 700; }
/* ── Risk period tabs ── */
.risk-period-tabs { margin-top: 0; margin-bottom: 0; }
.risk-panel { display: none; padding-top: 12px; }
.risk-panel.active { display: block; }
.risk-return-wrap {
margin-bottom: 14px;
}
.risk-return-chart {
width: 100%;
overflow: hidden;
border-bottom: 1px solid var(--c-border);
padding-bottom: 12px;
}
.risk-return-chart svg {
width: 100%;
height: auto;
display: block;
}
.risk-return-footnote {
font-size: 10px;
color: var(--c-muted);
margin-top: 6px;
font-style: italic;
}
/* ── Print ── */
@media print {
body { background: white; font-size: 11px; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.main { padding: 10px 0; max-width: 100%; }
.section { box-shadow: none; border: 1px solid #ddd; page-break-inside: avoid; margin-bottom: 14px; }
.tabs { display: none; }
.tab-panel { display: block !important; padding-top: 10px; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-eyebrow">Morningstar — Fund Comparison</div>
<div class="header-funds">
<h1 id="hdr-name-a"></h1>
<span class="vs">vs</span>
<h1 id="hdr-name-b"></h1>
<span class="header-ticker ticker-a" id="hdr-ticker-a"></span>
<span class="header-ticker ticker-b" id="hdr-ticker-b"></span>
</div>
</div>
<div class="main">
<!-- Cross-category warning (shown conditionally) -->
<div class="warning-banner" id="cross-cat-warning">
<strong>Cross-Category Note:</strong> These funds belong to different Morningstar Categories.
Category rankings and peer-group statistics reflect each fund's own peer group and are not
directly comparable across categories.
</div>
<!-- ── OVERVIEW ── -->
<div class="section" id="overview-section">
<div class="section-title">Overview</div>
<table class="overview-table">
<thead>
<tr>
<th></th>
<th class="th-a" id="ph-head-a"></th>
<th class="th-b" id="ph-head-b"></th>
</tr>
</thead>
<tbody id="profile-tbody"></tbody>
</table>
</div>
<!-- ── STRUCTURAL COMPOSITION ── -->
<div class="section">
<div class="section-title">Structural Composition</div>
<div id="portfolio-characteristics-section" class="composition-block">
<div class="section-subtitle">Portfolio Characteristics</div>
<table>
<thead>
<tr>
<th>Metric</th>
<th class="th-a" id="portfolio-char-head-a"></th>
<th class="th-b" id="portfolio-char-head-b"></th>
</tr>
</thead>
<tbody id="portfolio-characteristics-tbody"></tbody>
</table>
</div>
<!-- Asset Allocation -->
<div id="asset-alloc-section" class="composition-block">
<div class="section-subtitle">Asset Allocation</div>
<div id="alloc-chart" class="alloc-chart"></div>
</div>
<div class="composition-group" id="equity-composition-group">
<div class="composition-group-title">Equity Breakdown</div>
<!-- Equity Style Boxes -->
<div id="equity-style-section" class="composition-block">
<div class="section-subtitle">Equity Style Box</div>
<div class="style-box-container">
<div class="style-box-container-header">
<span class="style-box-container-title">Morningstar Style Box</span>
<span class="sb-legend" id="sb-legend-equity"></span>
</div>
<div class="style-box-pair" id="equity-style-pair"></div>
</div>
</div>
<div id="equity-breakdown-section" class="composition-block">
<div class="tabs">
<button class="tab-btn active" data-composition-tab="equity-sectors">Sectors</button>
<button class="tab-btn" data-composition-tab="equity-regions">Regions</button>
</div>
<div class="tab-panel active" id="equity-sectors-panel">
<table><thead id="sectors-thead"></thead><tbody id="sectors-tbody"></tbody></table>
</div>
<div class="tab-panel" id="equity-regions-panel">
<table><thead id="equity-regions-thead"></thead><tbody id="equity-regions-tbody"></tbody></table>
</div>
</div>
</div>
<div class="composition-group" id="fi-composition-group">
<div class="composition-group-title">Fixed Income Breakdown</div>
<!-- Fixed Income Style Boxes -->
<div id="fi-style-section" class="composition-block">
<div class="section-subtitle">Fixed Income Style Box</div>
<div class="style-box-container">
<div class="style-box-container-header">
<span class="style-box-container-title">Credit / Duration Style</span>
<span class="sb-legend" id="sb-legend-fi"></span>
</div>
<div class="style-box-pair" id="fi-style-pair"></div>
</div>
</div>
<div id="fi-breakdown-section" class="composition-block">
<div class="tabs">
<button class="tab-btn active" data-composition-tab="fi-sectors">Sectors</button>
<button class="tab-btn" data-composition-tab="fi-maturity">Maturity</button>
</div>
<div class="tab-panel active" id="fi-sectors-panel">
<table><thead id="fi-sectors-thead"></thead><tbody id="fi-sectors-tbody"></tbody></table>
</div>
<div class="tab-panel" id="fi-maturity-panel">
<table><thead id="fi-maturity-thead"></thead><tbody id="fi-maturity-tbody"></tbody></table>
</div>
</div>
</div>
<!-- Structural interpretation -->
<p class="table-footnote" id="structural-note" style="margin-top:14px;font-size:12px;line-height:1.6;font-style:normal;color:var(--c-text);"></p>
</div>
<!-- ── RETURNS ── -->
<div class="section">
<div class="section-title">Returns</div>
<div class="chart-legend" id="chart-legend"></div>
<div class="chart-wrap" id="growth-chart"></div>
<div class="tabs">
<button class="tab-btn active" data-tab="trailing">Trailing Returns</button>
<button class="tab-btn" data-tab="annual">Annual Returns</button>
</div>
<div class="tab-panel active" id="tab-trailing">
<table>
<thead>
<tr>
<th>Period</th>
<th class="th-a" id="trail-head-a"></th>
<th class="th-b" id="trail-head-b"></th>
<th>Δ (A−B)</th>
</tr>
</thead>
<tbody id="trailing-tbody"></tbody>
</table>
<div class="table-footnote">2Y+ returns are annualized. Parentheses show Morningstar Category percentile rank: 1 is best and 100 is worst; ranks are not directly comparable across different categories. Δ = Fund A minus Fund B in percentage points.</div>
</div>
<div class="tab-panel" id="tab-annual">
<table>
<thead>
<tr>
<th>Year</th>
<th class="th-a" id="ann-head-a"></th>
<th class="th-b" id="ann-head-b"></th>
<th>Δ (A−B)</th>
</tr>
</thead>
<tbody id="annual-tbody"></tbody>
</table>
<div class="table-footnote">Calendar year returns in %. Parentheses show Morningstar Category percentile rank: 1 is best and 100 is worst; ranks are not directly comparable across different categories. Δ = Fund A minus Fund B in percentage points.</div>
</div>
</div>
<!-- ── RISK ── -->
<div class="section" id="risk-section">
<div class="section-title">Risk</div>
<div class="risk-return-wrap" id="risk-return-wrap">
<div class="risk-return-chart" id="risk-return-chart"></div>
<div class="risk-return-footnote" id="risk-return-footnote"></div>
</div>
<div class="tabs risk-period-tabs" id="risk-tabs"></div>
<div id="risk-panels"></div>
<div class="table-footnote" id="risk-table-footnote" style="margin-top:10px;">Capture ratios: 100 = benchmark. Δ = Fund A minus Fund B.</div>
</div>
<!-- ── RATINGS ── -->
<div class="section">
<div class="section-title">Ratings</div>
<div class="stat-group">
<div class="stat-group-header">Ratings</div>
<div class="stat-card-grid" id="ratings-cards"></div>
</div>
</div>
<!-- ── NARRATIVE ── -->
<div class="section">
<div class="section-title">Analyst Research & Summary</div>
<div class="analyst-block">
<div class="fund-label label-a" id="analyst-label-a"></div>
<div id="analyst-text-a"></div>
</div>
<div class="analyst-block">
<div class="fund-label label-b" id="analyst-label-b"></div>
<div id="analyst-text-b"></div>
</div>
<div id="narrative-body"></div>
</div>
</div>
<div class="footer" id="footer-line">
AI-generated analysis using Morningstar's data and research. Not investment advice; for informational use only.
</div>
<script>
// ── Data injected by skill ────────────────────────────────────────────────────
// Replace __DATA_JSON__ with the full comparison data object (see full-workflow.md).
const D = __DATA_JSON__;
// ─────────────────────────────────────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const esc = s => String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
const fmt2 = v => v == null ? '--' : Number(v).toFixed(2);
const fmtPct = v => v == null ? '--' : Number(v).toFixed(2) + '%';
const fmtDelta = v => { if (v == null) return '--'; const n = Number(v); return (n >= 0 ? '+' : '') + n.toFixed(2); };
const deltaCls = v => { if (v == null || Math.abs(Number(v)) < 0.005) return ''; return Number(v) > 0 ? 'val-positive' : 'val-negative'; };
const deltaText = (v, decimals = 2) => v == null ? '--' : `${Number(v) >= 0 ? '+' : ''}${Number(v).toFixed(decimals)}%`;
const displayText = v => String(v ?? '--').replace(/\^[A-Z]+$/g, '').replace(/([a-z])([A-Z])/g, '$1 $2');
const pctWidth = v => v == null ? 0 : Math.max(0, Math.min(100, Number(v)));
let riskReturnAvailablePeriods = new Set();
let setRiskReturnPeriod = () => false;
const deltaPill = (v, decimals = 2) => {
if (v == null) return '--';
return `<span class="delta-pill ${deltaCls(v)}">${deltaText(v, decimals)}</span>`;
};
const tickerLabel = (ticker, name) => `${esc(ticker)} — ${esc(name)}`;
const shortLabel = ticker => esc(ticker);
function hide(id) { const el = $(id); if (el) el.style.display = 'none'; }
// ── Meta / header ─────────────────────────────────────────────────────────────
(function () {
const m = D.meta;
document.title = `Fund Comparison — ${m.ticker_a} vs ${m.ticker_b}`;
$('hdr-name-a').textContent = m.name_a;
$('hdr-name-b').textContent = m.name_b;
$('hdr-ticker-a').textContent = m.ticker_a;
$('hdr-ticker-b').textContent = m.ticker_b;
if (m.cross_category_warning) $('cross-cat-warning').classList.add('visible');
if (m.report_date || m.data_as_of_date) {
$('footer-line').innerHTML =
`AI-generated analysis using Morningstar's data and research. Not investment advice; for informational use only.<br>` +
`Report generated ${esc(m.report_date || '')} | Data as of ${esc(m.data_as_of_date || '')}`;
}
})();
// ── Column headers (shared helper) ───────────────────────────────────────────
function setColHeaders(aId, bId) {
const a = `${esc(D.meta.ticker_a)}`, b = `${esc(D.meta.ticker_b)}`;
if ($(aId)) $(aId).textContent = a;
if ($(bId)) $(bId).textContent = b;
}
['ph-head-a','ph-head-b','sec-head-a','sec-head-b',
'fi-sec-head-a','fi-sec-head-b','trail-head-a','trail-head-b','ann-head-a','ann-head-b'
].forEach((id, i) => {
const el = $(id); if (!el) return;
el.textContent = i % 2 === 0 ? D.meta.ticker_a : D.meta.ticker_b;
});
// ── Overview summary ──────────────────────────────────────────────────────────
(function () {
const tbody = $('profile-tbody');
if (!tbody) return;
const profileRows = D.profile || [];
const costRows = D.cost || [];
const findRows = (rows, labels) => labels
.map(label => rows.find(r => String(r.label || r.metric || '').toLowerCase() === label.toLowerCase()))
.filter(Boolean);
const groups = D.overview_groups || [
{ group: 'Fund Identity', rows: findRows(profileRows, ['Name', 'Type', 'Base Currency', 'Ticker']) },
{ group: 'Structure & Access', rows: findRows(profileRows, ['Active / Index', 'Structure', 'Fund Size', 'Inception Date', 'Min. Investment']) },
{ group: 'Management', rows: findRows(profileRows, ['Number of Managers', 'Average Tenure', 'Longest Tenure', 'Investment Advisor']) },
{ group: 'Classification & Benchmark', rows: findRows(profileRows, ['Category', 'Morningstar Category', 'Prospectus Benchmark']) },
{ group: 'Cost & Income', rows: costRows },
].filter(g => g.rows && g.rows.length);
if (!groups.length) { hide('overview-section'); return; }
const isOverviewRowHidden = r =>
String(r.label || r.metric || '').toLowerCase() === 'mpt benchmark index';
const visibleGroups = groups
.map(group => ({ ...group, rows: group.rows.filter(r => !isOverviewRowHidden(r)) }))
.filter(group => group.rows.length);
if (!visibleGroups.length) { hide('overview-section'); return; }
tbody.innerHTML = visibleGroups.map(group => {
const rows = group.rows.map(r => {
const label = r.label || r.metric || '';
return `<tr><td>${esc(label)}</td><td>${esc(r.a ?? '--')}</td><td>${esc(r.b ?? '--')}</td></tr>`;
}).join('');
return `<tr class="overview-group-row"><td colspan="3">${esc(group.group)}</td></tr>${rows}`;
}).join('');
})();
// ── Asset allocation bar chart ────────────────────────────────────────────────
(function () {
if (!D.asset_allocation || !D.asset_allocation.length) { hide('asset-alloc-section'); return; }
const container = $('alloc-chart');
if (!container) return;
const tA = esc(D.meta.ticker_a), tB = esc(D.meta.ticker_b);
let html = `<div class="alloc-legend">
<span class="alloc-legend-item"><span class="alloc-legend-swatch ls-a"></span>${tA}</span>
<span class="alloc-legend-item"><span class="alloc-legend-swatch ls-b"></span>${tB}</span>
</div>`;
D.asset_allocation.forEach(r => {
const aVal = r.a != null ? Number(r.a) : null;
const bVal = r.b != null ? Number(r.b) : null;
const delta = aVal != null && bVal != null ? aVal - bVal : null;
const deltaStr = delta != null ? (delta >= 0 ? '+' : '') + delta.toFixed(1) : '';
const dCls = delta != null ? (Math.abs(delta) < 0.05 ? '' : delta > 0 ? 'val-positive' : 'val-negative') : '';
html += `<div class="alloc-row">
<div class="alloc-label">${esc(r.label)}</div>
<div class="alloc-bars">
<div class="alloc-bar-track">
<span class="alloc-fund-tag tag-a">${tA}</span>
<div class="alloc-bar-outer"><div class="alloc-bar-fill fill-a" style="width:${pctWidth(aVal)}%"></div></div>
<span class="alloc-bar-pct">${aVal != null ? aVal.toFixed(1) + '%' : '--'}</span>
<span class="alloc-bar-delta ${dCls}">${deltaStr ? 'Δ' + deltaStr : ''}</span>
</div>
<div class="alloc-bar-track">
<span class="alloc-fund-tag tag-b">${tB}</span>
<div class="alloc-bar-outer"><div class="alloc-bar-fill fill-b" style="width:${pctWidth(bVal)}%"></div></div>
<span class="alloc-bar-pct">${bVal != null ? bVal.toFixed(1) + '%' : '--'}</span>
<span class="alloc-bar-delta"></span>
</div>
</div>
</div>`;
});
container.innerHTML = html;
})();
// ── Portfolio characteristics ────────────────────────────────────────────────
(function () {
const rows = D.portfolio_characteristics || [];
if (!rows.length) { hide('portfolio-characteristics-section'); return; }
const tbody = $('portfolio-characteristics-tbody');
if (!tbody) return;
$('portfolio-char-head-a').textContent = D.meta.ticker_a;
$('portfolio-char-head-b').textContent = D.meta.ticker_b;
tbody.innerHTML = rows.map(row =>
`<tr><td>${esc(row.label)}</td><td>${esc(row.a ?? '--')}</td><td>${esc(row.b ?? '--')}</td></tr>`
).join('');
})();
// ── Style box builder (heat-map tiers) ────────────────────────────────────────
function sbTierCls(v, fund) {
if (v == null) return '';
const n = Number(v);
const tier = n >= 50 ? 4 : n >= 25 ? 3 : n >= 10 ? 2 : 1;
return `sb-${fund}-${tier}`;
}
function buildStyleBoxLegend(fund) {
const tiers = [
{ label: '50%+', tier: 4 },
{ label: '25–49%', tier: 3 },
{ label: '10–24%', tier: 2 },
{ label: '0–9%', tier: 1 },
];
return tiers.map(t =>
`<span class="sb-legend-item">` +
`<span class="sb-legend-swatch sb-${fund}-${t.tier}"></span>${t.label}` +
`</span>`
).join('');
}
function buildStyleBox(data, fund, fundLabel, labelCls) {
if (!data) return '';
const rows = ['large','mid','small'];
const cols = ['value','blend','growth'];
const rowLabels = ['Large','Mid','Small'];
const colLabels = ['Value','Blend','Growth'];
let html = `<div class="style-box-wrap">`;
html += `<div class="style-box-fund-label ${labelCls}">${esc(fundLabel)}</div>`;
html += `<table class="style-box"><thead><tr><th></th>`;
colLabels.forEach(l => html += `<th>${l}</th>`);
html += `</tr></thead><tbody>`;
rows.forEach((r, ri) => {
html += `<tr><td>${rowLabels[ri]}</td>`;
cols.forEach(c => {
const v = data[`${r}_${c}`];
const cls = sbTierCls(v, fund);
html += `<td class="${cls}">${v != null ? Math.round(Number(v)) : '--'}</td>`;
});
html += `</tr>`;
});
html += `</tbody></table>`;
if (data.portfolio_analyzed != null && Number(data.portfolio_analyzed) < 95) {
html += `<div class="table-footnote">Covers ${Number(data.portfolio_analyzed).toFixed(0)}% of portfolio.</div>`;
}
html += `</div>`;
return html;
}
function styleValue(data, keys) {
for (const key of keys) {
if (data[key] != null) return data[key];
}
return null;
}
function buildFixedIncomeStyleBox(data, fund, fundLabel, labelCls) {
if (!data) return '';
const rows = [
{ label: 'High', keys: ['high'] },
{ label: 'Med', keys: ['medium', 'med'] },
{ label: 'Low', keys: ['low'] },
];
const cols = [
{ label: 'Ltd', keys: ['limited', 'ltd'] },
{ label: 'Mod', keys: ['moderate', 'mod'] },
{ label: 'Ext', keys: ['extensive', 'ext'] },
];
let html = `<div class="style-box-wrap">`;
html += `<div class="style-box-fund-label ${labelCls}">${esc(fundLabel)}</div>`;
html += `<table class="style-box"><thead><tr><th></th>`;
cols.forEach(c => html += `<th>${c.label}</th>`);
html += `</tr></thead><tbody>`;
rows.forEach(row => {
html += `<tr><td>${row.label}</td>`;
cols.forEach(col => {
const keys = [];
col.keys.forEach(c => row.keys.forEach(r => keys.push(`${c}_${r}`, `${r}_${c}`)));
const v = styleValue(data, keys);
const cls = sbTierCls(v, fund);
html += `<td class="${cls}">${v != null ? Math.round(Number(v)) : '--'}</td>`;
});
html += `</tr>`;
});
html += `</tbody></table>`;
if (data.portfolio_analyzed != null && Number(data.portfolio_analyzed) < 95) {
html += `<div class="table-footnote">Covers ${Number(data.portfolio_analyzed).toFixed(0)}% of portfolio.</div>`;
}
html += `</div>`;
return html;
}
// ── Equity style boxes ────────────────────────────────────────────────────────
(function () {
const hasA = D.equity_style_a != null, hasB = D.equity_style_b != null;
if (!hasA && !hasB) { hide('equity-style-section'); return; }
const pair = $('equity-style-pair');
if (!pair) return;
pair.classList.toggle('single', hasA !== hasB);
pair.innerHTML =
buildStyleBox(D.equity_style_a, 'a', `${D.meta.ticker_a} — ${D.meta.name_a}`, 'label-a') +
buildStyleBox(D.equity_style_b, 'b', `${D.meta.ticker_b} — ${D.meta.name_b}`, 'label-b');
const legend = $('sb-legend-equity');
if (legend) legend.innerHTML = buildStyleBoxLegend(hasA ? 'a' : 'b');
})();
// ── Sector bar-cell helper ────────────────────────────────────────────────────
function sectorBarCell(val, fund, maxPct) {
if (val == null) return `<td class="bar-cell"><div class="bar-cell-track"><span class="bar-cell-text">--</span></div></td>`;
const pct = (Number(val) / maxPct) * 100;
return `<td class="bar-cell">
<div class="bar-cell-track">
<div class="bar-cell-fill bc-${fund}" style="width:${pct.toFixed(1)}%"></div>
<span class="bar-cell-text">${Number(val).toFixed(1)}%</span>
</div>
</td>`;
}
function sectorDeltaCell(delta) {
if (delta == null) return '<td>--</td>';
const material = Math.abs(delta) >= 10;
return `<td class="${deltaCls(delta)}">${material ? deltaPill(delta, 1) : deltaText(delta, 1)}</td>`;
}
function returnDeltaThreshold() { return 5; }
function returnDeltaCell(delta, period, threshold = returnDeltaThreshold(period)) {
if (delta == null) return '<td>--</td>';
const material = Math.abs(delta) >= threshold;
return `<td class="${deltaCls(delta)}">${material ? deltaPill(delta, 2) : deltaText(delta, 2)}</td>`;
}
const EQUITY_SECTOR_ORDER = [
['Cyclical', 'Basic Materials'],
['Cyclical', 'Consumer Cyclical'],
['Cyclical', 'Financial Services'],
['Cyclical', 'Real Estate'],
['Sensitive', 'Communication Services'],
['Sensitive', 'Energy'],
['Sensitive', 'Industrials'],
['Sensitive', 'Technology'],
['Defensive', 'Consumer Defensive'],
['Defensive', 'Healthcare'],
['Defensive', 'Utilities'],
];
const FI_SECTOR_ORDER = [
'Government',
'Municipal',
'Corporate',
'Securitized',
'Cash & Equivalents',
'Derivative',
];
function normalizeOrderKey(value) {
const key = String(value || '')
.toLowerCase()
.replace(/&/g, 'and')
.replace(/\bcomm\.?\b/g, 'communication')
.replace(/[^a-z0-9]+/g, '');
if (key === 'derivatives') return 'derivative';
if (key === 'municipals') return 'municipal';
return key;
}
function orderMapFromPairs(pairs) {
const map = new Map();
pairs.forEach(([group, sector], i) => {
map.set(`${normalizeOrderKey(group)}|${normalizeOrderKey(sector)}`, i);
});
return map;
}
function orderMapFromLabels(labels) {
const map = new Map();
labels.forEach((label, i) => map.set(normalizeOrderKey(label), i));
return map;
}
const EQUITY_SECTOR_ORDER_MAP = orderMapFromPairs(EQUITY_SECTOR_ORDER);
const FI_SECTOR_ORDER_MAP = orderMapFromLabels(FI_SECTOR_ORDER);
function orderedSectorRows(rows, orderType) {
const orderMap = orderType === 'equity' ? EQUITY_SECTOR_ORDER_MAP : FI_SECTOR_ORDER_MAP;
return [...rows].sort((left, right) => {
const leftKey = orderType === 'equity'
? `${normalizeOrderKey(left.group)}|${normalizeOrderKey(left.sector)}`
: normalizeOrderKey(left.sector);
const rightKey = orderType === 'equity'
? `${normalizeOrderKey(right.group)}|${normalizeOrderKey(right.sector)}`
: normalizeOrderKey(right.sector);
const leftOrder = orderMap.has(leftKey) ? orderMap.get(leftKey) : Number.MAX_SAFE_INTEGER;
const rightOrder = orderMap.has(rightKey) ? orderMap.get(rightKey) : Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
return String(left.sector || '').localeCompare(String(right.sector || ''));
});
}
function renderSectorTable(rows, sectionId, theadId, tbodyId, orderType = null, rowLabel = 'Sector') {
if (!rows || !rows.length) { hide(sectionId); return; }
const thead = $(theadId), tbody = $(tbodyId);
if (!thead || !tbody) return;
const orderedRows = orderType ? orderedSectorRows(rows, orderType) : rows;
const hasA = orderedRows.some(r => r.a != null);
const hasB = orderedRows.some(r => r.b != null);
if (!hasA && !hasB) { hide(sectionId); return; }
const both = hasA && hasB;
const colSpan = 1 + (hasA ? 1 : 0) + (hasB ? 1 : 0) + (both ? 1 : 0);
thead.innerHTML = `<tr>
<th>${esc(rowLabel)}</th>
${hasA ? `<th class="th-a">${esc(D.meta.ticker_a)}</th>` : ''}
${hasB ? `<th class="th-b">${esc(D.meta.ticker_b)}</th>` : ''}
${both ? '<th>Δ (A−B)</th>' : ''}
</tr>`;
const maxPct = Math.max(...orderedRows.map(r => Math.max(hasA ? r.a ?? 0 : 0, hasB ? r.b ?? 0 : 0)), 1);
let currentGroup = null, html = '';
orderedRows.forEach(row => {
if (row.group && row.group !== currentGroup) {
currentGroup = row.group;
html += `<tr class="sector-group-row"><td colspan="${colSpan}">${esc(row.group)}</td></tr>`;
}
const aVal = row.a != null ? Number(row.a) : null;
const bVal = row.b != null ? Number(row.b) : null;
const delta = aVal != null && bVal != null ? aVal - bVal : null;
const material = both && delta != null && Math.abs(delta) >= 10;
html += `<tr class="${material ? 'sector-material-row' : ''}">
<td>${esc(row.sector)}</td>
${hasA ? sectorBarCell(row.a, 'a', maxPct) : ''}
${hasB ? sectorBarCell(row.b, 'b', maxPct) : ''}
${both ? sectorDeltaCell(delta) : ''}
</tr>`;
});
tbody.innerHTML = html;
}
// ── Equity breakdown ──────────────────────────────────────────────────────────
(function () {
renderSectorTable(D.equity_sectors, 'equity-sectors-panel', 'sectors-thead', 'sectors-tbody', 'equity');
renderSectorTable(D.equity_regions, 'equity-regions-panel', 'equity-regions-thead', 'equity-regions-tbody', null, 'Region');
})();
// ── Fixed income style boxes ──────────────────────────────────────────────────
(function () {
const hasA = D.fi_style_a != null, hasB = D.fi_style_b != null;
if (!hasA && !hasB) { hide('fi-style-section'); return; }
const pair = $('fi-style-pair');
if (!pair) return;
pair.classList.toggle('single', hasA !== hasB);
pair.innerHTML =
buildFixedIncomeStyleBox(D.fi_style_a, 'a', `${D.meta.ticker_a} — ${D.meta.name_a}`, 'label-a') +
buildFixedIncomeStyleBox(D.fi_style_b, 'b', `${D.meta.ticker_b} — ${D.meta.name_b}`, 'label-b');
const legend = $('sb-legend-fi');
if (legend) legend.innerHTML = buildStyleBoxLegend(hasA ? 'a' : 'b');
})();
// ── Fixed income breakdown ────────────────────────────────────────────────────
(function () {
renderSectorTable(D.fi_sectors, 'fi-sectors-panel', 'fi-sectors-thead', 'fi-sectors-tbody', 'fi');
renderSectorTable(D.fi_maturity, 'fi-maturity-panel', 'fi-maturity-thead', 'fi-maturity-tbody', null, 'Maturity');
})();
function setupCompositionTabs(sectionId) {
const section = $(sectionId);
if (!section) return;
const buttons = [...section.querySelectorAll('[data-composition-tab]')];
const visibleButtons = buttons.filter(button => {
const panel = $(button.dataset.compositionTab + '-panel');
const visible = panel && panel.style.display !== 'none';
button.style.display = visible ? '' : 'none';
return visible;
});
if (!visibleButtons.length) { hide(sectionId); return; }
const activate = button => {
visibleButtons.forEach(candidate => {
candidate.classList.toggle('active', candidate === button);
const panel = $(candidate.dataset.compositionTab + '-panel');
if (panel) panel.classList.toggle('active', candidate === button);
});
};
activate(visibleButtons[0]);
visibleButtons.forEach(button => button.addEventListener('click', () => activate(button)));
}
setupCompositionTabs('equity-breakdown-section');
setupCompositionTabs('fi-breakdown-section');
function hideCompositionGroup(groupId, childIds) {
const group = $(groupId);
if (!group) return;
const hasVisibleChild = childIds.some(id => {
const child = $(id);
return child && child.style.display !== 'none';
});
if (!hasVisibleChild) group.style.display = 'none';
}
hideCompositionGroup('equity-composition-group', ['equity-style-section', 'equity-breakdown-section']);
hideCompositionGroup('fi-composition-group', ['fi-style-section', 'fi-breakdown-section']);
// ── Structural note ───────────────────────────────────────────────────────────
(function () {
const el = $('structural-note');
if (el && D.structural_note) el.textContent = D.structural_note;
else if (el) el.style.display = 'none';
})();
// ── Growth of $10K chart ──────────────────────────────────────────────────────
(function () {
const chart = D.growth_chart;
if (!chart) { hide('growth-chart'); hide('chart-legend'); return; }
const keys = Object.keys(chart);
const dateKey = keys[0], keyA = keys[1], keyB = keys[2] || null;
const C_A = '#2364B9', C_B = '#B2013C', C_GRID = '#DAD9D8', C_BODY = '#3D3B39', C_AXIS = '#A6A4A1';
// Legend
const legend = $('chart-legend');
[[keyA, C_A], keyB ? [keyB, C_B] : null].filter(Boolean).forEach(([label, color]) => {
const item = document.createElement('div');
item.className = 'legend-item';
item.innerHTML = `<div class="legend-swatch" style="background:${color}"></div><span>${esc(label)}</span>`;
legend.appendChild(item);
});
function niceCeiling(v) {
if (v <= 0) return 1;
const exp = Math.pow(10, Math.floor(Math.log10(v)));
const frac = v / exp;
for (const s of [1,1.2,1.5,2,2.5,3,4,5,6,8,10]) if (frac <= s) return s * exp;
return 10 * exp;
}
function fmtDate(val) {
try { const d = new Date(val + 'T00:00:00Z'); return d.toLocaleDateString('en-US', {month:'short', year:'2-digit', timeZone:'UTC'}); }
catch(_) { return String(val); }
}
function fmtDollar(v) { return '$' + v.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2}); }
function escXml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
const dates = chart[dateKey], seriesA = chart[keyA] || [], seriesB = keyB ? (chart[keyB] || []) : [];
const rows = dates.map((d, i) => ({
date: d,
a: typeof seriesA[i] === 'number' ? seriesA[i] : null,
b: keyB && typeof seriesB[i] === 'number' ? seriesB[i] : null,
}));
const W = 700, H = 268, PT = 16, PR = 16, PB = 36, PL = 76;
const PW = W - PL - PR, PH = H - PT - PB;
const allVals = rows.flatMap(r => [r.a, r.b]).filter(v => typeof v === 'number');
if (!allVals.length) return;
const yMinRaw = Math.min(...allVals), yMaxRaw = Math.max(...allVals);
const yPad = (yMaxRaw - yMinRaw || Math.abs(yMaxRaw) || 1) * 0.04;
const yMin = yMinRaw >= 0 ? 0 : yMinRaw - yPad;
const yMax = niceCeiling(yMaxRaw + yPad);
const n = rows.length;
const xFor = i => n < 2 ? PL + PW / 2 : PL + (i / (n - 1)) * PW;
const yFor = v => PT + (1 - (v - yMin) / (yMax - yMin)) * PH;
const colW = n > 1 ? PW / (n - 1) : PW;
const p = [];
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" font-family="inherit">`);
p.push(`<defs><filter id="ts" x="-20%" y="-20%" width="140%" height="140%"><feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#3D3B39" flood-opacity="0.12"/></filter></defs>`);
for (let i = 0; i < 5; i++) {
const tv = yMin + (yMax - yMin) * i / 4, ty = yFor(tv).toFixed(1);
const isZero = Math.abs(tv) < 1e-6;
p.push(`<line x1="${PL}" y1="${ty}" x2="${W-PR}" y2="${ty}" stroke="${isZero ? C_AXIS : C_GRID}"${isZero ? '' : ' stroke-dasharray="2,4"'}/>`);
p.push(`<text x="${PL-8}" y="${ty}" text-anchor="end" dominant-baseline="middle" fill="${C_BODY}" font-size="11">${escXml(fmtDollar(tv))}</text>`);
}
p.push(`<line x1="${PL}" y1="${PT}" x2="${PL}" y2="${PT+PH}" stroke="${C_AXIS}" stroke-width="1"/>`);
p.push(`<text transform="rotate(-90)" x="${-(PT+PH/2).toFixed(1)}" y="12" text-anchor="middle" fill="${C_BODY}" font-size="10">Growth of $10,000</text>`);
const maxLbls = Math.min(8, n), lblEvery = Math.max(1, Math.ceil(n / maxLbls));
for (let i = 0; i < n; i++) {
if (i % lblEvery !== 0 && i !== n - 1) continue;
p.push(`<text x="${xFor(i).toFixed(1)}" y="${H-10}" text-anchor="middle" fill="${C_BODY}" font-size="11">${escXml(fmtDate(rows[i].date))}</text>`);
}
const ptA = rows.map((r,i) => r.a != null ? `${xFor(i).toFixed(1)},${yFor(r.a).toFixed(1)}` : null).filter(Boolean);
if (ptA.length) p.push(`<polyline points="${ptA.join(' ')}" fill="none" stroke="${C_A}" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>`);
const ptB = rows.map((r,i) => r.b != null ? `${xFor(i).toFixed(1)},${yFor(r.b).toFixed(1)}` : null).filter(Boolean);
if (ptB.length) p.push(`<polyline points="${ptB.join(' ')}" fill="none" stroke="${C_B}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`);
for (let i = 0; i < n; i++) {
const r = rows[i], cx = xFor(i), tipW = 170, tipH = r.b != null ? 58 : 42;
const anchorRight = cx + tipW + 16 > W - PR;
const tipX = (anchorRight ? cx - tipW - 10 : cx + 10).toFixed(1);
const refY = r.a != null ? yFor(r.a) : (r.b != null ? yFor(r.b) : PT + PH / 2);
const tipY = Math.max(PT+4, Math.min(refY - tipH/2, PT+PH-tipH-4)).toFixed(1);
p.push(`<g class="chart-point">`);
p.push(`<rect class="hover-target" x="${(cx-colW/2).toFixed(1)}" y="${PT}" width="${colW.toFixed(1)}" height="${PH}" fill="transparent"/>`);
p.push(`<line class="tick-line" x1="${cx.toFixed(1)}" y1="${PT}" x2="${cx.toFixed(1)}" y2="${PT+PH}" stroke="${C_GRID}" stroke-dasharray="3,4"/>`);
if (r.a != null) p.push(`<circle class="data-dot" cx="${cx.toFixed(1)}" cy="${yFor(r.a).toFixed(1)}" r="4" fill="${C_A}" stroke="#fff" stroke-width="1.5"/>`);
if (r.b != null) p.push(`<circle class="data-dot" cx="${cx.toFixed(1)}" cy="${yFor(r.b).toFixed(1)}" r="4" fill="${C_B}" stroke="#fff" stroke-width="1.5"/>`);
p.push(`<g class="chart-tooltip-box">`);
p.push(`<rect x="${tipX}" y="${tipY}" width="${tipW}" height="${tipH}" rx="4" ry="4" fill="#fff" stroke="${C_GRID}" stroke-width="1" filter="url(#ts)"/>`);
p.push(`<text x="${parseFloat(tipX)+10}" y="${parseFloat(tipY)+16}" fill="${C_BODY}" font-size="11" font-weight="700">${escXml(fmtDate(r.date))}</text>`);
if (r.a != null) {
const ry = parseFloat(tipY)+32;
p.push(`<line x1="${parseFloat(tipX)+10}" y1="${ry-3}" x2="${parseFloat(tipX)+21}" y2="${ry-3}" stroke="${C_A}" stroke-width="2.5" stroke-linecap="round"/>`);
p.push(`<text x="${parseFloat(tipX)+27}" y="${ry}" fill="${C_BODY}" font-size="10">${escXml(keyA)}</text>`);
p.push(`<text x="${parseFloat(tipX)+tipW-8}" y="${ry}" text-anchor="end" fill="${C_BODY}" font-size="10" font-variant-numeric="tabular-nums">${escXml(fmtDollar(r.a))}</text>`);
}
if (r.b != null) {
const ry = parseFloat(tipY)+(r.a != null ? 50 : 32);
p.push(`<line x1="${parseFloat(tipX)+10}" y1="${ry-3}" x2="${parseFloat(tipX)+21}" y2="${ry-3}" stroke="${C_B}" stroke-width="2.5" stroke-linecap="round"/>`);
p.push(`<text x="${parseFloat(tipX)+27}" y="${ry}" fill="${C_BODY}" font-size="10">${escXml(keyB)}</text>`);
p.push(`<text x="${parseFloat(tipX)+tipW-8}" y="${ry}" text-anchor="end" fill="${C_BODY}" font-size="10" font-variant-numeric="tabular-nums">${escXml(fmtDollar(r.b))}</text>`);
}
p.push(`</g></g>`);
}
p.push('</svg>');
$('growth-chart').innerHTML = p.join('\n');
})();
// ── Trailing returns ──────────────────────────────────────────────────────────
(function () {
if (!D.trailing_returns) return;
const rankByPeriod = new Map((D.category_rankings?.trailing || []).map(row => [row.period, row]));
const withRank = (value, rank) => value == null
? '--'
: `${Number(value).toFixed(2)}%${rank != null ? ` (${Math.round(Number(rank))})` : ''}`;
$('trailing-tbody').innerHTML = D.trailing_returns.map(r => {
const a = r.a != null ? Number(r.a) : null, b = r.b != null ? Number(r.b) : null;
const d = a != null && b != null ? a - b : null;
const rank = rankByPeriod.get(r.period) || {};
return `<tr>
<td>${esc(r.period)}</td>
<td>${withRank(a, rank.a)}</td>
<td>${withRank(b, rank.b)}</td>
${returnDeltaCell(d, r.period)}
</tr>`;
}).join('');
})();
// ── Annual returns ────────────────────────────────────────────────────────────
(function () {
if (!D.annual_returns) return;
const rankByYear = new Map((D.category_rankings?.annual || []).map(row => [row.year, row]));
const withRank = (value, rank) => value == null
? '--'
: `${Number(value).toFixed(2)}%${rank != null ? ` (${Math.round(Number(rank))})` : ''}`;
$('annual-tbody').innerHTML = D.annual_returns.map(r => {
const a = r.a != null ? Number(r.a) : null, b = r.b != null ? Number(r.b) : null;
const d = a != null && b != null ? a - b : null;
const rank = rankByYear.get(r.year) || {};
return `<tr>
<td>${esc(r.year)}</td>
<td>${withRank(a, rank.a)}</td>
<td>${withRank(b, rank.b)}</td>
${returnDeltaCell(d, r.year, 5)}
</tr>`;
}).join('');
})();
// ── Risk / return scatter ────────────────────────────────────────────────────
(function () {
const wrap = $('risk-return-wrap'), chartEl = $('risk-return-chart');
const note = $('risk-return-footnote');
if (!wrap || !chartEl) return;
const rows = Array.isArray(D.risk_return) ? D.risk_return : [];
const periodKey = period => String(period || '').toLowerCase().replace(/\s+/g, '').replace('yr', 'y');
const usable = rows.map(row => {
const a = row.a_return != null && row.a_risk != null
? { fund: 'a', ticker: D.meta.ticker_a, name: D.meta.name_a, ret: Number(row.a_return), risk: Number(row.a_risk) }
: null;
const b = row.b_return != null && row.b_risk != null
? { fund: 'b', ticker: D.meta.ticker_b, name: D.meta.name_b, ret: Number(row.b_return), risk: Number(row.b_risk) }
: null;
const points = [a, b].filter(p => p && Number.isFinite(p.ret) && Number.isFinite(p.risk));
return { period: row.period || '', key: periodKey(row.period), points };
}).filter(row => row.points.length);
if (!usable.length) { hide('risk-return-wrap'); return; }
const byPeriod = new Map(usable.map(row => [row.key, row]));
riskReturnAvailablePeriods = new Set(byPeriod.keys());
function renderRow(row) {
const points = row.points;
const W = 700, H = 250, PT = 20, PR = 130, PB = 42, PL = 66;
const PW = W - PL - PR, PH = H - PT - PB;
const C_A = '#2364B9', C_B = '#B2013C', C_GRID = '#DAD9D8', C_BODY = '#3D3B39', C_AXIS = '#A6A4A1';
const riskVals = points.map(p => p.risk), returnVals = points.map(p => p.ret);
function niceStep(max) {
const raw = max / 4 || 1;
const exp = Math.pow(10, Math.floor(Math.log10(raw)));
const frac = raw / exp;
const mult = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10;
return mult * exp;
}
function axisBounds(vals, floorAtZero = true) {
const minRaw = Math.min(...vals), maxRaw = Math.max(...vals);
const span = maxRaw - minRaw || Math.abs(maxRaw) || 1;
const paddedMin = floorAtZero ? Math.max(0, minRaw - span * 0.25) : minRaw - span * 0.25;
const paddedMax = maxRaw + span * 0.25;
const step = niceStep(paddedMax - paddedMin);
const min = floorAtZero ? Math.max(0, Math.floor(paddedMin / step) * step) : Math.floor(paddedMin / step) * step;
const max = Math.ceil(paddedMax / step) * step;
return { min, max: max > min ? max : min + step };
}
const x = axisBounds(riskVals, true), y = axisBounds(returnVals, false);
const xFor = v => PL + ((v - x.min) / (x.max - x.min)) * PW;
const yFor = v => PT + (1 - ((v - y.min) / (y.max - y.min))) * PH;
const fmt = v => Number(v).toFixed(2);
const escXml = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
const p = [];
p.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" font-family="inherit">`);
p.push(`<text x="${PL}" y="14" fill="${C_BODY}" font-size="13" font-weight="700">Risk/Return Analysis${row.period ? ` (${escXml(row.period)})` : ''}</text>`);
for (let i = 0; i <= 4; i++) {
const xv = x.min + (x.max - x.min) * i / 4, xp = xFor(xv).toFixed(1);
p.push(`<line x1="${xp}" y1="${PT}" x2="${xp}" y2="${PT+PH}" stroke="${C_GRID}"/>`);
p.push(`<text x="${xp}" y="${H-18}" text-anchor="middle" fill="${C_BODY}" font-size="10">${fmt(xv)}</text>`);
}
for (let i = 0; i <= 4; i++) {
const yv = y.min + (y.max - y.min) * i / 4, yp = yFor(yv).toFixed(1);
p.push(`<line x1="${PL}" y1="${yp}" x2="${PL+PW}" y2="${yp}" stroke="${C_GRID}"/>`);
p.push(`<text x="${PL-8}" y="${yp}" text-anchor="end" dominant-baseline="middle" fill="${C_BODY}" font-size="10">${fmt(yv)}</text>`);
}
p.push(`<line x1="${PL}" y1="${PT+PH}" x2="${PL+PW}" y2="${PT+PH}" stroke="${C_AXIS}" stroke-width="1.2"/>`);
p.push(`<line x1="${PL}" y1="${PT}" x2="${PL}" y2="${PT+PH}" stroke="${C_AXIS}" stroke-width="1.2"/>`);
p.push(`<text x="${PL+PW/2}" y="${H-3}" text-anchor="middle" fill="${C_BODY}" font-size="10">Standard Deviation %</text>`);
p.push(`<text transform="rotate(-90)" x="${-(PT+PH/2).toFixed(1)}" y="14" text-anchor="middle" fill="${C_BODY}" font-size="10">Total Return %</text>`);
points.forEach(point => {
const color = point.fund === 'a' ? C_A : C_B;
const cx = xFor(point.risk).toFixed(1), cy = yFor(point.ret).toFixed(1);
p.push(`<circle cx="${cx}" cy="${cy}" r="6" fill="${color}" stroke="#fff" stroke-width="1.5"/>`);
p.push(`<text x="${Number(cx)+10}" y="${Number(cy)+4}" fill="${C_BODY}" font-size="10" font-weight="700">${escXml(point.ticker)}</text>`);
});
const legendX = PL + PW + 14;
points.forEach((point, i) => {
const color = point.fund === 'a' ? C_A : C_B;
const ly = PT + 20 + i * 22;
p.push(`<circle cx="${legendX}" cy="${ly}" r="5" fill="${color}"/>`);
p.push(`<text x="${legendX+12}" y="${ly+4}" fill="${C_BODY}" font-size="10">${escXml(point.ticker)}</text>`);
});
p.push('</svg>');
chartEl.innerHTML = p.join('\n');
if (note) note.textContent = `${row.period} month-end annualized total return versus standard deviation.`;
}
setRiskReturnPeriod = period => {
const row = byPeriod.get(periodKey(period));
if (!row) {
chartEl.innerHTML = '';
if (note) note.textContent = `${period === '1y' ? '1 Yr' : period} risk/return scatter is unavailable.`;
return false;
}
renderRow(row);
return true;
};
renderRow(usable[0]);
})();
// ── Risk (period tabs) ────────────────────────────────────────────────────────
(function () {
if (!D.risk) return;
const tabsEl = $('risk-tabs'), panelsEl = $('risk-panels');
if (!tabsEl || !panelsEl) return;
const tA = esc(D.meta.ticker_a), tB = esc(D.meta.ticker_b);
const PERIOD_LABELS = {'1y':'1 Yr', '3y':'3 Yr', '5y':'5 Yr', '10y':'10 Yr'};
const PERIOD_ORDER = ['1y','3y','5y','10y'];
const RISK_METRICS = ['Std Dev', 'Sharpe Ratio', 'Upside Capture', 'Downside Capture'];
const available = PERIOD_ORDER.filter(p => D.risk[p] && D.risk[p].length);
if (!available.length) {
hide('risk-tabs');
hide('risk-panels');
hide('risk-table-footnote');
if ($('risk-return-wrap')?.style.display === 'none') hide('risk-section');
return;
}
const sharedIndex = available.findIndex(period => riskReturnAvailablePeriods.has(period));
const initialIndex = sharedIndex >= 0 ? sharedIndex : 0;
tabsEl.innerHTML = available.map((p, i) =>
`<button class="tab-btn${i === initialIndex ? ' active' : ''}" data-period="${p}">${PERIOD_LABELS[p] || p}</button>`
).join('');
panelsEl.innerHTML = available.map((p, i) => {
const byMetric = new Map();
D.risk[p].forEach(r => byMetric.set(String(r.metric || '').toLowerCase(), r));
const rows = RISK_METRICS.map(metric => {
const r = byMetric.get(metric.toLowerCase()) || { metric, a: null, b: null };
const a = r.a != null ? Number(r.a) : null, b = r.b != null ? Number(r.b) : null;
const d = a != null && b != null ? a - b : null;
return `<tr>
<td>${esc(metric)}</td>
<td class="th-a" style="color:var(--c-fund-a)">${a != null ? a.toFixed(2) : '--'}</td>
<td class="th-b" style="color:var(--c-fund-b)">${b != null ? b.toFixed(2) : '--'}</td>
<td class="${deltaCls(d)}">${d != null ? (d >= 0 ? '+' : '') + d.toFixed(2) : '--'}</td>
</tr>`;
}).join('');
return `<div class="risk-panel${i === initialIndex ? ' active' : ''}" id="risk-panel-${p}">
<table>
<thead><tr><th>Metric</th><th>${tA}</th><th>${tB}</th><th>Δ (A−B)</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}).join('');
setRiskReturnPeriod(available[initialIndex]);
tabsEl.addEventListener('click', e => {
const btn = e.target.closest('[data-period]'); if (!btn) return;
tabsEl.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
panelsEl.querySelectorAll('.risk-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
const panel = $('risk-panel-' + btn.dataset.period);
if (panel) panel.classList.add('active');
setRiskReturnPeriod(btn.dataset.period);
});
})();
// ── Stat card builder ─────────────────────────────────────────────────────────
function buildStatCards(rows, containerId) {
const el = $(containerId); if (!el || !rows) return;
const tA = esc(D.meta.ticker_a), tB = esc(D.meta.ticker_b);
el.innerHTML = rows.map(r =>
`<div class="stat-card">
<div class="stat-card-label">${esc(r.metric)}</div>
<div class="stat-card-vals">
<div class="stat-card-val">
<span class="stat-card-ticker sct-a">${tA}</span>
<span class="stat-card-num">${esc(displayText(r.a))}</span>
</div>
<div class="stat-card-val">
<span class="stat-card-ticker sct-b">${tB}</span>
<span class="stat-card-num">${esc(displayText(r.b))}</span>
</div>
</div>
</div>`
).join('');
}
// ── Ratings ───────────────────────────────────────────────────────────────────
(function () { buildStatCards(D.ratings, 'ratings-cards'); })();
// ── Narrative ─────────────────────────────────────────────────────────────────
(function () {
const m = D.meta;
$('analyst-label-a').textContent = `${m.ticker_a} — ${m.name_a}`;
$('analyst-label-b').textContent = `${m.ticker_b} — ${m.name_b}`;
$('analyst-text-a').textContent = D.analyst_a || '';
$('analyst-text-b').textContent = D.analyst_b || '';
const body = $('narrative-body');
const narrativeSections = [
['narrative_p1', 'Mandate & Structure'],
['narrative_p2', 'Performance & Risk'],
['narrative_p3', 'Cost & Quality'],
];
narrativeSections.forEach(([key, heading]) => {
if (D[key]) {
const section = document.createElement('div');
section.className = 'narrative-section';
const h = document.createElement('h3');
h.className = 'narrative-heading';
h.textContent = heading;
const p = document.createElement('p');
p.className = 'narrative-p';
p.textContent = D[key];
section.append(h, p);
body.appendChild(section);
}
});
})();
// ── Tabs ──────────────────────────────────────────────────────────────────────
document.querySelectorAll('.tab-btn[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
const section = btn.closest('.section');
section.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
section.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
section.querySelector('#tab-' + btn.dataset.tab).classList.add('active');
});
});
</script>
</body>
</html>
SHA-256: 99e9edc9a4ed9ca8d81579ffb126afb5648a1a2cecc51611273ec0175a257f01