Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<meta name="color-scheme" content="light dark" /> | |
<title>Vietnam Economic Growth Report 2025 β Interactive Research Dashboard</title> | |
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin /> | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-HH8GJxQn3D1a3bQSDK5dXkqX7jsa9HqVibw9H2rXx3+JqZ+oR+W3NNAJbCaf3WnC7wS9tX4SxN7M3g7Kp3i2HQ==" crossorigin="anonymous" referrerpolicy="no-referrer" /> | |
<style> | |
:root { | |
--bg: #0b0c10; | |
--surface: #11131a; | |
--card: #151924; | |
--text: #e6e8ef; | |
--muted: #a6adbb; | |
--primary: #2dd4bf; | |
--primary-600:#14b8a6; | |
--accent: #60a5fa; | |
--warning: #fde047; | |
--danger: #fb7185; | |
--success: #34d399; | |
--shadow: 0 10px 24px rgba(0,0,0,0.35); | |
--radius: 14px; | |
--gap: clamp(0.75rem, 1.5vw, 1.25rem); | |
--fs-900: clamp(2rem, 3.8vw, 3rem); | |
--fs-800: clamp(1.5rem, 2.8vw, 2.25rem); | |
--fs-700: clamp(1.25rem, 2.2vw, 1.75rem); | |
--fs-600: clamp(1.125rem, 1.8vw, 1.375rem); | |
--fs-500: clamp(1rem, 1.6vw, 1.15rem); | |
--fs-400: clamp(0.95rem, 1.4vw, 1rem); | |
--fs-300: clamp(0.85rem, 1.2vw, 0.95rem); | |
--ring: 0 0 0 3px color-mix(in oklab, var(--primary) 30%, transparent); | |
--grid-max: 1440px; | |
} | |
[data-theme="light"] { | |
--bg: #f7f8fb; | |
--surface: #ffffff; | |
--card: #ffffff; | |
--text: #0f172a; | |
--muted: #475569; | |
--shadow: 0 10px 20px rgba(0, 0, 0, 0.07); | |
--ring: 0 0 0 3px color-mix(in oklab, var(--accent) 30%, transparent); | |
} | |
html, body { | |
height: 100%; | |
scroll-behavior: smooth; | |
} | |
body { | |
margin: 0; | |
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Inter, Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji"; | |
color: var(--text); | |
background: radial-gradient(1200px 600px at 100% -10%, rgba(96,165,250,.06), transparent 60%) , | |
radial-gradient(1200px 600px at -10% 100%, rgba(45,212,191,.07), transparent 60%), var(--bg); | |
line-height: 1.55; | |
} | |
/* Top progress bar */ | |
.reading-progress { | |
position: fixed; | |
inset: 0 0 auto 0; | |
height: 4px; | |
background: linear-gradient(90deg, var(--primary), var(--accent)); | |
transform-origin: left; | |
transform: scaleX(0); | |
z-index: 9999; | |
box-shadow: 0 2px 8px rgba(0,0,0,.2); | |
} | |
header.appbar { | |
position: sticky; | |
top: 0; | |
z-index: 999; | |
backdrop-filter: saturate(180%) blur(10px); | |
background: color-mix(in oklab, var(--surface) 85%, transparent); | |
border-bottom: 1px solid color-mix(in oklab, var(--muted) 15%, transparent); | |
} | |
.appbar-inner { | |
max-width: var(--grid-max); | |
margin: 0 auto; | |
padding: .75rem clamp(.75rem, 3vw, 1.5rem); | |
display: flex; | |
align-items: center; | |
gap: .75rem; | |
} | |
.brand { | |
display: flex; | |
align-items: center; | |
gap: .65rem; | |
font-weight: 700; | |
letter-spacing: .2px; | |
font-size: var(--fs-500); | |
} | |
.brand i { color: var(--primary); } | |
.spacer { flex: 1; } | |
.toolbar { | |
display: flex; | |
align-items: center; | |
gap: .5rem; | |
} | |
.search { | |
position: relative; | |
width: min(52vw, 420px); | |
min-width: 180px; | |
} | |
.search input { | |
width: 100%; | |
padding: .7rem 2.2rem .7rem 2.2rem; | |
border: 1px solid color-mix(in oklab, var(--muted) 20%, transparent); | |
border-radius: 999px; | |
background: var(--card); | |
color: var(--text); | |
font-size: var(--fs-400); | |
outline: none; | |
transition: border .2s, box-shadow .2s; | |
} | |
.search input:focus { box-shadow: var(--ring); border-color: color-mix(in oklab, var(--primary) 35%, var(--muted)); } | |
.search i { | |
position: absolute; | |
left: .7rem; | |
top: 50%; | |
transform: translateY(-50%); | |
color: var(--muted); | |
} | |
.search .count { | |
position: absolute; | |
right: .7rem; | |
top: 50%; | |
transform: translateY(-50%); | |
font-size: .75rem; | |
color: var(--muted); | |
} | |
.icon-btn { | |
display: grid; | |
place-items: center; | |
border: 1px solid color-mix(in oklab, var(--muted) 20%, transparent); | |
background: var(--card); | |
color: var(--text); | |
width: 40px; | |
height: 40px; | |
border-radius: 10px; | |
cursor: pointer; | |
transition: transform .08s ease, background .2s, border-color .2s; | |
} | |
.icon-btn:hover { background: color-mix(in oklab, var(--card) 75%, var(--surface)); } | |
.icon-btn:active { transform: translateY(1px); } | |
.export-menu { | |
position: relative; | |
} | |
.menu { | |
position: absolute; | |
right: 0; | |
top: calc(100% + 8px); | |
min-width: 220px; | |
background: var(--surface); | |
border: 1px solid color-mix(in oklab, var(--muted) 20%, transparent); | |
border-radius: 12px; | |
box-shadow: var(--shadow); | |
padding: .25rem; | |
display: none; | |
} | |
.menu.open { display: block; } | |
.menu button { | |
width: 100%; | |
padding: .75rem .85rem; | |
display: flex; | |
align-items: center; | |
gap: .6rem; | |
font-size: var(--fs-400); | |
background: transparent; | |
border: 0; | |
color: var(--text); | |
border-radius: 8px; | |
cursor: pointer; | |
} | |
.menu button:hover { background: color-mix(in oklab, var(--card) 80%, transparent); } | |
.layout { | |
display: grid; | |
grid-template-columns: 1fr; | |
gap: var(--gap); | |
max-width: var(--grid-max); | |
margin: 0 auto; | |
padding: clamp(0.75rem, 2vw, 1.25rem); | |
} | |
/* Sticky TOC */ | |
nav.toc { | |
position: sticky; | |
top: 72px; | |
align-self: start; | |
background: var(--surface); | |
border: 1px solid color-mix(in oklab, var(--muted) 20%, transparent); | |
border-radius: var(--radius); | |
padding: .75rem; | |
display: grid; | |
gap: .25rem; | |
height: fit-content; | |
max-height: calc(100vh - 96px); | |
overflow: auto; | |
} | |
.toc h4 { | |
margin: .25rem .5rem .5rem; | |
color: var(--muted); | |
font-size: .85rem; | |
letter-spacing: .4px; | |
text-transform: uppercase; | |
} | |
.toc a { | |
display: grid; | |
grid-template-columns: auto 1fr auto; | |
gap: .6rem; | |
align-items: center; | |
padding: .5rem .6rem; | |
color: var(--text); | |
text-decoration: none; | |
border-radius: 8px; | |
font-size: var(--fs-400); | |
} | |
.toc a .dot { | |
width: 8px; height: 8px; border-radius: 50%; | |
background: color-mix(in oklab, var(--muted) 60%, transparent); | |
} | |
.toc a.active { | |
background: color-mix(in oklab, var(--primary) 8%, transparent); | |
outline: 1px dashed color-mix(in oklab, var(--primary) 30%, transparent); | |
} | |
.toc a.active .dot { background: var(--primary); } | |
.toc small { | |
color: var(--muted); | |
font-size: .7rem; | |
} | |
/* Hero */ | |
.hero { | |
background: linear-gradient(180deg, color-mix(in oklab, var(--accent) 12%, transparent), transparent 45%), var(--surface); | |
border: 1px solid color-mix(in oklab, var(--muted) 20%, transparent); | |
border-radius: var(--radius); | |
padding: clamp(1rem, 3vw, 1.75rem); | |
display: grid; | |
gap: var(--gap); | |
} | |
.hero header h1 { | |
font-size: var(--fs-900); | |
margin: 0; | |
letter-spacing: -0.02em; | |
} | |
.hero header p { | |
margin: .35rem 0 0; | |
color: var(--muted); | |
font-size: var(--fs-500); | |
} | |
.kpis { | |
display: grid; | |
grid-template-columns: repeat(2, 1fr); | |
gap: var(--gap); | |
} | |
.card { | |
container-type: inline-size; | |
background: var(--card); | |
border: 1px solid color-mix(in oklab, var(--muted) 20%, transparent); | |
border-radius: calc(var(--radius) - 2px); | |
padding: clamp(.85rem, 2.2vw, 1.25rem); | |
box-shadow: var(--shadow); | |
display: grid; | |
gap: .65rem; | |
} | |
.card .label { | |
color: var(--muted); | |
font-size: var(--fs-300); | |
letter-spacing: .2px; | |
text-transform: uppercase; | |
} | |
.card .value { | |
font-weight: 800; | |
font-size: clamp(1.35rem, 2.5vw, 1.85rem); | |
} | |
.trend { | |
display: inline-flex; | |
gap: .45rem; | |
align-items: center; | |
color: var(--success); | |
font-size: .9rem; | |
} | |
.trend.down { color: var(--danger); } | |
@container (min-width: 360px) { | |
.card .value { font-size: clamp(1.5rem, 2.2cqi, 2rem); } | |
} | |
/* Content grid */ | |
.content-grid { | |
display: grid; | |
grid-template-columns: 1fr; | |
gap: var(--gap); | |
} | |
.section { | |
background: var(--surface); | |
border: 1px solid color-mix(in oklab, var(--muted) 20%, transparent); | |
border-radius: var(--radius); | |
padding: clamp(1rem, 2.5vw, 1.5rem); | |
display: grid; | |
gap: clamp(.75rem, 1.2vw, 1rem); | |
} | |
.section header { | |
display: flex; | |
align-items: baseline; | |
justify-content: space-between; | |
gap: 1rem; | |
} | |
.section h2 { | |
margin: 0; | |
font-size: var(--fs-800); | |
} | |
.section .actions { | |
display: flex; | |
gap: .5rem; | |
} | |
.section.collapsible > .content { | |
display: grid; | |
gap: .75rem; | |
} | |
.section.collapsible[data-collapsed="true"] > .content { | |
display: none; | |
} | |
.grid-2 { | |
display: grid; | |
grid-template-columns: 1fr; | |
gap: var(--gap); | |
} | |
.grid-3 { | |
display: grid; | |
grid-template-columns: 1fr; | |
gap: var(--gap); | |
} | |
/* Chart cards */ | |
.chart { | |
display: grid; | |
gap: .5rem; | |
} | |
.chart header { | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
gap: .5rem; | |
} | |
.chart-title { | |
font-weight: 700; | |
font-size: var(--fs-600); | |
} | |
.chart .legend { | |
display: flex; | |
gap: .75rem; | |
flex-wrap: wrap; | |
} | |
.legend .item { | |
display: inline-flex; | |
align-items: center; | |
gap: .4rem; | |
font-size: .85rem; | |
color: var(--muted); | |
} | |
.legend .swatch { | |
width: 12px; height: 12px; border-radius: 3px; | |
} | |
.viz { | |
position: relative; | |
width: 100%; | |
aspect-ratio: 16/9; | |
background: linear-gradient(180deg, color-mix(in oklab, var(--card) 90%, transparent), transparent); | |
border-radius: 12px; | |
border: 1px dashed color-mix(in oklab, var(--muted) 18%, transparent); | |
overflow: hidden; | |
} | |
.viz svg, .viz canvas { width: 100%; height: 100%; display: block; } | |
.tooltip { | |
position: absolute; | |
background: color-mix(in oklab, var(--card) 92%, transparent); | |
color: var(--text); | |
border: 1px solid color-mix(in oklab, var(--muted) 30%, transparent); | |
padding: .4rem .55rem; | |
font-size: .85rem; | |
border-radius: 8px; | |
box-shadow: var(--shadow); | |
pointer-events: none; | |
opacity: 0; | |
transform: translate(-50%, -120%); | |
transition: opacity .1s ease; | |
white-space: nowrap; | |
} | |
.dl-chart { | |
border: 0; background: transparent; color: var(--muted); cursor: pointer; | |
} | |
.dl-chart:hover { color: var(--text); } | |
/* Table */ | |
.table { | |
overflow: auto; | |
border-radius: 12px; | |
border: 1px solid color-mix(in oklab, var(--muted) 20%, transparent); | |
} | |
table { | |
width: 100%; | |
border-collapse: collapse; | |
font-size: var(--fs-400); | |
} | |
thead tr { | |
background: color-mix(in oklab, var(--card) 75%, transparent); | |
} | |
th, td { | |
padding: .75rem .85rem; | |
text-align: left; | |
border-bottom: 1px solid color-mix(in oklab, var(--muted) 15%, transparent); | |
vertical-align: top; | |
} | |
th.sortable { | |
cursor: pointer; | |
user-select: none; | |
white-space: nowrap; | |
} | |
th.sortable .sort { | |
margin-left: .35rem; color: var(--muted); | |
} | |
tbody tr:hover { | |
background: color-mix(in oklab, var(--card) 85%, transparent); | |
} | |
.filters { | |
display: flex; flex-wrap: wrap; gap: .5rem; | |
} | |
.chip { | |
padding: .4rem .6rem; | |
font-size: .85rem; | |
background: color-mix(in oklab, var(--card) 85%, transparent); | |
border: 1px solid color-mix(in oklab, var(--muted) 20%, transparent); | |
color: var(--text); | |
border-radius: 999px; | |
cursor: pointer; | |
} | |
.chip.active { | |
background: color-mix(in oklab, var(--primary) 12%, transparent); | |
border-color: color-mix(in oklab, var(--primary) 50%, var(--muted)); | |
} | |
/* References */ | |
.ref-list { | |
display: grid; | |
gap: .5rem; | |
} | |
.ref { | |
display: grid; | |
grid-template-columns: auto 1fr; | |
gap: .6rem; | |
align-items: start; | |
} | |
.ref a { color: var(--accent); text-decoration: none; } | |
.ref a:hover { text-decoration: underline; } | |
/* Back to top */ | |
.to-top { | |
position: fixed; | |
right: 12px; | |
bottom: 12px; | |
z-index: 999; | |
display: none; | |
} | |
.to-top.show { display: block; } | |
/* Breakpoints */ | |
@media (min-width: 768px) { | |
.kpis { grid-template-columns: repeat(3, 1fr); } | |
.grid-2 { grid-template-columns: repeat(2, 1fr); } | |
.grid-3 { grid-template-columns: repeat(3, 1fr); } | |
} | |
@media (min-width: 1024px) { | |
.layout { | |
grid-template-columns: 280px 1fr; | |
align-items: start; | |
} | |
} | |
@media (min-width: 1440px) { | |
.hero { grid-template-columns: 1.2fr 1fr; align-items: center; } | |
} | |
/* Highlights from search */ | |
mark { | |
background: color-mix(in oklab, var(--warning) 55%, transparent); | |
color: inherit; | |
padding: 0 .2em; | |
border-radius: 3px; | |
} | |
/* Small helpers */ | |
.muted { color: var(--muted); } | |
.ok { color: var(--success); } | |
.warn { color: var(--warning); } | |
.bad { color: var(--danger); } | |
.note { | |
padding: .6rem .75rem; | |
background: color-mix(in oklab, var(--card) 85%, transparent); | |
border-left: 3px solid var(--accent); | |
border-radius: 8px; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="reading-progress" id="readingProgress"></div> | |
<header class="appbar" id="top"> | |
<div class="appbar-inner"> | |
<div class="brand"> | |
<i class="fa-solid fa-chart-line"></i> | |
Vietnam Economic Growth Report 2025 | |
</div> | |
<div class="spacer"></div> | |
<div class="toolbar"> | |
<div class="search" role="search"> | |
<i class="fa-solid fa-magnifying-glass"></i> | |
<input id="searchInput" type="search" placeholder="Search the report (e.g., inflation, FDI, GDP)..." aria-label="Search report" /> | |
<span class="count" id="searchCount">0</span> | |
</div> | |
<button class="icon-btn" id="clearSearch" title="Clear search"><i class="fa-solid fa-eraser"></i></button> | |
<button class="icon-btn" id="themeToggle" title="Toggle theme"><i class="fa-solid fa-sun"></i></button> | |
<div class="export-menu"> | |
<button class="icon-btn" id="exportBtn" title="Export & share"><i class="fa-solid fa-arrow-down-to-line"></i></button> | |
<div class="menu" id="exportMenu" role="menu" aria-label="Export options"> | |
<button data-action="print"><i class="fa-solid fa-file-pdf"></i>Export as PDF (Print)</button> | |
<button data-action="copy-summary"><i class="fa-solid fa-clipboard"></i>Copy Executive Summary</button> | |
<button data-action="download-csv"><i class="fa-solid fa-table"></i>Download Key Data (CSV)</button> | |
<button data-action="share-link"><i class="fa-solid fa-link"></i>Copy Link to Section</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
</header> | |
<main class="layout" id="main"> | |
<nav class="toc" aria-label="Table of contents"> | |
<h4>Contents</h4> | |
<a href="#exec-summary"><span class="dot"></span>Executive Summary<small></small></a> | |
<a href="#methodology"><span class="dot"></span>Methodology<small></small></a> | |
<a href="#findings"><span class="dot"></span>Findings<small></small></a> | |
<a href="#indicators"><span class="dot"></span>Key Indicators<small></small></a> | |
<a href="#sectoral"><span class="dot"></span>Sectoral Analysis<small></small></a> | |
<a href="#risks"><span class="dot"></span>Challenges & Risks<small></small></a> | |
<a href="#historical"><span class="dot"></span>Historical Comparison<small></small></a> | |
<a href="#outlook"><span class="dot"></span>Outlook & Projections<small></small></a> | |
<a href="#conclusion"><span class="dot"></span>Conclusion<small></small></a> | |
<a href="#references"><span class="dot"></span>References<small></small></a> | |
<a href="#appendices"><span class="dot"></span>Appendices<small></small></a> | |
</nav> | |
<div class="content-grid"> | |
<section class="hero" id="exec-summary"> | |
<header> | |
<div> | |
<h1>Vietnamβs Economy 2025: Strong Momentum, Prudent Optimism</h1> | |
<p>GDP expanded 7.96% y/y in Q2; H1 growth reached 7.52%βthe strongest first-half since 2011βdriven by services and manufacturing. Inflation remains contained, unemployment low, and FDI inflows robust despite external headwinds.</p> | |
</div> | |
</header> | |
<div class="kpis"> | |
<div class="card" data-animate="counter"> | |
<div class="label">GDP Growth (H1 2025)</div> | |
<div class="value" data-target="7.52">7.52%</div> | |
<div class="trend"><i class="fa-solid fa-arrow-trend-up"></i> Highest H1 since 2011</div> | |
</div> | |
<div class="card" data-animate="counter"> | |
<div class="label">Q2 2025 GDP (y/y)</div> | |
<div class="value" data-target="7.96">7.96%</div> | |
<div class="trend"><i class="fa-solid fa-bolt"></i> Broad-based in industry & services</div> | |
</div> | |
<div class="card" data-animate="counter"> | |
<div class="label">Inflation (Jun 2025)</div> | |
<div class="value" data-target="3.57">3.57%</div> | |
<div class="trend"><i class="fa-solid fa-gauge"></i> Within 3β4.5% target</div> | |
</div> | |
<div class="card" data-animate="counter"> | |
<div class="label">Unemployment (Q1 2025)</div> | |
<div class="value" data-target="2.20">2.20%</div> | |
<div class="trend ok"><i class="fa-solid fa-user-check"></i> Historically low</div> | |
</div> | |
<div class="card" data-animate="counter"> | |
<div class="label">FDI Total (H1 2025)</div> | |
<div class="value" data-target="21.51">US$21.51b</div> | |
<div class="trend"><i class="fa-solid fa-globe"></i> +32.6% y/y</div> | |
</div> | |
<div class="card" data-animate="counter"> | |
<div class="label">Retail Sales (Q1 2025)</div> | |
<div class="value" data-target="66.83">US$66.83b</div> | |
<div class="trend"><i class="fa-solid fa-store"></i> +9.9% y/y</div> | |
</div> | |
</div> | |
<div class="note"> | |
Summary: Vietnamβs robust start to 2025 is anchored in resilient domestic fundamentalsβlow unemployment, contained inflation, and strong FDIβwhile external risks from trade tensions and tariffs weigh on the outlook. Government targets (8.3β8.5%) exceed most international forecasts (5.2β6.6%), calling for cautious optimism. | |
</div> | |
</section> | |
<section class="section collapsible" id="methodology" data-collapsed="false"> | |
<header> | |
<h2>Methodology</h2> | |
<div class="actions"> | |
<button class="icon-btn toggle-section" title="Collapse/Expand"><i class="fa-solid fa-chevron-up"></i></button> | |
</div> | |
</header> | |
<div class="content"> | |
<p>This dashboard synthesizes publicly available statistics and reputable institutional forecasts to present a concise and interactive view of Vietnamβs 2025 economic performance.</p> | |
<ul> | |
<li>Scope: Macroeconomic indicators (GDP growth, inflation, unemployment), capital flows (FDI), and sectoral context (services, manufacturing, retail).</li> | |
<li>Sources: IMF, ADB, World Bank, Vietnam GSO, Trading Economics, Vietnam Investment Review, and other cited outlets (see References).</li> | |
<li>Processing: Figures are normalized to percent or USD where applicable; H1 denotes first half of calendar year. Forecasts are labeled distinctly from actuals.</li> | |
<li>Visualization: Custom SVG charts (no frameworks) with responsive scaling, tooltips, and export-to-PNG utilities.</li> | |
<li>Quality: Cross-checked figures against at least two sources where possible; discrepancies noted in footnotes if relevant.</li> | |
</ul> | |
</div> | |
</section> | |
<section class="section" id="findings"> | |
<header> | |
<h2>Key Findings</h2> | |
</header> | |
<div class="grid-2"> | |
<div class="card"> | |
<h3 style="margin:0;font-size:var(--fs-700)">Growth Momentum</h3> | |
<p>Q2 growth accelerated to 7.96% y/y, lifting H1 2025 to 7.52%βa post-2011 high. Services and manufacturing were principal drivers, underpinned by resilient domestic demand and export competitiveness.</p> | |
</div> | |
<div class="card"> | |
<h3 style="margin:0;font-size:var(--fs-700)">Macro Stability</h3> | |
<p>Inflation at 3.57% in June remains within the 3β4.5% comfort zone; unemployment at 2.20% reflects a tight labor market. Policy space remains available should global shocks intensify.</p> | |
</div> | |
</div> | |
</section> | |
<section class="section" id="indicators"> | |
<header> | |
<h2>Key Economic Indicators 2025</h2> | |
<div class="actions"> | |
<button class="icon-btn" data-jump="#dataset"><i class="fa-solid fa-table"></i></button> | |
</div> | |
</header> | |
<div class="grid-2"> | |
<div class="card chart" id="chart-gdp"> | |
<header> | |
<div class="chart-title">GDP Growth: 2025 Quarterly & H1</div> | |
<div> | |
<button class="dl-chart" title="Download chart as PNG" data-download="chart-gdp"><i class="fa-solid fa-download"></i></button> | |
</div> | |
</header> | |
<div class="legend"> | |
<div class="item"><span class="swatch" style="background: var(--accent)"></span>Actual (y/y)</div> | |
<div class="item"><span class="swatch" style="background: var(--primary)"></span>H1 Average</div> | |
</div> | |
<div class="viz" data-chart="bar"></div> | |
</div> | |
<div class="card chart" id="chart-inflation"> | |
<header> | |
<div class="chart-title">Inflation: 2025 Actuals vs Forecasts</div> | |
<div> | |
<button class="dl-chart" title="Download chart as PNG" data-download="chart-inflation"><i class="fa-solid fa-download"></i></button> | |
</div> | |
</header> | |
<div class="legend"> | |
<div class="item"><span class="swatch" style="background: var(--accent)"></span>Actual</div> | |
<div class="item"><span class="swatch" style="background: var(--warning)"></span>IMF 2025</div> | |
<div class="item"><span class="swatch" style="background: var(--primary)"></span>ADB 2025</div> | |
</div> | |
<div class="viz" data-chart="lines"></div> | |
</div> | |
<div class="card chart" id="chart-unemp"> | |
<header> | |
<div class="chart-title">Unemployment Gauge (Q1 2025)</div> | |
<div> | |
<button class="dl-chart" title="Download chart as PNG" data-download="chart-unemp"><i class="fa-solid fa-download"></i></button> | |
</div> | |
</header> | |
<div class="viz" data-chart="donut"></div> | |
</div> | |
<div class="card chart" id="chart-fdi"> | |
<header> | |
<div class="chart-title">FDI Inflows 2025</div> | |
<div> | |
<button class="dl-chart" title="Download chart as PNG" data-download="chart-fdi"><i class="fa-solid fa-download"></i></button> | |
</div> | |
</header> | |
<div class="legend"> | |
<div class="item"><span class="swatch" style="background: var(--accent)"></span>Registered (JanβMay)</div> | |
<div class="item"><span class="swatch" style="background: var(--primary)"></span>Disbursed (JanβMay)</div> | |
<div class="item"><span class="swatch" style="background: var(--success)"></span>Total (H1)</div> | |
</div> | |
<div class="viz" data-chart="columns"></div> | |
</div> | |
</div> | |
</section> | |
<section class="section" id="sectoral"> | |
<header><h2>Sectoral Analysis</h2></header> | |
<div class="grid-2"> | |
<div class="card"> | |
<h3 style="margin:0;font-size:var(--fs-700)">Primary Growth Drivers</h3> | |
<ol> | |
<li><strong>Services sector</strong> remained the largest GDP contributor.</li> | |
<li><strong>Manufacturing</strong> continued its recovery and expansion.</li> | |
<li><strong>Export industries</strong> acted as the economyβs backbone despite global frictions.</li> | |
<li><strong>Banking sector</strong> projected earnings +17% in 2025 with system-wide credit +15%.</li> | |
</ol> | |
</div> | |
<div class="card"> | |
<h3 style="margin:0;font-size:var(--fs-700)">Retail Performance</h3> | |
<p>Retail sales reached 1.708 quadrillion VND (US$66.83b) in Q1 2025, up 9.9% y/y, supported by low unemployment and steady real incomes amid controlled inflation.</p> | |
</div> | |
</div> | |
</section> | |
<section class="section" id="risks"> | |
<header><h2>Challenges and Risk Factors</h2></header> | |
<div class="grid-2"> | |
<div class="card"> | |
<ul> | |
<li><strong>Global trade tensions</strong> and <strong>US tariffs</strong> weigh on export-oriented firms.</li> | |
<li><strong>Geopolitical instability</strong> raises uncertainty and risk premiums.</li> | |
<li><strong>FDI overdependence</strong> and inflation vigilance flagged by experts.</li> | |
<li><strong>Macro stability</strong> must be preserved: avoid excess public debt and overheating.</li> | |
</ul> | |
</div> | |
<div class="card"> | |
<h3 style="margin:0;font-size:var(--fs-700)">Policy Watch</h3> | |
<p>Authorities aim to diversify export markets, bolster domestic demand, and maintain policy flexibility to cushion global shocks if necessary.</p> | |
</div> | |
</div> | |
</section> | |
<section class="section" id="historical"> | |
<header> | |
<h2>Historical Comparison</h2> | |
</header> | |
<div class="grid-2"> | |
<div class="card chart" id="chart-historical"> | |
<header> | |
<div class="chart-title">Q1 GDP Growth 2020β2025 (y/y)</div> | |
<div> | |
<button class="dl-chart" title="Download chart as PNG" data-download="chart-historical"><i class="fa-solid fa-download"></i></button> | |
</div> | |
</header> | |
<div class="legend"> | |
<div class="item"><span class="swatch" style="background: var(--accent)"></span>Q1 Growth</div> | |
</div> | |
<div class="viz" data-chart="line"></div> | |
</div> | |
<div class="card"> | |
<h3 style="margin:0;font-size:var(--fs-700)">Context</h3> | |
<p>After a 7.1% expansion in 2024, growth started stronger in 2025. While external headwinds may temper full-year performance, long-term fundamentals remain resilient.</p> | |
<ul> | |
<li>2024 GDP growth: <strong>7.1%</strong></li> | |
<li>2025 growth: potential moderation vs H1 pace due to external factors</li> | |
<li>Structural strengths: competitive exports, FDI pipeline, stable labor market</li> | |
</ul> | |
</div> | |
</div> | |
</section> | |
<section class="section" id="outlook"> | |
<header><h2>Economic Outlook and Projections</h2></header> | |
<div class="grid-2"> | |
<div class="card"> | |
<h3 style="margin:0;font-size:var(--fs-700)">Near-term Prospects (2025)</h3> | |
<p>Solid baseline growth expected amid uncertainty. Governmentβs 8.3β8.5% target is ambitious relative to international forecasts (IMF 5.2%, World Bank 5.8%, ADB 6.6%). Parliamentary guidance indicates a <em>7% to at least 8%</em> range, reflecting confidence but recognizing risks.</p> | |
</div> | |
<div class="card chart" id="chart-forecasts"> | |
<header> | |
<div class="chart-title">2025 GDP Growth Forecasts vs Target</div> | |
<div> | |
<button class="dl-chart" title="Download chart as PNG" data-download="chart-forecasts"><i class="fa-solid fa-download"></i></button> | |
</div> | |
</header> | |
<div class="legend"> | |
<div class="item"><span class="swatch" style="background: var(--accent)"></span>Institutional Forecasts</div> | |
<div class="item"><span class="swatch" style="background: var(--warning)"></span>Gov Target Range</div> | |
</div> | |
<div class="viz" data-chart="range"></div> | |
</div> | |
</div> | |
<div class="grid-3" style="margin-top:var(--gap)"> | |
<div class="card"> | |
<h3 style="margin:0;font-size:var(--fs-700)">Supporting Factors</h3> | |
<ul> | |
<li>Robust FDI inflows signal investor confidence.</li> | |
<li>Low unemployment supports consumption.</li> | |
<li>Inflation control preserves purchasing power.</li> | |
<li>Export competitiveness remains intact.</li> | |
<li>Parliamentary support for higher growth ambitions.</li> | |
</ul> | |
</div> | |
<div class="card"> | |
<h3 style="margin:0;font-size:var(--fs-700)">Risk Mitigation</h3> | |
<ul> | |
<li>Diversify export markets and supply chains.</li> | |
<li>Strengthen domestic demand via targeted fiscal measures.</li> | |
<li>Maintain macro stability and policy buffers.</li> | |
</ul> | |
</div> | |
<div class="card"> | |
<h3 style="margin:0;font-size:var(--fs-700)">Takeaway</h3> | |
<p>Cautious optimism is warranted: upside from FDI and services could offset external headwinds, but achieving 8%+ will require benign external conditions and effective policy execution.</p> | |
</div> | |
</div> | |
</section> | |
<section class="section" id="dataset"> | |
<header> | |
<h2>Interactive Dataset</h2> | |
</header> | |
<div class="filters" style="margin-bottom:.5rem"> | |
<button class="chip active" data-filter="all">All</button> | |
<button class="chip" data-filter="GDP">GDP</button> | |
<button class="chip" data-filter="Inflation">Inflation</button> | |
<button class="chip" data-filter="Unemployment">Unemployment</button> | |
<button class="chip" data-filter="FDI">FDI</button> | |
<button class="chip" data-filter="Retail">Retail</button> | |
<div class="spacer"></div> | |
<button class="chip" id="resetTable"><i class="fa-solid fa-rotate-left"></i> Reset</button> | |
</div> | |
<div class="table"> | |
<table id="dataTable"> | |
<thead> | |
<tr> | |
<th class="sortable" data-sort="text">Indicator <i class="sort fa-solid fa-sort"></i></th> | |
<th class="sortable" data-sort="text">Period <i class="sort fa-solid fa-sort"></i></th> | |
<th class="sortable" data-sort="number">Value <i class="sort fa-solid fa-sort"></i></th> | |
<th>Notes / Source</th> | |
</tr> | |
</thead> | |
<tbody> | |
<tr data-type="GDP"><td>GDP Growth</td><td>Q1 2025</td><td data-value="6.9">6.9%</td><td>y/y growth</td></tr> | |
<tr data-type="GDP"><td>GDP Growth</td><td>Q2 2025</td><td data-value="7.96">7.96%</td><td>y/y growth</td></tr> | |
<tr data-type="GDP"><td>GDP Growth</td><td>H1 2025</td><td data-value="7.52">7.52%</td><td>Highest H1 since 2011</td></tr> | |
<tr data-type="Inflation"><td>Inflation</td><td>May 2025</td><td data-value="3.24">3.24%</td><td>IMF/ADB range 2.9β4.0%</td></tr> | |
<tr data-type="Inflation"><td>Inflation</td><td>Jun 2025</td><td data-value="3.57">3.57%</td><td>Highest since start of year</td></tr> | |
<tr data-type="Inflation"><td>Inflation Forecast (IMF)</td><td>2025</td><td data-value="2.9">2.9%</td><td>IMF projection</td></tr> | |
<tr data-type="Inflation"><td>Inflation Forecast (ADB)</td><td>2025</td><td data-value="4.0">4.0%</td><td>ADB projection</td></tr> | |
<tr data-type="Unemployment"><td>Unemployment</td><td>Q1 2025</td><td data-value="2.2">2.20%</td><td>Down from 2.22% in Q4 2024</td></tr> | |
<tr data-type="FDI"><td>FDI Registered</td><td>JanβMay 2025</td><td data-value="18.4">US$18.4b</td><td>+51% y/y</td></tr> | |
<tr data-type="FDI"><td>FDI Disbursed</td><td>JanβMay 2025</td><td data-value="8.9">US$8.9b</td><td></td></tr> | |
<tr data-type="FDI"><td>FDI Total</td><td>H1 2025</td><td data-value="21.51">US$21.51b</td><td>+32.6% y/y</td></tr> | |
<tr data-type="Retail"><td>Retail Sales</td><td>Q1 2025</td><td data-value="66.83">US$66.83b</td><td>1.708 quadrillion VND; +9.9% y/y</td></tr> | |
</tbody> | |
</table> | |
</div> | |
</section> | |
<section class="section collapsible" id="conclusion" data-collapsed="false"> | |
<header> | |
<h2>Conclusion</h2> | |
<div class="actions"> | |
<button class="icon-btn toggle-section" title="Collapse/Expand"><i class="fa-solid fa-chevron-up"></i></button> | |
</div> | |
</header> | |
<div class="content"> | |
<p>Vietnamβs 2025 performance underscores resilience and growth potential. With low unemployment, contained inflation, and strong FDI inflows, the economy is well-positioned for sustained development. The ambitious government target (~8%+) contrasts with more conservative international projections, suggesting a balanced stance of aspiration and prudence.</p> | |
<p>The governmentβs determination to reach 8% in 2025 aims to set the stage for higher growth in subsequent years; execution and external conditions will be pivotal.</p> | |
</div> | |
</section> | |
<section class="section" id="references"> | |
<header><h2>Sources and Citations</h2></header> | |
<div class="ref-list"> | |
<div class="ref"><span>1.</span> <a href="https://tradingeconomics.com/vietnam/gdp-growth-annual" target="_blank" rel="noopener">Trading Economics - Vietnam GDP Annual Growth Rate</a></div> | |
<div class="ref"><span>2.</span> <a href="https://www.imf.org/en/Countries/VNM" target="_blank" rel="noopener">International Monetary Fund - Vietnam Country Profile</a></div> | |
<div class="ref"><span>3.</span> <a href="https://www.worldeconomics.com/GDP/Vietnam.gdp" target="_blank" rel="noopener">World Economics - Vietnam GDP Estimates</a></div> | |
<div class="ref"><span>4.</span> <a href="https://www.gso.gov.vn/en/" target="_blank" rel="noopener">Government of Vietnam - General Statistics Office</a></div> | |
<div class="ref"><span>5.</span> <a href="https://en.wikipedia.org/wiki/Economy_of_Vietnam" target="_blank" rel="noopener">Wikipedia - Economy of Vietnam</a></div> | |
<div class="ref"><span>6.</span> <a href="https://www.imf.org/en/Countries/VNM" target="_blank" rel="noopener">IMF - Vietnam and the IMF</a></div> | |
<div class="ref"><span>7.</span> <a href="https://www.focus-economics.com/countries/vietnam" target="_blank" rel="noopener">FocusEconomics - Vietnam Economic Indicators</a></div> | |
<div class="ref"><span>8.</span> <a href="https://www.gso.gov.vn/en/data-and-statistics/" target="_blank" rel="noopener">National Statistics Office of Vietnam - Economic Reports</a></div> | |
<div class="ref"><span>9.</span> <a href="https://vietnamnet.vn/" target="_blank" rel="noopener">VietnamNet - Economic News and Analysis</a></div> | |
<div class="ref"><span>10.</span> <a href="https://www.imf.org/en/Publications/CR" target="_blank" rel="noopener">IMF - Article IV Mission Reports</a></div> | |
<div class="ref"><span>11.</span> <a href="https://www.vietnam-briefing.com/" target="_blank" rel="noopener">Vietnam Briefing - Economic Analysis</a></div> | |
<div class="ref"><span>12.</span> <a href="https://vir.com.vn/" target="_blank" rel="noopener">Vietnam Investment Review - FDI Statistics</a></div> | |
<div class="ref"><span>13.</span> <a href="https://tradingeconomics.com/vietnam/foreign-direct-investment" target="_blank" rel="noopener">Trading Economics - Vietnam FDI</a></div> | |
<div class="ref"><span>14.</span> <a href="https://www.whitecase.com/" target="_blank" rel="noopener">White & Case - Regional Economic Outlook</a></div> | |
<div class="ref"><span>15.</span> <a href="https://vneconomictimes.com/" target="_blank" rel="noopener">Vietnam Economic Times</a></div> | |
<div class="ref"><span>16.</span> <a href="https://www.adb.org/countries/viet-nam/main" target="_blank" rel="noopener">Asian Development Bank - Vietnam Country Partnership</a></div> | |
<div class="ref"><span>17.</span> <a href="https://www.mpi.gov.vn/en/" target="_blank" rel="noopener">Ministry of Planning and Investment - Vietnam</a></div> | |
</div> | |
</section> | |
<section class="section collapsible" id="appendices" data-collapsed="true"> | |
<header> | |
<h2>Appendices</h2> | |
<div class="actions"> | |
<button class="icon-btn toggle-section" title="Collapse/Expand"><i class="fa-solid fa-chevron-down"></i></button> | |
</div> | |
</header> | |
<div class="content"> | |
<details open> | |
<summary><strong>Appendix A:</strong> Historical Q1 GDP Growth (2020β2025)</summary> | |
<ul> | |
<li>2020: 3.21%</li> | |
<li>2021: 4.85%</li> | |
<li>2022: 5.42%</li> | |
<li>2023: 3.46%</li> | |
<li>2024: 5.98%</li> | |
<li>2025: 6.93% (Q1)</li> | |
</ul> | |
</details> | |
<details> | |
<summary><strong>Appendix B:</strong> Forecast & Target Notes</summary> | |
<p>Forecasts reflect baseline assumptions by institutions (IMF, ADB, World Bank). Government target range (8.3β8.5%) and parliamentary guidance (7% to β₯8%) are policy aspirations and may differ from realized outcomes depending on global conditions and policy implementation.</p> | |
</details> | |
</div> | |
</section> | |
</div> | |
</main> | |
<button class="icon-btn to-top" id="toTop" title="Back to top"><i class="fa-solid fa-arrow-up"></i></button> | |
<script> | |
/* Theme persistence */ | |
(function initTheme() { | |
const saved = localStorage.getItem('theme'); | |
const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches; | |
const theme = saved || (prefersLight ? 'light' : 'dark'); | |
document.documentElement.setAttribute('data-theme', theme); | |
updateThemeIcon(theme); | |
})(); | |
function updateThemeIcon(theme) { | |
const icon = document.querySelector('#themeToggle i'); | |
if (!icon) return; | |
icon.className = 'fa-solid ' + (theme === 'light' ? 'fa-moon' : 'fa-sun'); | |
} | |
document.getElementById('themeToggle').addEventListener('click', () => { | |
const curr = document.documentElement.getAttribute('data-theme') || 'dark'; | |
const next = curr === 'dark' ? 'light' : 'dark'; | |
document.documentElement.setAttribute('data-theme', next); | |
localStorage.setItem('theme', next); | |
updateThemeIcon(next); | |
}); | |
/* Export menu */ | |
const exportBtn = document.getElementById('exportBtn'); | |
const exportMenu = document.getElementById('exportMenu'); | |
exportBtn.addEventListener('click', () => exportMenu.classList.toggle('open')); | |
document.addEventListener('click', (e) => { | |
if (!exportMenu.contains(e.target) && e.target !== exportBtn) exportMenu.classList.remove('open'); | |
}); | |
exportMenu.addEventListener('click', async (e) => { | |
const btn = e.target.closest('button'); | |
if (!btn) return; | |
const action = btn.dataset.action; | |
if (action === 'print') { | |
window.print(); | |
} else if (action === 'copy-summary') { | |
const exec = document.querySelector('#exec-summary .note')?.textContent?.trim(); | |
await navigator.clipboard.writeText(exec || ''); | |
toast('Executive Summary copied to clipboard.'); | |
} else if (action === 'download-csv') { | |
downloadCSV(); | |
} else if (action === 'share-link') { | |
const active = document.querySelector('.toc a.active')?.getAttribute('href') || '#top'; | |
const url = location.origin + location.pathname + active; | |
await navigator.clipboard.writeText(url); | |
toast('Sharable link copied.'); | |
} | |
exportMenu.classList.remove('open'); | |
}); | |
function downloadCSV() { | |
const rows = [['Indicator','Period','Value','Notes']]; | |
document.querySelectorAll('#dataTable tbody tr').forEach(tr => { | |
if (tr.style.display === 'none') return; | |
const cells = [...tr.children].map(td => td.textContent.trim()); | |
rows.push(cells); | |
}); | |
const csv = rows.map(r => r.map(v => `"${v.replace(/"/g,'""')}"`).join(',')).join('\n'); | |
const blob = new Blob([csv], {type:'text/csv'}); | |
const a = document.createElement('a'); | |
a.href = URL.createObjectURL(blob); | |
a.download = 'vietnam_economic_indicators_2025.csv'; | |
document.body.appendChild(a); | |
a.click(); | |
a.remove(); | |
} | |
/* Smooth jump via buttons */ | |
document.querySelectorAll('[data-jump]').forEach(btn => { | |
btn.addEventListener('click', () => { | |
const target = document.querySelector(btn.dataset.jump); | |
if (target) target.scrollIntoView({behavior:'smooth', block:'start'}); | |
}); | |
}); | |
/* Collapsible sections */ | |
document.querySelectorAll('.toggle-section').forEach(btn => { | |
btn.addEventListener('click', () => { | |
const section = btn.closest('.section.collapsible'); | |
const collapsed = section.getAttribute('data-collapsed') === 'true'; | |
section.setAttribute('data-collapsed', collapsed ? 'false' : 'true'); | |
btn.innerHTML = `<i class="fa-solid fa-chevron-${collapsed ? 'up' : 'down'}"></i>`; | |
}); | |
}); | |
/* Table sorting & filtering */ | |
const dataTable = document.getElementById('dataTable'); | |
const ths = dataTable.querySelectorAll('th.sortable'); | |
let sortState = { index: 0, dir: 1 }; | |
ths.forEach((th, i) => { | |
th.addEventListener('click', () => { | |
const type = th.dataset.sort || 'text'; | |
sortState.dir = sortState.index === i ? sortState.dir * -1 : 1; | |
sortState.index = i; | |
sortTable(i, type, sortState.dir); | |
ths.forEach(x => x.querySelector('.sort').className = 'sort fa-solid fa-sort'); | |
th.querySelector('.sort').className = 'sort fa-solid ' + (sortState.dir === 1 ? 'fa-sort-up' : 'fa-sort-down'); | |
localStorage.setItem('tableSort', JSON.stringify(sortState)); | |
}); | |
}); | |
function sortTable(col, type, dir) { | |
const rows = Array.from(dataTable.tBodies[0].rows); | |
rows.sort((a,b) => { | |
const A = a.cells[col].dataset.value || a.cells[col].textContent.trim(); | |
const B = b.cells[col].dataset.value || b.cells[col].textContent.trim(); | |
if (type === 'number') return (parseFloat(A) - parseFloat(B)) * dir; | |
return A.localeCompare(B) * dir; | |
}); | |
rows.forEach(r => dataTable.tBodies[0].appendChild(r)); | |
} | |
(function restoreSort(){ | |
try { | |
const saved = JSON.parse(localStorage.getItem('tableSort')); | |
if (!saved) return; | |
sortState = saved; | |
const th = ths[sortState.index]; | |
sortTable(sortState.index, th.dataset.sort || 'text', sortState.dir); | |
th.querySelector('.sort').className = 'sort fa-solid ' + (sortState.dir === 1 ? 'fa-sort-up' : 'fa-sort-down'); | |
} catch {} | |
})(); | |
const filterChips = document.querySelectorAll('.filters .chip[data-filter]'); | |
filterChips.forEach(chip => chip.addEventListener('click', () => { | |
filterChips.forEach(c => c.classList.remove('active')); | |
chip.classList.add('active'); | |
const type = chip.dataset.filter; | |
dataTable.querySelectorAll('tbody tr').forEach(tr => { | |
tr.style.display = (type === 'all' || tr.dataset.type === type) ? '' : 'none'; | |
}); | |
localStorage.setItem('tableFilter', type); | |
})); | |
document.getElementById('resetTable').addEventListener('click', () => { | |
filterChips.forEach(c => c.classList.remove('active')); | |
document.querySelector('.filters .chip[data-filter="all"]').classList.add('active'); | |
dataTable.querySelectorAll('tbody tr').forEach(tr => tr.style.display = ''); | |
localStorage.removeItem('tableFilter'); | |
localStorage.removeItem('tableSort'); | |
}); | |
(function restoreFilter(){ | |
const saved = localStorage.getItem('tableFilter'); | |
if (!saved) return; | |
const chip = document.querySelector(`.filters .chip[data-filter="${saved}"]`); | |
if (chip) chip.click(); | |
})(); | |
/* Search with highlight */ | |
const searchInput = document.getElementById('searchInput'); | |
const searchCount = document.getElementById('searchCount'); | |
const clearSearch = document.getElementById('clearSearch'); | |
let currentSearch = ''; | |
function clearMarks(root=document) { | |
root.querySelectorAll('mark').forEach(m => m.replaceWith(document.createTextNode(m.textContent))); | |
} | |
function highlightTerm(term) { | |
if (!term) return 0; | |
const rx = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'ig'); | |
let count = 0; | |
document.querySelectorAll('main .section, .hero').forEach(sec => { | |
clearMarks(sec); | |
const walker = document.createTreeWalker(sec, NodeFilter.SHOW_TEXT, { | |
acceptNode: n => n.nodeValue.trim().length > 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT | |
}); | |
const nodes = []; | |
while (walker.nextNode()) nodes.push(walker.currentNode); | |
nodes.forEach(node => { | |
const frag = document.createDocumentFragment(); | |
let lastIdx = 0; | |
const text = node.nodeValue; | |
text.replace(rx, (m, idx) => { | |
const before = text.slice(lastIdx, idx); | |
if (before) frag.appendChild(document.createTextNode(before)); | |
const mark = document.createElement('mark'); | |
mark.textContent = m; | |
frag.appendChild(mark); | |
lastIdx = idx + m.length; | |
count++; | |
}); | |
if (count && lastIdx) { | |
const after = text.slice(lastIdx); | |
if (after) frag.appendChild(document.createTextNode(after)); | |
node.parentNode.replaceChild(frag, node); | |
} | |
}); | |
}); | |
return count; | |
} | |
searchInput.addEventListener('input', () => { | |
const term = searchInput.value.trim(); | |
currentSearch = term; | |
clearMarks(document); | |
const hits = highlightTerm(term); | |
searchCount.textContent = hits; | |
if (hits > 0) { | |
const first = document.querySelector('mark'); | |
first?.scrollIntoView({behavior:'smooth', block:'center'}); | |
} | |
localStorage.setItem('searchTerm', term); | |
}); | |
clearSearch.addEventListener('click', () => { | |
searchInput.value = ''; | |
currentSearch = ''; | |
clearMarks(document); | |
searchCount.textContent = '0'; | |
localStorage.removeItem('searchTerm'); | |
}); | |
(function restoreSearch(){ | |
const term = localStorage.getItem('searchTerm'); | |
if (!term) return; | |
searchInput.value = term; | |
const hits = highlightTerm(term); | |
searchCount.textContent = hits; | |
})(); | |
/* TOC active link + progress bar */ | |
const sections = [...document.querySelectorAll('main .section, #exec-summary')]; | |
const tocLinks = [...document.querySelectorAll('.toc a')]; | |
const io = new IntersectionObserver(entries => { | |
entries.forEach(entry => { | |
if (entry.isIntersecting) { | |
const id = '#' + entry.target.id; | |
tocLinks.forEach(a => a.classList.toggle('active', a.getAttribute('href') === id)); | |
localStorage.setItem('lastSection', id); | |
} | |
}); | |
}, { rootMargin: '-40% 0px -50% 0px', threshold: 0.01 }); | |
sections.forEach(s => io.observe(s)); | |
(function restoreLastSection(){ | |
const id = localStorage.getItem('lastSection'); | |
if (id && document.querySelector(id)) { | |
// Do not auto-scroll on load to avoid jarring UX | |
} | |
})(); | |
const readingProgress = document.getElementById('readingProgress'); | |
document.addEventListener('scroll', () => { | |
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop; | |
const scrollHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight; | |
const p = Math.max(0, Math.min(1, scrollTop / (scrollHeight || 1))); | |
readingProgress.style.transform = `scaleX(${p})`; | |
document.getElementById('toTop').classList.toggle('show', scrollTop > 500); | |
}); | |
/* Back to top */ | |
document.getElementById('toTop').addEventListener('click', () => { | |
window.scrollTo({top: 0, behavior: 'smooth'}); | |
}); | |
/* KPI counters animation */ | |
const counters = document.querySelectorAll('.card[data-animate="counter"] .value'); | |
const ioCounters = new IntersectionObserver(entries => { | |
entries.forEach(entry => { | |
if (entry.isIntersecting) { | |
animateCounter(entry.target); | |
ioCounters.unobserve(entry.target); | |
} | |
}); | |
}, { threshold: 0.5 }); | |
counters.forEach(el => ioCounters.observe(el)); | |
function animateCounter(el) { | |
const raw = el.dataset.target; | |
const isMoney = el.textContent.includes('US$'); | |
const suffix = el.textContent.trim().endsWith('%') ? '%' : (isMoney ? 'b' : ''); | |
const target = parseFloat(raw); | |
const duration = 1200; | |
const start = performance.now(); | |
function frame(t) { | |
const k = Math.min(1, (t - start) / duration); | |
const v = target * (0.2 + 0.8 * easeOutCubic(k)); | |
el.textContent = (isMoney ? 'US$' : '') + v.toFixed(target < 10 ? 2 : 1) + (suffix ? (suffix === 'b' ? 'b' : '%') : ''); | |
if (k < 1) requestAnimationFrame(frame); | |
else el.textContent = (isMoney ? 'US$' : '') + target + (suffix ? (suffix === 'b' ? 'b' : '%') : ''); | |
} | |
requestAnimationFrame(frame); | |
} | |
function easeOutCubic(x) { return 1 - Math.pow(1 - x, 3); } | |
/* Lightweight SVG chart utilities (no frameworks) */ | |
function createSVG(w, h) { | |
const svg = document.createElementNS('http://www.w3.org/2000/svg','svg'); | |
svg.setAttribute('viewBox', `0 0 ${w} ${h}`); | |
svg.setAttribute('preserveAspectRatio', 'none'); | |
return svg; | |
} | |
function linePath(points) { | |
return points.map((p,i) => (i?'L':'M') + p[0] + ' ' + p[1]).join(' '); | |
} | |
function addTooltip(viz) { | |
let tip = viz.querySelector('.tooltip'); | |
if (!tip) { | |
tip = document.createElement('div'); | |
tip.className = 'tooltip'; | |
viz.appendChild(tip); | |
} | |
return tip; | |
} | |
function svgText(svg, x, y, text, opts={}) { | |
const t = document.createElementNS('http://www.w3.org/2000/svg','text'); | |
t.setAttribute('x', x); | |
t.setAttribute('y', y); | |
t.textContent = text; | |
for (const k in opts) t.setAttribute(k, opts[k]); | |
svg.appendChild(t); | |
} | |
/* Chart: Bar (GDP Q1/Q2/H1) */ | |
function renderGDPBar(container) { | |
const viz = container.querySelector('.viz'); | |
viz.innerHTML = ''; | |
const w = 800, h = 450, pad = {l:60,r:20,t:30,b:50}; | |
const svg = createSVG(w, h); | |
viz.appendChild(svg); | |
const data = [ | |
{label:'Q1 2025', val:6.9, color:'var(--accent)'}, | |
{label:'Q2 2025', val:7.96, color:'var(--accent)'}, | |
{label:'H1 2025', val:7.52, color:'var(--primary)'} | |
]; | |
const max = Math.ceil(Math.max(...data.map(d=>d.val)) + 1); | |
// axes | |
const axis = document.createElementNS('http://www.w3.org/2000/svg','g'); | |
axis.setAttribute('stroke','currentColor'); | |
axis.setAttribute('opacity','0.4'); | |
const ySteps = 6; | |
for (let i=0;i<=ySteps;i++){ | |
const y = pad.t + (h-pad.t-pad.b) * (i/ySteps); | |
const line = document.createElementNS('http://www.w3.org/2000/svg','line'); | |
line.setAttribute('x1', pad.l); | |
line.setAttribute('x2', w - pad.r); | |
line.setAttribute('y1', y); | |
line.setAttribute('y2', y); | |
axis.appendChild(line); | |
const val = Math.round((max) * (1 - i/ySteps)*10)/10; | |
svgText(svg, pad.l-8, y+4, val+'%', { 'text-anchor':'end', 'font-size':'12' }); | |
} | |
svg.appendChild(axis); | |
const barW = (w-pad.l-pad.r) / data.length * 0.5; | |
const tip = addTooltip(viz); | |
data.forEach((d, i) => { | |
const xCenter = pad.l + (w-pad.l-pad.r) * ((i+0.5)/data.length); | |
const x = xCenter - barW/2; | |
const y = pad.t + (h-pad.t-pad.b) * (1 - d.val/max); | |
const rect = document.createElementNS('http://www.w3.org/2000/svg','rect'); | |
rect.setAttribute('x', x); | |
rect.setAttribute('y', y); | |
rect.setAttribute('width', barW); | |
rect.setAttribute('height', (h-pad.t-pad.b) * (d.val/max)); | |
rect.setAttribute('fill', getComputedStyle(document.documentElement).getPropertyValue(d.color.replace(/[^\w-]/g,'')) || d.color); | |
rect.setAttribute('rx','8'); | |
rect.style.filter = 'drop-shadow(0 6px 10px rgba(0,0,0,.15))'; | |
rect.addEventListener('mousemove', (e)=> { | |
tip.textContent = `${d.label}: ${d.val}%`; | |
tip.style.left = e.offsetX + 'px'; | |
tip.style.top = e.offsetY + 'px'; | |
tip.style.opacity = 1; | |
}); | |
rect.addEventListener('mouseleave', ()=> tip.style.opacity = 0); | |
svg.appendChild(rect); | |
svgText(svg, xCenter, h-pad.b+18, d.label, {'text-anchor':'middle','font-size':'12'}); | |
}); | |
} | |
/* Chart: Inflation actual vs forecasts */ | |
function renderInflationLines(container) { | |
const viz = container.querySelector('.viz'); | |
viz.innerHTML = ''; | |
const w = 800, h = 450, pad = {l:50,r:20,t:20,b:40}; | |
const svg = createSVG(w, h); | |
viz.appendChild(svg); | |
const actual = [ | |
{label:'May', val:3.24}, | |
{label:'Jun', val:3.57} | |
]; | |
const forecasts = [ | |
{label:'IMF', val:2.9, color:'var(--warning)'}, | |
{label:'ADB', val:4.0, color:'var(--primary)'} | |
]; | |
const labels = ['May','Jun','IMF','ADB']; | |
const allVals = [...actual.map(d=>d.val), ...forecasts.map(d=>d.val)]; | |
const min = Math.min(...allVals, 0), max = Math.max(...allVals) + 0.5; | |
// gridlines | |
const ySteps = 6; | |
const axis = document.createElementNS('http://www.w3.org/2000/svg','g'); | |
axis.setAttribute('stroke','currentColor'); | |
axis.setAttribute('opacity','0.35'); | |
for (let i=0;i<=ySteps;i++){ | |
const y = pad.t + (h-pad.t-pad.b) * (i/ySteps); | |
const line = document.createElementNS('http://www.w3.org/2000/svg','line'); | |
line.setAttribute('x1', pad.l); | |
line.setAttribute('x2', w - pad.r); | |
line.setAttribute('y1', y); | |
line.setAttribute('y2', y); | |
axis.appendChild(line); | |
const val = (max - (max-min) * (i/ySteps)).toFixed(1); | |
svgText(svg, pad.l-6, y+4, val+'%', {'text-anchor':'end','font-size':'12'}); | |
} | |
svg.appendChild(axis); | |
// X positions | |
const x = (idx) => pad.l + (w-pad.l-pad.r) * (idx/(labels.length-1)); | |
const y = (val) => pad.t + (h-pad.t-pad.b) * (1 - (val-min)/(max-min)); | |
const tip = addTooltip(viz); | |
// actual line | |
const points = actual.map((d,i)=>[x(i), y(d.val)]); | |
const path = document.createElementNS('http://www.w3.org/2000/svg','path'); | |
path.setAttribute('d', linePath(points)); | |
path.setAttribute('fill','none'); | |
path.setAttribute('stroke','var(--accent)'); | |
path.setAttribute('stroke-width','3'); | |
svg.appendChild(path); | |
actual.forEach((d,i)=>{ | |
const cx = x(i), cy = y(d.val); | |
const c = document.createElementNS('http://www.w3.org/2000/svg','circle'); | |
c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', 5); | |
c.setAttribute('fill','var(--accent)'); | |
c.addEventListener('mousemove', (e) => { | |
tip.textContent = `${d.label} 2025: ${d.val}%`; | |
tip.style.left = e.offsetX + 'px'; | |
tip.style.top = e.offsetY + 'px'; | |
tip.style.opacity = 1; | |
}); | |
c.addEventListener('mouseleave', ()=> tip.style.opacity = 0); | |
svg.appendChild(c); | |
svgText(svg, cx, h-pad.b+16, d.label, {'text-anchor':'middle','font-size':'12'}); | |
}); | |
// forecasts points | |
forecasts.forEach((d, i) => { | |
const cx = x(i + actual.length), cy = y(d.val); | |
const c = document.createElementNS('http://www.w3.org/2000/svg','rect'); | |
c.setAttribute('x', cx-6); c.setAttribute('y', cy-6); c.setAttribute('width', 12); c.setAttribute('height', 12); c.setAttribute('rx','2'); | |
c.setAttribute('fill', getComputedStyle(document.documentElement).getPropertyValue(d.color.replace(/[^\w-]/g,'')) || d.color); | |
c.addEventListener('mousemove', (e) => { | |
tip.textContent = `${d.label} 2025 Forecast: ${d.val}%`; | |
tip.style.left = e.offsetX + 'px'; | |
tip.style.top = e.offsetY + 'px'; | |
tip.style.opacity = 1; | |
}); | |
c.addEventListener('mouseleave', ()=> tip.style.opacity = 0); | |
svg.appendChild(c); | |
svgText(svg, cx, h-pad.b+16, d.label, {'text-anchor':'middle','font-size':'12'}); | |
}); | |
} | |
/* Chart: Donut (Unemployment) */ | |
function renderUnempDonut(container) { | |
const viz = container.querySelector('.viz'); | |
viz.innerHTML = ''; | |
const w = 450, h = 450; | |
const svg = createSVG(w, h); | |
viz.appendChild(svg); | |
const center = {x: w/2, y: h/2}; | |
const radius = 150; | |
const val = 2.2; // % | |
const max = 10; // gauge cap | |
// bg circle | |
const bg = document.createElementNS('http://www.w3.org/2000/svg','circle'); | |
bg.setAttribute('cx', center.x); | |
bg.setAttribute('cy', center.y); | |
bg.setAttribute('r', radius); | |
bg.setAttribute('fill','none'); | |
bg.setAttribute('stroke','color-mix(in oklab, var(--muted) 25%, transparent)'); | |
bg.setAttribute('stroke-width','26'); | |
svg.appendChild(bg); | |
// arc | |
const angle = Math.min(360, (val/max)*360); | |
const large = angle > 180 ? 1 : 0; | |
const start = polar(center.x, center.y, radius, -90); | |
const end = polar(center.x, center.y, radius, -90 + angle); | |
const d = `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${large} 1 ${end.x} ${end.y}`; | |
const path = document.createElementNS('http://www.w3.org/2000/svg','path'); | |
path.setAttribute('d', d); | |
path.setAttribute('fill','none'); | |
path.setAttribute('stroke','var(--success)'); | |
path.setAttribute('stroke-width','26'); | |
path.setAttribute('stroke-linecap','round'); | |
svg.appendChild(path); | |
svgText(svg, center.x, center.y, val.toFixed(2)+'%', {'text-anchor':'middle','font-size':'34','font-weight':'700'}); | |
svgText(svg, center.x, center.y+26, 'Unemployment (Q1 2025)', {'text-anchor':'middle','font-size':'14', 'opacity':'0.75'}); | |
function polar(cx, cy, r, deg) { | |
const rad = (deg) * Math.PI/180; | |
return {x: cx + r*Math.cos(rad), y: cy + r*Math.sin(rad)}; | |
} | |
} | |
/* Chart: FDI columns */ | |
function renderFDIColumns(container) { | |
const viz = container.querySelector('.viz'); | |
viz.innerHTML = ''; | |
const w = 800, h = 450, pad = {l:70,r:20,t:30,b:60}; | |
const svg = createSVG(w, h); | |
viz.appendChild(svg); | |
const data = [ | |
{label:'Registered (JanβMay)', val:18.4, color:'var(--accent)'}, | |
{label:'Disbursed (JanβMay)', val:8.9, color:'var(--primary)'}, | |
{label:'Total (H1)', val:21.51, color:'var(--success)'} | |
]; | |
const max = Math.ceil(Math.max(...data.map(d=>d.val)) + 2); | |
// grid | |
const ySteps = 6; | |
const axis = document.createElementNS('http://www.w3.org/2000/svg','g'); | |
axis.setAttribute('stroke','currentColor'); | |
axis.setAttribute('opacity','0.35'); | |
for (let i=0;i<=ySteps;i++){ | |
const y = pad.t + (h-pad.t-pad.b) * (i/ySteps); | |
const line = document.createElementNS('http://www.w3.org/2000/svg','line'); | |
line.setAttribute('x1', pad.l); | |
line.setAttribute('x2', w - pad.r); | |
line.setAttribute('y1', y); | |
line.setAttribute('y2', y); | |
axis.appendChild(line); | |
const val = Math.round((max) * (1 - i/ySteps)*10)/10; | |
svgText(svg, pad.l-8, y+4, '$' + val + 'b', {'text-anchor':'end','font-size':'12'}); | |
} | |
svg.appendChild(axis); | |
const barW = (w-pad.l-pad.r) / data.length * 0.4; | |
const tip = addTooltip(viz); | |
data.forEach((d, i) => { | |
const xCenter = pad.l + (w-pad.l-pad.r) * ((i+0.5)/data.length); | |
const x = xCenter - barW/2; | |
const y = pad.t + (h-pad.t-pad.b) * (1 - d.val/max); | |
const rect = document.createElementNS('http://www.w3.org/2000/svg','rect'); | |
rect.setAttribute('x', x); | |
rect.setAttribute('y', y); | |
rect.setAttribute('width', barW); | |
rect.setAttribute('height', (h-pad.t-pad.b) * (d.val/max)); | |
rect.setAttribute('fill', getComputedStyle(document.documentElement).getPropertyValue(d.color.replace(/[^\w-]/g,'')) || d.color); | |
rect.setAttribute('rx','8'); | |
rect.style.filter = 'drop-shadow(0 6px 10px rgba(0,0,0,.15))'; | |
rect.addEventListener('mousemove', (e)=> { | |
tip.textContent = `${d.label}: US$${d.val}b`; | |
tip.style.left = e.offsetX + 'px'; | |
tip.style.top = e.offsetY + 'px'; | |
tip.style.opacity = 1; | |
}); | |
rect.addEventListener('mouseleave', ()=> tip.style.opacity = 0); | |
svg.appendChild(rect); | |
svgText(svg, xCenter, h-pad.b+30, d.label, {'text-anchor':'middle','font-size':'12'}); | |
}); | |
} | |
/* Chart: Historical Line */ | |
function renderHistorical(container) { | |
const viz = container.querySelector('.viz'); | |
viz.innerHTML = ''; | |
const w = 800, h = 450, pad = {l:60,r:20,t:20,b:50}; | |
const svg = createSVG(w, h); | |
viz.appendChild(svg); | |
const data = [ | |
{year:2020, val:3.21}, | |
{year:2021, val:4.85}, | |
{year:2022, val:5.42}, | |
{year:2023, val:3.46}, | |
{year:2024, val:5.98}, | |
{year:2025, val:6.93} | |
]; | |
const min = 0, max = Math.ceil(Math.max(...data.map(d=>d.val)) + 1); | |
const x = (i)=> pad.l + (w-pad.l-pad.r) * (i/(data.length-1)); | |
const y = (v)=> pad.t + (h-pad.t-pad.b) * (1 - (v-min)/(max-min)); | |
// grid | |
const ySteps = 6; | |
const axis = document.createElementNS('http://www.w3.org/2000/svg','g'); | |
axis.setAttribute('stroke','currentColor'); | |
axis.setAttribute('opacity','0.35'); | |
for (let i=0;i<=ySteps;i++){ | |
const yy = pad.t + (h-pad.t-pad.b) * (i/ySteps); | |
const line = document.createElementNS('http://www.w3.org/2000/svg','line'); | |
line.setAttribute('x1', pad.l); | |
line.setAttribute('x2', w - pad.r); | |
line.setAttribute('y1', yy); | |
line.setAttribute('y2', yy); | |
axis.appendChild(line); | |
const val = Math.round((max) * (1 - i/ySteps)*10)/10; | |
svgText(svg, pad.l-8, yy+4, val+'%', {'text-anchor':'end','font-size':'12'}); | |
} | |
svg.appendChild(axis); | |
// line | |
const pts = data.map((d,i)=>[x(i), y(d.val)]); | |
const path = document.createElementNS('http://www.w3.org/2000/svg','path'); | |
path.setAttribute('d', linePath(pts)); | |
path.setAttribute('fill','none'); | |
path.setAttribute('stroke','var(--accent)'); | |
path.setAttribute('stroke-width','3'); | |
svg.appendChild(path); | |
const tip = addTooltip(viz); | |
data.forEach((d,i)=>{ | |
const cx=x(i), cy=y(d.val); | |
const c = document.createElementNS('http://www.w3.org/2000/svg','circle'); | |
c.setAttribute('cx', cx); c.setAttribute('cy', cy); c.setAttribute('r', 5); | |
c.setAttribute('fill','var(--accent)'); | |
c.addEventListener('mousemove', (e)=>{ | |
tip.textContent = `Q1 ${d.year}: ${d.val}%`; | |
tip.style.left = e.offsetX + 'px'; tip.style.top = e.offsetY + 'px'; tip.style.opacity = 1; | |
}); | |
c.addEventListener('mouseleave', ()=> tip.style.opacity = 0); | |
svg.appendChild(c); | |
svgText(svg, cx, h-pad.b+18, d.year, {'text-anchor':'middle','font-size':'12'}); | |
}); | |
} | |
/* Chart: Forecasts vs Target range */ | |
function renderForecasts(container) { | |
const viz = container.querySelector('.viz'); | |
viz.innerHTML = ''; | |
const w = 800, h = 450, pad = {l:90,r:40,t:30,b:40}; | |
const svg = createSVG(w, h); | |
viz.appendChild(svg); | |
const items = [ | |
{label:'IMF', val:5.2}, | |
{label:'World Bank', val:5.8}, | |
{label:'ADB', val:6.6} | |
]; | |
const targetRange = [8.3, 8.5]; | |
const max = Math.max(targetRange[1], ...items.map(i=>i.val)) + 1; | |
const min = 0; | |
const y = (i)=> pad.t + (h-pad.t-pad.b) * (i/(items.length-1)); | |
const x = (v)=> pad.l + (w-pad.l-pad.r) * ((v - min)/(max - min)); | |
// target band | |
const band = document.createElementNS('http://www.w3.org/2000/svg','rect'); | |
band.setAttribute('x', x(targetRange[0])); | |
band.setAttribute('y', pad.t); | |
band.setAttribute('width', x(targetRange[1]) - x(targetRange[0])); | |
band.setAttribute('height', h - pad.t - pad.b); | |
band.setAttribute('fill', 'color-mix(in oklab, var(--warning) 25%, transparent)'); | |
band.setAttribute('rx','8'); | |
svg.appendChild(band); | |
// grid x | |
const xSteps = 8; | |
const axis = document.createElementNS('http://www.w3.org/2000/svg','g'); | |
axis.setAttribute('stroke','currentColor'); axis.setAttribute('opacity','0.35'); | |
for (let i=0;i<=xSteps;i++){ | |
const xx = pad.l + (w-pad.l-pad.r) * (i/xSteps); | |
const line = document.createElementNS('http://www.w3.org/2000/svg','line'); | |
line.setAttribute('x1', xx); line.setAttribute('x2', xx); | |
line.setAttribute('y1', pad.t); line.setAttribute('y2', h - pad.b); | |
axis.appendChild(line); | |
const val = (min + (max-min) * (i/xSteps)).toFixed(1); | |
svgText(svg, xx, h - pad.b + 18, val+'%', {'text-anchor':'middle','font-size':'12'}); | |
} | |
svg.appendChild(axis); | |
// points | |
const tip = addTooltip(viz); | |
items.forEach((it, i) => { | |
const cx = x(it.val), cy = y(i); | |
const g = document.createElementNS('http://www.w3.org/2000/svg','g'); | |
const mark = document.createElementNS('http://www.w3.org/2000/svg','circle'); | |
mark.setAttribute('cx', cx); mark.setAttribute('cy', cy); mark.setAttribute('r', 7); | |
mark.setAttribute('fill','var(--accent)'); | |
const label = document.createElementNS('http://www.w3.org/2000/svg','text'); | |
label.setAttribute('x', pad.l - 10); label.setAttribute('y', cy + 4); | |
label.setAttribute('text-anchor','end'); label.setAttribute('font-size','13'); | |
label.textContent = it.label; | |
g.appendChild(mark); g.appendChild(label); | |
g.addEventListener('mousemove', (e)=> { | |
tip.textContent = `${it.label}: ${it.val}%`; | |
tip.style.left = e.offsetX + 'px'; | |
tip.style.top = e.offsetY + 'px'; | |
tip.style.opacity = 1; | |
}); | |
g.addEventListener('mouseleave', ()=> tip.style.opacity = 0); | |
svg.appendChild(g); | |
}); | |
// target text | |
svgText(svg, x(targetRange[0]), pad.t + 18, `Gov target ${targetRange[0]}β${targetRange[1]}%`, {'font-size':'12'}); | |
} | |
/* Render all charts */ | |
function renderAllCharts() { | |
renderGDPBar(document.getElementById('chart-gdp')); | |
renderInflationLines(document.getElementById('chart-inflation')); | |
renderUnempDonut(document.getElementById('chart-unemp')); | |
renderFDIColumns(document.getElementById('chart-fdi')); | |
renderHistorical(document.getElementById('chart-historical')); | |
renderForecasts(document.getElementById('chart-forecasts')); | |
} | |
renderAllCharts(); | |
/* Resize observer to re-render charts on container resize for crispness */ | |
let resizeTimer; | |
window.addEventListener('resize', () => { | |
clearTimeout(resizeTimer); | |
resizeTimer = setTimeout(renderAllCharts, 200); | |
}); | |
/* Download chart as PNG (convert SVG inside .viz) */ | |
document.querySelectorAll('.dl-chart').forEach(btn => { | |
btn.addEventListener('click', () => { | |
const wrap = document.getElementById(btn.dataset.download).querySelector('.viz'); | |
const svg = wrap.querySelector('svg'); | |
if (!svg) { toast('No SVG to export'); return; } | |
const s = new XMLSerializer().serializeToString(svg); | |
const svgBlob = new Blob([s], {type:'image/svg+xml;charset=utf-8'}); | |
const url = URL.createObjectURL(svgBlob); | |
const img = new Image(); | |
img.onload = () => { | |
const canvas = document.createElement('canvas'); | |
canvas.width = 1600; canvas.height = 900; // upscale for clarity | |
const ctx = canvas.getContext('2d'); | |
// background for contrast | |
ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--surface') || '#fff'; | |
ctx.fillRect(0,0,canvas.width,canvas.height); | |
ctx.drawImage(img,0,0,canvas.width,canvas.height); | |
canvas.toBlob(blob => { | |
const a = document.createElement('a'); | |
a.href = URL.createObjectURL(blob); | |
a.download = btn.dataset.download + '.png'; | |
document.body.appendChild(a); | |
a.click(); | |
a.remove(); | |
URL.revokeObjectURL(url); | |
}); | |
}; | |
img.src = url; | |
}); | |
}); | |
/* Simple toast */ | |
let toastTimer; | |
function toast(msg) { | |
let el = document.getElementById('toast'); | |
if (!el) { | |
el = document.createElement('div'); | |
el.id = 'toast'; | |
Object.assign(el.style, { | |
position:'fixed', left:'50%', bottom:'24px', transform:'translateX(-50%)', | |
background:'var(--card)', color:'var(--text)', padding:'10px 14px', | |
border:'1px solid rgba(128,128,128,.3)', borderRadius:'10px', boxShadow:'var(--shadow)', | |
zIndex:10000, opacity:'0', transition:'opacity .2s' | |
}); | |
document.body.appendChild(el); | |
} | |
el.textContent = msg; | |
el.style.opacity = '1'; | |
clearTimeout(toastTimer); | |
toastTimer = setTimeout(()=> el.style.opacity = '0', 1600); | |
} | |
/* Preserve scroll to section in URL hash and TOC clicks */ | |
document.querySelectorAll('.toc a').forEach(a => { | |
a.addEventListener('click', (e) => { | |
// allow default, but also store preference | |
localStorage.setItem('lastSection', a.getAttribute('href')); | |
}); | |
}); | |
/* Accessibility: keyboard focus for skip */ | |
document.addEventListener('keydown', (e) => { | |
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { | |
e.preventDefault(); | |
searchInput.focus(); | |
} | |
}); | |
</script> | |
</body> | |
</html> |