Spaces:
Running
Running
<html lang="en" data-theme="light"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<title>Vietnam Economic Growth Report 2025 — Interactive Research Dashboard</title> | |
<meta name="description" content="Comprehensive analytical dashboard for Vietnam's 2025 economic performance: GDP, inflation, unemployment, FDI, sectoral analysis, forecasts, and citations." /> | |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> | |
<script src="https://unpkg.com/feather-icons"></script> | |
<style> | |
:root { | |
--bg: #0b0f14; | |
--panel: #11161d; | |
--elev: #0f141b; | |
--text: #eaf2ff; | |
--muted: #a8b2c2; | |
--accent: #3ab5ff; | |
--accent-2: #00ffa3; | |
--accent-3: #ff7a59; | |
--bdr: rgba(255,255,255,0.08); | |
--ok: #2ecc71; | |
--warn: #f1c40f; | |
--err: #e74c3c; | |
--shadow: 0 10px 30px rgba(0,0,0,0.35), 0 1px 0 rgba(255,255,255,0.02) inset; | |
--radius: 14px; | |
--radius-sm: 10px; | |
--radius-lg: 22px; | |
--space-1: 0.35rem; | |
--space-2: 0.6rem; | |
--space-3: 0.9rem; | |
--space-4: 1.25rem; | |
--space-5: 2rem; | |
--maxw: 1400px; | |
--toc-w: 280px; | |
--font-size: clamp(15px, 1.25vw, 17px); | |
--h1: clamp(1.8rem, 5vw, 3rem); | |
--h2: clamp(1.3rem, 3.2vw, 2rem); | |
--h3: clamp(1.15rem, 2.2vw, 1.4rem); | |
--kbd-bg: #0a0d12; | |
--link: #7cc8ff; | |
--mark: rgba(255, 235, 59, .25); | |
--chip: #1a2330; | |
} | |
[data-theme="light"] { | |
--bg: #f6f8fb; | |
--panel: #ffffff; | |
--elev: #ffffff; | |
--text: #0f1726; | |
--muted: #475569; | |
--accent: #0066ff; | |
--accent-2: #10b981; | |
--accent-3: #ff5a3c; | |
--bdr: rgba(0,0,0,0.08); | |
--shadow: 0 8px 28px rgba(12, 17, 29, 0.07), 0 1px 0 rgba(0,0,0,0.03) inset; | |
--kbd-bg: #ecf1f8; | |
--link: #0056d6; | |
--chip: #eef4ff; | |
} | |
* { box-sizing: border-box; } | |
html { scroll-behavior: smooth; } | |
body { | |
margin: 0; font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; | |
background: radial-gradient(1200px 600px at 10% -10%, rgba(58,181,255,0.15), transparent 40%), | |
radial-gradient(900px 500px at 110% 10%, rgba(0,255,163,0.12), transparent 40%), | |
var(--bg); | |
color: var(--text); font-size: var(--font-size); line-height: 1.5; | |
} | |
a { color: var(--link); text-decoration: none; } | |
a:hover { text-decoration: underline; } | |
kbd { | |
background: var(--kbd-bg); border: 1px solid var(--bdr); | |
border-bottom-width: 2px; border-radius: 6px; padding: 0 .35rem; font-size: .85em; | |
box-shadow: var(--shadow); | |
} | |
.wrap { | |
display: grid; | |
grid-template-columns: 1fr; | |
gap: var(--space-5); | |
max-width: var(--maxw); | |
margin: 0 auto; | |
padding: clamp(10px, 1.8vw, 22px); | |
} | |
header.app { | |
position: sticky; top: 0; z-index: 50; backdrop-filter: saturate(1.2) blur(10px); | |
background: color-mix(in hsl, var(--bg) 70%, transparent); | |
border-bottom: 1px solid var(--bdr); | |
} | |
.topbar { | |
max-width: var(--maxw); | |
margin: 0 auto; padding: .6rem clamp(10px, 1.8vw, 22px); display: flex; gap: .75rem; align-items: center; justify-content: space-between; | |
} | |
.brand { display: flex; align-items: center; gap: .75rem; font-weight: 800; letter-spacing: .2px; } | |
.brand .logo { | |
width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; | |
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: white; | |
box-shadow: 0 10px 20px color-mix(in oklab, var(--accent) 25%, transparent); | |
} | |
.toolbar { display: flex; align-items: center; gap: .5rem; } | |
.searchbar { | |
display: flex; align-items: center; gap: .5rem; background: var(--panel); | |
border: 1px solid var(--bdr); border-radius: 999px; padding: .35rem .65rem; width: min(540px, 55vw); | |
box-shadow: var(--shadow); | |
} | |
.searchbar input { | |
border: none; outline: none; background: transparent; width: 100%; color: var(--text); | |
font-size: .95rem; | |
} | |
.btn { | |
display: inline-flex; align-items: center; gap: .5rem; border: 1px solid var(--bdr); | |
background: var(--panel); color: var(--text); padding: .45rem .75rem; border-radius: 10px; cursor: pointer; | |
box-shadow: var(--shadow); | |
} | |
.btn.primary { background: linear-gradient(180deg, color-mix(in oklab, var(--accent) 18%, transparent), transparent), var(--panel); border-color: color-mix(in oklab, var(--accent) 35%, var(--bdr)); } | |
.btn.ghost { background: transparent; } | |
.btn:active { transform: translateY(1px); } | |
.progressbar { | |
position: absolute; left: 0; bottom: 0; height: 3px; width: 0%; | |
background: linear-gradient(90deg, var(--accent), var(--accent-2)); transition: width .2s ease; | |
} | |
.layout { | |
display: grid; grid-template-columns: 1fr; gap: var(--space-5); | |
} | |
aside.toc { | |
position: sticky; top: 70px; align-self: start; border: 1px solid var(--bdr); | |
background: var(--panel); border-radius: var(--radius); padding: var(--space-4); | |
box-shadow: var(--shadow); | |
max-height: calc(100vh - 90px); overflow: auto; | |
} | |
.toc h4 { margin: 0 0 .5rem 0; font-size: .95rem; color: var(--muted); display: flex; align-items: center; gap: .4rem; } | |
.toc nav a { | |
display: block; padding: .4rem .5rem; margin: .1rem 0; border-radius: 8px; color: var(--text); | |
text-decoration: none; border: 1px solid transparent; | |
} | |
.toc nav a:hover { background: color-mix(in oklab, var(--accent) 16%, transparent); } | |
.toc nav a.active { | |
border-color: color-mix(in oklab, var(--accent) 40%, transparent); | |
background: color-mix(in oklab, var(--accent) 10%, transparent); | |
} | |
.main { | |
display: grid; gap: var(--space-5); | |
} | |
.hero { | |
display: grid; gap: var(--space-4); border: 1px solid var(--bdr); | |
background: linear-gradient(160deg, color-mix(in oklab, var(--accent) 6%, transparent), transparent 60%), var(--panel); | |
border-radius: var(--radius-lg); padding: clamp(14px, 2.2vw, 30px); box-shadow: var(--shadow); | |
} | |
.hero h1 { font-size: var(--h1); line-height: 1.1; margin: 0; letter-spacing: -0.02em; } | |
.subhead { color: var(--muted); font-weight: 500; } | |
.kpi-grid { | |
display: grid; gap: var(--space-4); | |
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); | |
container-type: inline-size; container-name: kpi; | |
} | |
.kpi { | |
background: var(--elev); border: 1px solid var(--bdr); border-radius: 14px; padding: var(--space-4); | |
display: grid; gap: .5rem; position: relative; overflow: clip; box-shadow: var(--shadow); | |
} | |
.kpi .value { font-size: clamp(1.4rem, 4vw, 2rem); font-weight: 800; letter-spacing: -0.02em; } | |
.kpi .label { color: var(--muted); font-weight: 600; font-size: .9rem; } | |
.kpi .delta { font-size: .85rem; display: inline-flex; align-items: center; gap: .3rem; border-radius: 999px; padding: .15rem .5rem; background: color-mix(in oklab, var(--accent-2) 12%, transparent); color: var(--accent-2); } | |
.kpi .actions { margin-top: .35rem; display: flex; gap: .35rem; flex-wrap: wrap; } | |
.kpi .bg { | |
pointer-events: none; content: ""; position: absolute; inset: auto -20% -35% auto; width: 140px; height: 140px; | |
background: radial-gradient(60% 60% at 50% 50%, color-mix(in oklab, var(--accent) 35%, transparent), transparent); | |
border-radius: 50%; | |
opacity: .35; | |
} | |
@container kpi (min-width: 240px) { | |
.kpi .value { font-size: clamp(1.6rem, 3.4cqi, 2.2rem); } | |
} | |
.section { | |
border: 1px solid var(--bdr); background: var(--panel); border-radius: var(--radius); | |
padding: clamp(14px, 2vw, 26px); box-shadow: var(--shadow); | |
} | |
.section h2 { font-size: var(--h2); line-height: 1.15; margin: 0 0 .5rem 0; scroll-margin-top: 80px; } | |
.section .summary { | |
background: color-mix(in oklab, var(--accent) 10%, transparent); border: 1px dashed color-mix(in oklab, var(--accent) 45%, var(--bdr)); | |
padding: .75rem .9rem; border-radius: 12px; color: var(--text); | |
} | |
.section .chips { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .5rem; } | |
.chip { | |
background: var(--chip); color: var(--text); padding: .35rem .6rem; border-radius: 999px; border: 1px solid var(--bdr); | |
font-size: .85rem; display: inline-flex; align-items: center; gap: .35rem; | |
} | |
.with-toolbar { display: grid; gap: var(--space-3); } | |
.toolbar-row { display: flex; align-items: center; justify-content: space-between; gap: .75rem; flex-wrap: wrap; } | |
.tools { display: flex; gap: .5rem; flex-wrap: wrap; } | |
.grid { | |
display: grid; gap: var(--space-4); | |
grid-template-columns: 1fr; | |
} | |
@media (min-width: 768px) { | |
.layout { grid-template-columns: minmax(0, 1fr) 300px; } | |
.grid.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
.grid.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
} | |
@media (min-width: 1024px) { | |
.layout { grid-template-columns: 280px minmax(0, 1fr); column-gap: var(--space-5); } | |
.grid.cols-3-xl { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
} | |
@media (min-width: 1440px) { | |
.layout { grid-template-columns: var(--toc-w) minmax(0, 1fr); } | |
} | |
.chart-card { | |
background: var(--elev); border: 1px solid var(--bdr); border-radius: var(--radius); padding: 1rem; | |
display: grid; gap: .5rem; box-shadow: var(--shadow); | |
container-type: inline-size; container-name: chart; | |
} | |
.chart-card h3 { margin: 0; font-size: var(--h3); } | |
.chart { | |
width: 100%; aspect-ratio: 16 / 9; background: | |
linear-gradient(0deg, transparent 24%, color-mix(in oklab, var(--text) 2%, transparent) 25% 26%, transparent 27%), | |
linear-gradient(90deg, transparent 24%, color-mix(in oklab, var(--text) 2%, transparent) 25% 26%, transparent 27%); | |
background-size: 40px 40px; | |
border-radius: 12px; border: 1px solid var(--bdr); display: grid; place-items: center; position: relative; overflow: hidden; | |
} | |
.legend { display: flex; flex-wrap: wrap; gap: .6rem; font-size: .85rem; color: var(--muted); } | |
.legend .key { display: inline-flex; align-items: center; gap: .35rem; } | |
.legend .sw { width: 10px; height: 10px; border-radius: 50%; } | |
.collapsible { | |
border: 1px solid var(--bdr); border-radius: 12px; overflow: hidden; | |
background: var(--elev); | |
} | |
.collapsible summary { | |
list-style: none; cursor: pointer; padding: .75rem 1rem; font-weight: 600; | |
display: flex; align-items: center; justify-content: space-between; gap: .5rem; | |
border-bottom: 1px solid var(--bdr); | |
} | |
.collapsible summary::-webkit-details-marker { display: none; } | |
.collapsible .content { padding: 1rem; } | |
.table-wrap { overflow: auto; border: 1px solid var(--bdr); border-radius: 12px; background: var(--elev); } | |
table { | |
width: 100%; border-collapse: collapse; min-width: 680px; | |
} | |
th, td { | |
text-align: left; padding: .7rem .9rem; border-bottom: 1px solid var(--bdr); | |
} | |
th { | |
position: sticky; top: 0; background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 65%, transparent), transparent), var(--elev); | |
font-weight: 700; user-select: none; cursor: pointer; | |
} | |
tr:hover td { background: color-mix(in oklab, var(--accent) 7%, transparent); } | |
.notice { | |
background: color-mix(in oklab, var(--warn) 18%, transparent); | |
border: 1px solid color-mix(in oklab, var(--warn) 40%, var(--bdr)); | |
padding: .75rem .9rem; border-radius: 12px; color: var(--text); | |
} | |
.info { | |
background: color-mix(in oklab, var(--accent) 12%, transparent); | |
border: 1px solid color-mix(in oklab, var(--accent) 42%, var(--bdr)); | |
padding: .75rem .9rem; border-radius: 12px; | |
} | |
.foot { | |
display: grid; gap: .75rem; color: var(--muted); | |
border-top: 1px solid var(--bdr); padding: var(--space-4) 0 var(--space-5); | |
} | |
.pill { | |
display: inline-flex; align-items: center; gap: .4rem; padding: .2rem .5rem; border-radius: 999px; | |
background: color-mix(in oklab, var(--accent-2) 12%, transparent); | |
border: 1px solid color-mix(in oklab, var(--accent-2) 40%, var(--bdr)); | |
color: var(--text); font-size: .85rem; | |
} | |
.meta-row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; } | |
.small { font-size: .88rem; color: var(--muted); } | |
.highlight { background: var(--mark); border-radius: 4px; padding: 0 .15rem; } | |
.sticky-tools { | |
position: sticky; bottom: 12px; z-index: 40; display: grid; justify-items: end; | |
} | |
.floater { | |
display: inline-flex; gap: .5rem; padding: .5rem; background: var(--panel); border: 1px solid var(--bdr); border-radius: 14px; | |
box-shadow: var(--shadow); | |
} | |
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; } | |
@media (max-width: 480px) { | |
.searchbar { width: 100%; } | |
.brand .title { display: none; } | |
} | |
</style> | |
</head> | |
<body> | |
<header class="app"> | |
<div class="topbar"> | |
<div class="brand"> | |
<div class="logo" aria-hidden="true"><i data-feather="activity"></i></div> | |
<div class="title">Vietnam Economic Growth Report 2025</div> | |
</div> | |
<div class="toolbar"> | |
<div class="searchbar" role="search"> | |
<i data-feather="search" aria-hidden="true"></i> | |
<input id="globalSearch" type="search" placeholder="Search the report (Press / to focus)" aria-label="Search" /> | |
<button class="btn ghost" id="clearSearch" title="Clear search"><i data-feather="x-circle"></i></button> | |
</div> | |
<button class="btn" id="themeToggle" title="Toggle theme"> | |
<i data-feather="moon"></i> | |
<span class="hide@sm">Theme</span> | |
</button> | |
<button class="btn primary" id="printBtn" title="Export to PDF"> | |
<i data-feather="download-cloud"></i> | |
Export | |
</button> | |
</div> | |
</div> | |
<div class="progressbar" id="progressbar"></div> | |
</header> | |
<main class="wrap"> | |
<div class="layout"> | |
<aside class="toc" id="toc"> | |
<h4><i data-feather="list"></i> On this page</h4> | |
<nav id="tocLinks"></nav> | |
<div class="small" style="margin-top:.75rem;">Tip: Press / to search. Use ↑ ↓ to navigate results.</div> | |
</aside> | |
<div class="main"> | |
<section class="hero"> | |
<h1>Vietnam 2025: Momentum With Vigilance</h1> | |
<div class="subhead">GDP surged 7.96% in Q2 and 7.52% in H1—best first-half since 2011—powered by industry and services. Inflation is contained, unemployment remains low, and FDI inflows are robust amid global headwinds.</div> | |
<div class="kpi-grid" id="kpiGrid"> | |
<div class="kpi"> | |
<div class="bg"></div> | |
<div class="label">GDP Growth Q2 2025 (y/y)</div> | |
<div class="value" data-kpi="gdp_q2">7.96%</div> | |
<div class="delta"><i data-feather="trending-up"></i> Above Q1</div> | |
<div class="actions"> | |
<button class="btn ghost copy-kpi" data-copy="GDP Q2 2025: 7.96%">Copy</button> | |
<button class="btn ghost link-kpi" data-link="#gdp-indicators">Jump</button> | |
</div> | |
</div> | |
<div class="kpi"> | |
<div class="bg"></div> | |
<div class="label">GDP Growth H1 2025</div> | |
<div class="value" data-kpi="gdp_h1">7.52%</div> | |
<div class="delta"><i data-feather="award"></i> Highest since 2011</div> | |
<div class="actions"> | |
<button class="btn ghost copy-kpi" data-copy="GDP H1 2025: 7.52%">Copy</button> | |
<button class="btn ghost link-kpi" data-link="#gdp-indicators">Jump</button> | |
</div> | |
</div> | |
<div class="kpi"> | |
<div class="bg"></div> | |
<div class="label">Inflation (June 2025)</div> | |
<div class="value">3.57%</div> | |
<div class="delta" style="color:var(--warn); background: color-mix(in oklab, var(--warn) 14%, transparent);"> | |
<i data-feather="chevron-up"></i> Highest YTD | |
</div> | |
<div class="actions"> | |
<button class="btn ghost copy-kpi" data-copy="Inflation June 2025: 3.57%">Copy</button> | |
<button class="btn ghost link-kpi" data-link="#inflation">Jump</button> | |
</div> | |
</div> | |
<div class="kpi"> | |
<div class="bg"></div> | |
<div class="label">Unemployment (Q1 2025)</div> | |
<div class="value">2.20%</div> | |
<div class="delta" style="color:var(--ok); background: color-mix(in oklab, var(--ok) 14%, transparent);"> | |
<i data-feather="trending-down"></i> From Q4 2024 | |
</div> | |
<div class="actions"> | |
<button class="btn ghost copy-kpi" data-copy="Unemployment Q1 2025: 2.20%">Copy</button> | |
<button class="btn ghost link-kpi" data-link="#labor">Jump</button> | |
</div> | |
</div> | |
<div class="kpi"> | |
<div class="bg"></div> | |
<div class="label">FDI (H1 2025)</div> | |
<div class="value">$21.51B</div> | |
<div class="delta" style="color:var(--accent-2); background: color-mix(in oklab, var(--accent-2) 14%, transparent);"> | |
<i data-feather="plus-circle"></i> +32.6% y/y | |
</div> | |
<div class="actions"> | |
<button class="btn ghost copy-kpi" data-copy="FDI H1 2025: $21.51B (+32.6% y/y)">Copy</button> | |
<button class="btn ghost link-kpi" data-link="#fdi">Jump</button> | |
</div> | |
</div> | |
<div class="kpi"> | |
<div class="bg"></div> | |
<div class="label">Retail Sales (Q1 2025)</div> | |
<div class="value">₫1,708T</div> | |
<div class="delta"><i data-feather="shopping-bag"></i> +9.9% y/y</div> | |
<div class="actions"> | |
<button class="btn ghost copy-kpi" data-copy="Retail Q1 2025: ₫1,708T (+9.9% y/y)">Copy</button> | |
<button class="btn ghost link-kpi" data-link="#sectoral">Jump</button> | |
</div> | |
</div> | |
</div> | |
</section> | |
<section class="section with-toolbar" id="executive-summary" data-title="Executive Summary"> | |
<div class="toolbar-row"> | |
<h2>Executive Summary</h2> | |
<div class="tools"> | |
<button class="btn ghost copy-section" data-target="executive-summary"><i data-feather="clipboard"></i> Copy</button> | |
<button class="btn ghost link-section" data-target="executive-summary"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<div class="summary"> | |
Vietnam’s economy sustained robust momentum in 2025. GDP expanded 7.96% y/y in Q2 and 7.52% in H1 (best since 2011). Growth was anchored by services and manufacturing, while inflation remained within the 3–4.5% target band. Unemployment stayed historically low at 2.20%, and FDI inflows accelerated, signaling strong investor confidence despite external frictions from trade tensions and tariffs. | |
</div> | |
<ul> | |
<li>Growth leadership: services, manufacturing, and export industries; banking earnings seen up 17% with ~15% credit growth.</li> | |
<li>Risks: global trade tensions, tariff exposure, geopolitical uncertainty, and FDI overdependence concerns.</li> | |
<li>Policy stance: diversify markets, support domestic demand, preserve macro stability, and use fiscal space if needed.</li> | |
</ul> | |
</section> | |
<section class="section with-toolbar" id="gdp-indicators" data-title="Key Economic Indicators"> | |
<div class="toolbar-row"> | |
<h2>Key Economic Indicators 2025</h2> | |
<div class="tools"> | |
<button class="btn ghost" id="downloadData"><i data-feather="download"></i> Data CSV</button> | |
<button class="btn ghost link-section" data-target="gdp-indicators"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<div class="grid cols-2"> | |
<div class="chart-card"> | |
<h3>GDP Growth Performance</h3> | |
<div class="legend"> | |
<span class="key"><span class="sw" style="background:var(--accent)"></span> Actual</span> | |
<span class="key"><span class="sw" style="background:var(--warn)"></span> Gov Target 8.3–8.5%</span> | |
</div> | |
<div class="chart" id="chart-gdp-bars" role="img" aria-label="Bar chart of GDP growth for Q1, Q2, and H1 2025"></div> | |
<div class="tools"> | |
<button class="btn ghost export-chart" data-chart="chart-gdp-bars"><i data-feather="image"></i> Save PNG</button> | |
<div class="chip"><i data-feather="filter"></i> | |
<label style="display:inline-flex;gap:.35rem;align-items:center;"> | |
<input type="checkbox" id="toggleTargetBand" checked /> Target band | |
</label> | |
</div> | |
</div> | |
</div> | |
<div class="chart-card"> | |
<h3>2025 GDP Growth Forecasts</h3> | |
<div class="legend"> | |
<span class="key"><span class="sw" style="background:var(--accent-3)"></span> International</span> | |
<span class="key"><span class="sw" style="background:var(--accent-2)"></span> Government target</span> | |
</div> | |
<div class="chart" id="chart-forecasts" role="img" aria-label="Dot plot of GDP forecasts"></div> | |
<div class="tools"> | |
<div class="chip"><i data-feather="sliders"></i> Min forecast | |
<input type="range" id="forecastMin" min="0" max="9" step="0.1" value="0" style="width:160px;"> | |
<span id="forecastMinVal" class="small">0%</span> | |
</div> | |
<button class="btn ghost export-chart" data-chart="chart-forecasts"><i data-feather="image"></i> Save PNG</button> | |
</div> | |
</div> | |
</div> | |
<details class="collapsible" style="margin-top:1rem;"> | |
<summary> | |
<span>Indicator Table (Sortable)</span> | |
<i data-feather="chevron-down"></i> | |
</summary> | |
<div class="content"> | |
<div class="table-wrap"> | |
<table id="tblIndicators"> | |
<thead> | |
<tr> | |
<th data-sort="text">Indicator</th> | |
<th data-sort="text">Period</th> | |
<th data-sort="num">Value</th> | |
<th data-sort="text">Notes</th> | |
</tr> | |
</thead> | |
<tbody> | |
<tr><td>GDP Growth</td><td>Q1 2025</td><td data-v="6.9">6.9%</td><td>y/y</td></tr> | |
<tr><td>GDP Growth</td><td>Q2 2025</td><td data-v="7.96">7.96%</td><td>y/y</td></tr> | |
<tr><td>GDP Growth</td><td>H1 2025</td><td data-v="7.52">7.52%</td><td>Highest since 2011</td></tr> | |
<tr><td>Inflation</td><td>May 2025</td><td data-v="3.24">3.24%</td><td>YTD</td></tr> | |
<tr><td>Inflation</td><td>June 2025</td><td data-v="3.57">3.57%</td><td>YTD high</td></tr> | |
<tr><td>Unemployment</td><td>Q1 2025</td><td data-v="2.2">2.20%</td><td>Down from 2.22%</td></tr> | |
<tr><td>FDI Registered</td><td>Jan–May 2025</td><td data-v="18.4">$18.4B</td><td>+51% y/y</td></tr> | |
<tr><td>FDI Disbursed</td><td>Jan–May 2025</td><td data-v="8.9">$8.9B</td><td> </td></tr> | |
<tr><td>FDI Total</td><td>H1 2025</td><td data-v="21.51">$21.51B</td><td>+32.6% y/y</td></tr> | |
</tbody> | |
</table> | |
</div> | |
</div> | |
</details> | |
</section> | |
<section class="section with-toolbar" id="inflation" data-title="Inflation Dynamics"> | |
<div class="toolbar-row"> | |
<h2>Inflation Rate</h2> | |
<div class="tools"> | |
<span class="pill"><i data-feather="target"></i> Target: 3–4.5%</span> | |
<button class="btn ghost link-section" data-target="inflation"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<p>Inflation remains well-controlled within the 3–4.5% target range. June’s 3.57% is the highest YTD but consistent with a manageable trajectory. External cost pressures from trade tensions are a watch point.</p> | |
<div class="grid cols-2"> | |
<div class="chart-card"> | |
<h3>Inflation Recent Prints & Forecasts</h3> | |
<div class="legend"> | |
<span class="key"><span class="sw" style="background:var(--accent)"></span> Actual</span> | |
<span class="key"><span class="sw" style="background:var(--accent-3)"></span> IMF 2025: 2.9%</span> | |
<span class="key"><span class="sw" style="background:var(--warn)"></span> ADB 2025: 4.0%</span> | |
</div> | |
<div class="chart" id="chart-inflation"></div> | |
<div class="tools"> | |
<button class="btn ghost export-chart" data-chart="chart-inflation"><i data-feather="image"></i> Save PNG</button> | |
</div> | |
</div> | |
<div class="chart-card"> | |
<h3>Labor Market Snapshot</h3> | |
<div class="legend"> | |
<span class="key"><span class="sw" style="background:var(--accent-2)"></span> Unemployment</span> | |
</div> | |
<div class="chart" id="chart-labor"></div> | |
<div class="tools"> | |
<button class="btn ghost export-chart" data-chart="chart-labor"><i data-feather="image"></i> Save PNG</button> | |
</div> | |
</div> | |
</div> | |
</section> | |
<section class="section with-toolbar" id="fdi" data-title="Foreign Direct Investment"> | |
<div class="toolbar-row"> | |
<h2>Foreign Direct Investment</h2> | |
<div class="tools"> | |
<button class="btn ghost link-section" data-target="fdi"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<p>FDI inflows are robust, reflecting sustained foreign investor confidence. Registered capital surged while disbursements kept pace, and H1 totals surpassed $21B.</p> | |
<div class="grid cols-2"> | |
<div class="chart-card"> | |
<h3>FDI Momentum</h3> | |
<div class="legend"> | |
<span class="key"><span class="sw" style="background:var(--accent)"></span> Registered (Jan–May)</span> | |
<span class="key"><span class="sw" style="background:var(--accent-3)"></span> Disbursed (Jan–May)</span> | |
<span class="key"><span class="sw" style="background:var(--accent-2)"></span> Total (H1)</span> | |
</div> | |
<div class="chart" id="chart-fdi"></div> | |
<div class="tools"> | |
<button class="btn ghost export-chart" data-chart="chart-fdi"><i data-feather="image"></i> Save PNG</button> | |
</div> | |
</div> | |
<div class="section" style="margin:0;"> | |
<h3>Investor Takeaways</h3> | |
<ul> | |
<li>Strength across diversified manufacturing and services clusters.</li> | |
<li>Policy continuity and infrastructure investments support pipeline.</li> | |
<li>Monitor sectoral concentration and supply-chain dependencies.</li> | |
</ul> | |
<div class="info">Policy note: Maintain macro buffers; prioritize quality FDI with technology transfer and value chain deepening.</div> | |
</div> | |
</div> | |
</section> | |
<section class="section with-toolbar" id="sectoral" data-title="Sectoral Analysis"> | |
<div class="toolbar-row"> | |
<h2>Sectoral Analysis</h2> | |
<div class="tools"> | |
<button class="btn ghost link-section" data-target="sectoral"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<div class="chips" id="sectorFilters"> | |
<label class="chip"><input type="checkbox" data-sector="services" checked> Services</label> | |
<label class="chip"><input type="checkbox" data-sector="manufacturing" checked> Manufacturing</label> | |
<label class="chip"><input type="checkbox" data-sector="export" checked> Export Industries</label> | |
<label class="chip"><input type="checkbox" data-sector="banking" checked> Banking</label> | |
</div> | |
<div class="grid cols-2 cols-3-xl" id="sectorCards"> | |
<div class="chart-card sector services"> | |
<h3>Services</h3> | |
<p>Primary growth engine with broad-based expansion across retail, logistics, tourism, and digital services.</p> | |
</div> | |
<div class="chart-card sector manufacturing"> | |
<h3>Manufacturing</h3> | |
<p>Recovery maintained with strong export orders; supply-chain realignment favors Vietnam’s cost-quality mix.</p> | |
</div> | |
<div class="chart-card sector export"> | |
<h3>Export Industries</h3> | |
<p>Remain the economic backbone; competitiveness intact despite tariff-related frictions.</p> | |
</div> | |
<div class="chart-card sector banking"> | |
<h3>Banking</h3> | |
<p>Earnings projected +17% in 2025 on ~15% system-wide credit growth; watch asset quality under external stress.</p> | |
</div> | |
</div> | |
</section> | |
<section class="section with-toolbar" id="risks" data-title="Challenges and Risks"> | |
<div class="toolbar-row"> | |
<h2>Challenges and Risk Factors</h2> | |
<div class="tools"> | |
<button class="btn ghost link-section" data-target="risks"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<ul> | |
<li>Global trade tensions and US tariff policies pressure export-oriented sectors.</li> | |
<li>Geopolitical instability raises uncertainty and potential cost pass-through.</li> | |
<li>Overreliance on FDI could amplify cyclicality and external shocks.</li> | |
<li>Imperative: growth not at the cost of macro stability, public debt, or inflation.</li> | |
</ul> | |
<div class="notice"><strong>Mitigation:</strong> diversify export markets, strengthen domestic demand, enhance resilience, and maintain fiscal space for counter-cyclical support.</div> | |
</section> | |
<section class="section with-toolbar" id="history" data-title="Historical Comparison"> | |
<div class="toolbar-row"> | |
<h2>Historical Comparison</h2> | |
<div class="tools"> | |
<button class="btn ghost link-section" data-target="history"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<p>Vietnam sustained high growth in 2024 (7.1%). Growth in 2025 may moderate versus ambitious targets due to external constraints, but fundamentals remain resilient.</p> | |
<div class="chart-card"> | |
<h3>Q1 GDP Growth 2020–2025 (y/y)</h3> | |
<div class="legend"> | |
<span class="key"><span class="sw" style="background:var(--accent)"></span> Q1 Growth</span> | |
<span class="key"><span class="sw" style="background:var(--accent-2)"></span> Trend</span> | |
</div> | |
<div class="chart" id="chart-history"></div> | |
<div class="tools"> | |
<button class="btn ghost export-chart" data-chart="chart-history"><i data-feather="image"></i> Save PNG</button> | |
</div> | |
</div> | |
</section> | |
<section class="section with-toolbar" id="outlook" data-title="Economic Outlook"> | |
<div class="toolbar-row"> | |
<h2>Economic Outlook and Projections</h2> | |
<div class="tools"> | |
<button class="btn ghost link-section" data-target="outlook"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<div class="grid cols-2"> | |
<div> | |
<h3>Near-term Prospects (2025)</h3> | |
<p>Solid starting point in Q1 (6.9% y/y) with resilience expected despite uncertainty. Government’s 8.3–8.5% target is ambitious vs international forecasts, but domestic drivers provide ballast.</p> | |
<h4>Key Supporting Factors</h4> | |
<ul> | |
<li>Robust FDI inflows and investor confidence</li> | |
<li>Low unemployment supporting consumption</li> | |
<li>Controlled inflation sustaining purchasing power</li> | |
<li>Export competitiveness and market diversification</li> | |
<li>Parliamentary push to raise GDP growth to at least 8%</li> | |
</ul> | |
</div> | |
<div> | |
<h3>Policy Playbook</h3> | |
<ul> | |
<li>Diversify export markets and deepen regional value chains.</li> | |
<li>Strengthen domestic demand through targeted measures.</li> | |
<li>Enhance resilience: buffers, liquidity backstops, prudent credit growth.</li> | |
<li>Use fiscal space counter-cyclically if global shocks intensify.</li> | |
</ul> | |
<div class="info">Bottom line: Cautiously optimistic baseline with upside from supply-chain shifts; vigilance on external risks is warranted.</div> | |
</div> | |
</div> | |
</section> | |
<section class="section with-toolbar" id="conclusion" data-title="Conclusion"> | |
<div class="toolbar-row"> | |
<h2>Conclusion</h2> | |
<div class="tools"> | |
<button class="btn ghost link-section" data-target="conclusion"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<p>Vietnam’s 2025 performance highlights resilience and strong fundamentals: low unemployment, contained inflation, and buoyant FDI. While international projections are more conservative than the government’s 8%+ target, momentum and reform commitment provide a solid backdrop for sustained development.</p> | |
</section> | |
<section class="section with-toolbar" id="methodology" data-title="Methodology"> | |
<div class="toolbar-row"> | |
<h2>Research Methodology</h2> | |
<div class="tools"> | |
<button class="btn ghost link-section" data-target="methodology"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<details class="collapsible"> | |
<summary>Scope, Sources, and Processing <i data-feather="chevron-down"></i></summary> | |
<div class="content"> | |
<ul> | |
<li>Scope: Macroeconomic indicators for Vietnam 2024–2025 with emphasis on Q1/Q2 2025 and H1 aggregates.</li> | |
<li>Sources: IMF, ADB, World Bank, GSO Vietnam, Trading Economics, Vietnam Investment Review, and related portals.</li> | |
<li>Processing: Extracted key values, normalized units, and constructed trend series for visualization. Forecasts aggregated for cross-comparison.</li> | |
<li>Validation: Cross-checked ranges and narratives against cited sources; flagged assumptions where applicable.</li> | |
</ul> | |
</div> | |
</details> | |
</section> | |
<section class="section with-toolbar" id="references" data-title="Sources & Citations"> | |
<div class="toolbar-row"> | |
<h2>Sources and Citations</h2> | |
<div class="tools"> | |
<button class="btn ghost link-section" data-target="references"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<div id="citationList" class="grid"> | |
<div class="table-wrap"> | |
<table> | |
<thead> | |
<tr><th>#</th><th>Source</th><th>URL</th><th>Actions</th></tr> | |
</thead> | |
<tbody> | |
<tr><td>1</td><td>Trading Economics - Vietnam GDP Annual Growth Rate</td><td><a href="https://tradingeconomics.com/vietnam/gdp-growth-annual" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="Trading Economics - Vietnam GDP Annual Growth Rate: https://tradingeconomics.com/vietnam/gdp-growth-annual"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>2</td><td>International Monetary Fund - Vietnam Country Profile</td><td><a href="https://www.imf.org/en/Countries/VNM" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="IMF - Vietnam Country Profile: https://www.imf.org/en/Countries/VNM"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>3</td><td>World Economics - Vietnam GDP Estimates</td><td><a href="https://www.worldeconomics.com/GDP/Vietnam.gdp" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="World Economics - Vietnam GDP Estimates: https://www.worldeconomics.com/GDP/Vietnam.gdp"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>4</td><td>General Statistics Office (Vietnam)</td><td><a href="https://www.gso.gov.vn/en/" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="General Statistics Office (Vietnam): https://www.gso.gov.vn/en/"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>5</td><td>Wikipedia - Economy of Vietnam</td><td><a href="https://en.wikipedia.org/wiki/Economy_of_Vietnam" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="Wikipedia - Economy of Vietnam: https://en.wikipedia.org/wiki/Economy_of_Vietnam"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>6</td><td>IMF - Vietnam and the IMF</td><td><a href="https://www.imf.org/en/Countries/VNM" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="IMF - Vietnam and the IMF: https://www.imf.org/en/Countries/VNM"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>7</td><td>FocusEconomics - Vietnam Economic Indicators</td><td><a href="https://www.focus-economics.com/countries/vietnam" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="FocusEconomics - Vietnam Economic Indicators: https://www.focus-economics.com/countries/vietnam"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>8</td><td>GSO Vietnam - Data & Statistics</td><td><a href="https://www.gso.gov.vn/en/data-and-statistics/" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="GSO Vietnam - Data & Statistics: https://www.gso.gov.vn/en/data-and-statistics/"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>9</td><td>VietnamNet - Economic News</td><td><a href="https://vietnamnet.vn/" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="VietnamNet - Economic News: https://vietnamnet.vn/"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>10</td><td>IMF - Article IV Mission Reports</td><td><a href="https://www.imf.org/en/Publications/CR" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="IMF - Article IV Mission Reports: https://www.imf.org/en/Publications/CR"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>11</td><td>Vietnam Briefing - Analysis</td><td><a href="https://www.vietnam-briefing.com/" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="Vietnam Briefing - Economic Analysis: https://www.vietnam-briefing.com/"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>12</td><td>Vietnam Investment Review - FDI</td><td><a href="https://vir.com.vn/" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="Vietnam Investment Review - FDI Statistics: https://vir.com.vn/"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>13</td><td>Trading Economics - FDI</td><td><a href="https://tradingeconomics.com/vietnam/foreign-direct-investment" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="Trading Economics - Vietnam FDI: https://tradingeconomics.com/vietnam/foreign-direct-investment"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>14</td><td>White & Case - Regional Outlook</td><td><a href="https://www.whitecase.com/" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="White & Case - Regional Economic Outlook: https://www.whitecase.com/"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>15</td><td>Vietnam Economic Times</td><td><a href="https://vneconomictimes.com/" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="Vietnam Economic Times: https://vneconomictimes.com/"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>16</td><td>Asian Development Bank - Viet Nam</td><td><a href="https://www.adb.org/countries/viet-nam/main" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="ADB - Viet Nam Country Partnership: https://www.adb.org/countries/viet-nam/main"><i data-feather="copy"></i> Copy</button></td></tr> | |
<tr><td>17</td><td>Ministry of Planning and Investment</td><td><a href="https://www.mpi.gov.vn/en/" target="_blank" rel="noopener">Link</a></td><td><button class="btn ghost copy-cite" data-cite="Ministry of Planning and Investment (Vietnam): https://www.mpi.gov.vn/en/"><i data-feather="copy"></i> Copy</button></td></tr> | |
</tbody> | |
</table> | |
</div> | |
</div> | |
</section> | |
<section class="section with-toolbar" id="appendix" data-title="Appendices"> | |
<div class="toolbar-row"> | |
<h2>Appendices</h2> | |
<div class="tools"> | |
<button class="btn ghost link-section" data-target="appendix"><i data-feather="link-2"></i> Link</button> | |
</div> | |
</div> | |
<details class="collapsible"> | |
<summary>Data Dictionary <i data-feather="chevron-down"></i></summary> | |
<div class="content"> | |
<ul> | |
<li>GDP Growth: Real GDP y/y change, quarterly and half-year aggregate.</li> | |
<li>Inflation: Headline CPI y/y.</li> | |
<li>Unemployment: National unemployment rate.</li> | |
<li>FDI: Registered and disbursed capital in USD; total H1 in USD.</li> | |
</ul> | |
</div> | |
</details> | |
<details class="collapsible" style="margin-top:.75rem;"> | |
<summary>Assumptions & Notes <i data-feather="chevron-down"></i></summary> | |
<div class="content"> | |
<ul> | |
<li>Q1 historical series reflects reported year-on-year expansions 2020–2025.</li> | |
<li>Forecast comparison normalizes to calendar 2025 growth.</li> | |
</ul> | |
</div> | |
</details> | |
</section> | |
<footer class="foot"> | |
<div class="meta-row"> | |
<span class="pill"><i data-feather="shield"></i> Stable macro</span> | |
<span class="pill"><i data-feather="cpu"></i> Manufacturing hub</span> | |
<span class="pill"><i data-feather="trending-up"></i> FDI momentum</span> | |
</div> | |
<div class="small">© 2025 Research Dashboard. Built with vanilla Web APIs. Use the Export button to print to PDF or save charts as PNG.</div> | |
</footer> | |
<div class="sticky-tools"> | |
<div class="floater"> | |
<button class="btn" id="backToTop"><i data-feather="arrow-up"></i> Top</button> | |
<button class="btn" id="nextSection"><i data-feather="chevron-down"></i> Next</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
</main> | |
<script> | |
feather.replace(); | |
// State & Data | |
const state = { | |
theme: localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'), | |
search: { index: [], matches: [], current: -1 }, | |
charts: {} | |
}; | |
document.documentElement.setAttribute('data-theme', state.theme); | |
// Datasets | |
const data = { | |
gdpBars: [ | |
{label: 'Q1 2025', value: 6.9}, | |
{label: 'Q2 2025', value: 7.96}, | |
{label: 'H1 2025', value: 7.52} | |
], | |
forecast: [ | |
{name: 'World Bank', value: 5.8, type: 'intl'}, | |
{name: 'ADB', value: 6.6, type: 'intl'}, | |
{name: 'IMF', value: 5.2, type: 'intl'}, | |
{name: 'Gov Target Lower', value: 8.3, type: 'gov'}, | |
{name: 'Gov Target Upper', value: 8.5, type: 'gov'} | |
], | |
inflation: [ | |
{month: 'May 2025', value: 3.24}, | |
{month: 'June 2025', value: 3.57} | |
], | |
inflationForecasts: [ | |
{name: 'IMF 2025', value: 2.9, color: 'var(--accent-3)'}, | |
{name: 'ADB 2025', value: 4.0, color: 'var(--warn)'} | |
], | |
unemployment: [ | |
{label: 'Q4 2024', value: 2.22}, | |
{label: 'Q1 2025', value: 2.20} | |
], | |
fdi: [ | |
{label: 'Registered (Jan–May)', value: 18.4, color: 'var(--accent)'}, | |
{label: 'Disbursed (Jan–May)', value: 8.9, color: 'var(--accent-3)'}, | |
{label: 'Total (H1)', value: 21.51, color: 'var(--accent-2)'} | |
], | |
historyQ1: [ | |
{year: 2020, value: 3.21}, | |
{year: 2021, value: 4.85}, | |
{year: 2022, value: 5.42}, | |
{year: 2023, value: 3.46}, | |
{year: 2024, value: 5.98}, | |
{year: 2025, value: 6.93} | |
] | |
}; | |
// Utility: formatters | |
const fmt = { | |
pct: v => `${(Math.round(v * 100) / 100).toFixed(2)}%`, | |
moneyB: v => `$${v.toFixed(2)}B` | |
}; | |
// Theme toggle | |
document.getElementById('themeToggle').addEventListener('click', () => { | |
state.theme = (state.theme === 'light') ? 'dark' : 'light'; | |
document.documentElement.setAttribute('data-theme', state.theme); | |
localStorage.setItem('theme', state.theme); | |
}); | |
// Print/Export | |
document.getElementById('printBtn').addEventListener('click', () => { | |
window.print(); | |
}); | |
// Build TOC | |
const sections = Array.from(document.querySelectorAll('.section, .hero')).map(el => { | |
if (!el.id) el.id = 'section-' + Math.random().toString(36).slice(2,7); | |
return el; | |
}); | |
const tocLinks = document.getElementById('tocLinks'); | |
const tocFrag = document.createDocumentFragment(); | |
sections.forEach(sec => { | |
const title = sec.dataset.title || sec.querySelector('h1,h2')?.textContent?.trim() || 'Section'; | |
const a = document.createElement('a'); | |
a.href = `#${sec.id}`; | |
a.textContent = title; | |
tocFrag.appendChild(a); | |
}); | |
tocLinks.appendChild(tocFrag); | |
// IntersectionObserver: active section & progress | |
const progress = document.getElementById('progressbar'); | |
const linkMap = new Map(Array.from(tocLinks.querySelectorAll('a')).map(a => [a.getAttribute('href').slice(1), a])); | |
const io = new IntersectionObserver(entries => { | |
entries.forEach(entry => { | |
if (entry.isIntersecting) { | |
linkMap.forEach(a => a.classList.remove('active')); | |
const a = linkMap.get(entry.target.id); | |
if (a) a.classList.add('active'); | |
} | |
}); | |
const viewport = window.scrollY + window.innerHeight; | |
const doc = document.body.scrollHeight; | |
const pct = Math.min(100, Math.max(0, (viewport / doc) * 100)); | |
progress.style.width = pct + '%'; | |
}, {rootMargin: '-40% 0px -55% 0px', threshold: [0, 0.25, 0.5, 1]}); | |
sections.forEach(sec => io.observe(sec)); | |
// Smooth link buttons | |
document.querySelectorAll('.link-section').forEach(btn => btn.addEventListener('click', e => { | |
const id = e.currentTarget.dataset.target; | |
location.hash = id; | |
document.getElementById(id)?.scrollIntoView({behavior: 'smooth', block: 'start'}); | |
})); | |
document.querySelectorAll('.link-kpi').forEach(btn => btn.addEventListener('click', e => { | |
const id = e.currentTarget.dataset.link.slice(1); | |
document.getElementById(id)?.scrollIntoView({behavior: 'smooth', block: 'start'}); | |
})); | |
// Copy KPI buttons | |
const copyText = async (txt) => { | |
try { | |
await navigator.clipboard.writeText(txt); | |
toast('Copied to clipboard'); | |
} catch { | |
const ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); | |
ta.select(); document.execCommand('copy'); ta.remove(); toast('Copied'); | |
} | |
}; | |
document.querySelectorAll('.copy-kpi').forEach(btn => btn.addEventListener('click', e => { | |
copyText(e.currentTarget.dataset.copy); | |
})); | |
document.querySelectorAll('.copy-section').forEach(btn => btn.addEventListener('click', e => { | |
const id = e.currentTarget.dataset.target; | |
const el = document.getElementById(id); | |
copyText(el.innerText.trim()); | |
})); | |
document.querySelectorAll('.copy-cite').forEach(btn => btn.addEventListener('click', e => { | |
copyText(e.currentTarget.dataset.cite); | |
})); | |
// Back to top & Next section | |
document.getElementById('backToTop').addEventListener('click', () => window.scrollTo({top:0, behavior:'smooth'})); | |
document.getElementById('nextSection').addEventListener('click', () => { | |
const y = window.scrollY; | |
const next = sections.find(sec => sec.getBoundingClientRect().top + window.scrollY > y + 20); | |
next?.scrollIntoView({behavior: 'smooth', block: 'start'}); | |
}); | |
// Search with highlight and navigation | |
const searchInput = document.getElementById('globalSearch'); | |
const clearSearch = () => { | |
document.querySelectorAll('.highlight').forEach(n => { | |
const parent = n.parentNode; parent.replaceChild(document.createTextNode(n.textContent), n); parent.normalize(); | |
}); | |
state.search.matches = []; state.search.current = -1; | |
}; | |
document.getElementById('clearSearch').addEventListener('click', () => { | |
searchInput.value = ''; clearSearch(); | |
}); | |
const highlightAll = (term) => { | |
clearSearch(); | |
if (!term) return; | |
const walker = document.createTreeWalker(document.querySelector('main'), NodeFilter.SHOW_TEXT, { | |
acceptNode: node => { | |
const t = node.nodeValue.trim(); | |
if (!t || node.parentElement.closest('script,style,svg,code,pre,button,nav,header,footer')) return NodeFilter.FILTER_REJECT; | |
return t.toLowerCase().includes(term.toLowerCase()) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; | |
} | |
}); | |
const nodes = []; | |
while (walker.nextNode()) nodes.push(walker.currentNode); | |
nodes.forEach(textNode => { | |
const val = textNode.nodeValue; | |
const idx = val.toLowerCase().indexOf(term.toLowerCase()); | |
if (idx > -1) { | |
const span = document.createElement('span'); | |
span.className = 'highlight'; | |
span.textContent = val.substr(idx, term.length); | |
const before = document.createTextNode(val.substr(0, idx)); | |
const after = document.createTextNode(val.substr(idx + term.length)); | |
const parent = textNode.parentNode; | |
parent.replaceChild(after, textNode); | |
parent.insertBefore(span, after); | |
parent.insertBefore(before, span); | |
} | |
}); | |
state.search.matches = Array.from(document.querySelectorAll('.highlight')); | |
state.search.current = -1; | |
if (state.search.matches.length) gotoMatch(0); | |
}; | |
const gotoMatch = (i) => { | |
if (!state.search.matches.length) return; | |
state.search.current = (i + state.search.matches.length) % state.search.matches.length; | |
const el = state.search.matches[state.search.current]; | |
el.scrollIntoView({behavior:'smooth', block:'center'}); | |
el.animate([{background: 'var(--mark)'}, {background: 'transparent'}], {duration: 1500, fill: 'forwards'}); | |
}; | |
searchInput.addEventListener('input', e => highlightAll(e.target.value.trim())); | |
window.addEventListener('keydown', (e) => { | |
if (e.key === '/' && document.activeElement !== searchInput) { | |
e.preventDefault(); searchInput.focus(); searchInput.select(); | |
} else if ((e.key === 'Enter' || e.key === 'ArrowDown') && state.search.matches.length) { | |
gotoMatch(state.search.current + 1); | |
} else if (e.key === 'ArrowUp' && state.search.matches.length) { | |
gotoMatch(state.search.current - 1); | |
} | |
}); | |
// Sortable tables | |
const sortTable = (table, colIndex, type, asc) => { | |
const tbody = table.tBodies[0]; | |
const rows = Array.from(tbody.querySelectorAll('tr')); | |
rows.sort((a,b) => { | |
const av = a.children[colIndex].dataset.v ?? a.children[colIndex].textContent; | |
const bv = b.children[colIndex].dataset.v ?? b.children[colIndex].textContent; | |
if (type === 'num') return (parseFloat(av) - parseFloat(bv)) * (asc ? 1 : -1); | |
return av.toString().localeCompare(bv.toString()) * (asc ? 1 : -1); | |
}); | |
rows.forEach(r => tbody.appendChild(r)); | |
}; | |
document.querySelectorAll('table thead th').forEach((th, i) => { | |
let asc = true; | |
th.addEventListener('click', () => { | |
sortTable(th.closest('table'), i, th.dataset.sort, asc); | |
asc = !asc; | |
}); | |
}); | |
// Export data CSV | |
document.getElementById('downloadData').addEventListener('click', () => { | |
const rows = [ | |
['Indicator','Period','Value','Notes'], | |
['GDP Growth','Q1 2025','6.9%','y/y'], | |
['GDP Growth','Q2 2025','7.96%','y/y'], | |
['GDP Growth','H1 2025','7.52%','Highest since 2011'], | |
['Inflation','May 2025','3.24%','YTD'], | |
['Inflation','June 2025','3.57%','YTD high'], | |
['Unemployment','Q1 2025','2.20%','Down from 2.22%'], | |
['FDI Registered','Jan–May 2025','$18.4B','+51% y/y'], | |
['FDI Disbursed','Jan–May 2025','$8.9B',''], | |
['FDI Total','H1 2025','$21.51B','+32.6% y/y'] | |
]; | |
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n'); | |
const blob = new Blob([csv], {type: 'text/csv;charset=utf-8;'}); | |
const url = URL.createObjectURL(blob); | |
const a = document.createElement('a'); a.href = url; a.download = 'vietnam_2025_indicators.csv'; | |
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); | |
}); | |
// Sector filters | |
const sectorFilters = document.getElementById('sectorFilters'); | |
sectorFilters.addEventListener('change', () => { | |
const active = new Set(Array.from(sectorFilters.querySelectorAll('input:checked')).map(i => i.dataset.sector)); | |
document.querySelectorAll('#sectorCards .sector').forEach(card => { | |
const cat = [...card.classList].find(c => ['services','manufacturing','export','banking'].includes(c)); | |
card.style.display = active.has(cat) ? '' : 'none'; | |
}); | |
localStorage.setItem('sectorFilters', JSON.stringify([...active])); | |
}); | |
// Restore sector filters | |
const savedFilters = JSON.parse(localStorage.getItem('sectorFilters') || 'null'); | |
if (savedFilters) { | |
sectorFilters.querySelectorAll('input').forEach(i => i.checked = savedFilters.includes(i.dataset.sector)); | |
sectorFilters.dispatchEvent(new Event('change')); | |
} | |
// Small toasts | |
let toastTimer; | |
function toast(msg) { | |
clearTimeout(toastTimer); | |
let el = document.getElementById('toast'); | |
if (!el) { | |
el = document.createElement('div'); | |
el.id = 'toast'; | |
el.style.position = 'fixed'; | |
el.style.bottom = '18px'; | |
el.style.left = '50%'; | |
el.style.transform = 'translateX(-50%)'; | |
el.style.background = 'var(--panel)'; | |
el.style.border = '1px solid var(--bdr)'; | |
el.style.boxShadow = 'var(--shadow)'; | |
el.style.color = 'var(--text)'; | |
el.style.padding = '.6rem .9rem'; | |
el.style.borderRadius = '10px'; | |
el.style.zIndex = '999'; | |
document.body.appendChild(el); | |
} | |
el.textContent = msg; | |
el.style.opacity = '1'; | |
toastTimer = setTimeout(() => { el.style.transition = 'opacity .4s'; el.style.opacity = '0'; }, 1500); | |
} | |
// Charts: SVG helpers | |
function createSVG(w, h) { | |
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); | |
svg.setAttribute('viewBox', `0 0 ${w} ${h}`); | |
svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); | |
return svg; | |
} | |
function axis(svg, x, y, w, h, ticks=5, max=10, format=v=>v.toString()) { | |
const g = document.createElementNS(svg.namespaceURI, 'g'); | |
g.setAttribute('stroke', 'currentColor'); | |
g.setAttribute('opacity', '.5'); | |
for (let i=0;i<=ticks;i++) { | |
const ty = y + h - (i/ticks)*h; | |
const line = document.createElementNS(svg.namespaceURI, 'line'); | |
line.setAttribute('x1', x); line.setAttribute('x2', x+w); | |
line.setAttribute('y1', ty); line.setAttribute('y2', ty); | |
line.setAttribute('stroke-width', i===0?1.2:0.6); | |
g.appendChild(line); | |
const label = document.createElementNS(svg.namespaceURI, 'text'); | |
label.setAttribute('x', x - 6); | |
label.setAttribute('y', ty + 4); | |
label.setAttribute('font-size', '12'); | |
label.setAttribute('text-anchor', 'end'); | |
label.textContent = format((i/ticks)*max); | |
g.appendChild(label); | |
} | |
svg.appendChild(g); | |
} | |
function exportSVGToPNG(svgEl, filename='chart.png') { | |
const svgData = new XMLSerializer().serializeToString(svgEl); | |
const canvas = document.createElement('canvas'); | |
const bbox = svgEl.viewBox.baseVal; | |
canvas.width = bbox.width * 2; | |
canvas.height = bbox.height * 2; | |
const ctx = canvas.getContext('2d'); | |
const img = new Image(); | |
const svgBlob = new Blob([svgData], {type: 'image/svg+xml;charset=utf-8'}); | |
const url = URL.createObjectURL(svgBlob); | |
img.onload = function() { | |
ctx.scale(2,2); | |
ctx.drawImage(img, 0, 0); | |
URL.revokeObjectURL(url); | |
canvas.toBlob(function(blob) { | |
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); | |
a.download = filename; document.body.appendChild(a); a.click(); a.remove(); | |
}); | |
}; | |
img.src = url; | |
} | |
// Chart: Bars with optional target band | |
function drawBars(containerId, series, opts = {}) { | |
const el = document.getElementById(containerId); | |
el.innerHTML = ''; | |
const svg = createSVG(800, 480); | |
el.appendChild(svg); | |
const pad = {l: 60, r: 20, t: 30, b: 60}; | |
const w = 800 - pad.l - pad.r; | |
const h = 480 - pad.t - pad.b; | |
const max = Math.max(opts.max || 10, ...series.map(d => d.value)) + 0.5; | |
// Target band | |
if (opts.targetBand?.length && document.getElementById('toggleTargetBand')?.checked) { | |
const [lo, hi] = opts.targetBand; | |
const yLo = pad.t + h - (lo / max) * h; | |
const yHi = pad.t + h - (hi / max) * h; | |
const rect = document.createElementNS(svg.namespaceURI, 'rect'); | |
rect.setAttribute('x', pad.l); | |
rect.setAttribute('width', w); | |
rect.setAttribute('y', yHi); | |
rect.setAttribute('height', Math.max(2, yLo - yHi)); | |
rect.setAttribute('fill', 'url(#bandGrad)'); | |
rect.setAttribute('opacity', '0.35'); | |
// gradient | |
const defs = document.createElementNS(svg.namespaceURI, 'defs'); | |
const grad = document.createElementNS(svg.namespaceURI, 'linearGradient'); | |
grad.setAttribute('id','bandGrad'); grad.setAttribute('x1','0%'); grad.setAttribute('x2','0%'); grad.setAttribute('y1','0%'); grad.setAttribute('y2','100%'); | |
const s1 = document.createElementNS(svg.namespaceURI, 'stop'); s1.setAttribute('offset','0%'); s1.setAttribute('stop-color','var(--warn)'); | |
const s2 = document.createElementNS(svg.namespaceURI, 'stop'); s2.setAttribute('offset','100%'); s2.setAttribute('stop-color','var(--accent-2)'); | |
grad.appendChild(s1); grad.appendChild(s2); defs.appendChild(grad); svg.appendChild(defs); | |
svg.appendChild(rect); | |
} | |
axis(svg, pad.l, pad.t, w, h, 5, max, v => (v).toFixed(0) + '%'); | |
const bw = w / (series.length * 1.5); | |
series.forEach((d, i) => { | |
const x = pad.l + i * (w / series.length) + (w / series.length - bw) / 2; | |
const y = pad.t + h - (d.value / max) * h; | |
const rect = document.createElementNS(svg.namespaceURI, 'rect'); | |
rect.setAttribute('x', x); | |
rect.setAttribute('y', y); | |
rect.setAttribute('width', bw); | |
rect.setAttribute('height', Math.max(2, pad.t + h - y)); | |
rect.setAttribute('rx', '6'); | |
rect.setAttribute('fill', 'var(--accent)'); | |
rect.setAttribute('opacity', '.9'); | |
svg.appendChild(rect); | |
const lbl = document.createElementNS(svg.namespaceURI, 'text'); | |
lbl.setAttribute('x', x + bw/2); | |
lbl.setAttribute('y', pad.t + h + 20); | |
lbl.setAttribute('text-anchor', 'middle'); | |
lbl.setAttribute('font-size', '12'); | |
lbl.textContent = d.label; | |
svg.appendChild(lbl); | |
const val = document.createElementNS(svg.namespaceURI, 'text'); | |
val.setAttribute('x', x + bw/2); | |
val.setAttribute('y', y - 6); | |
val.setAttribute('text-anchor', 'middle'); | |
val.setAttribute('font-size', '12'); | |
val.textContent = d.value.toFixed(2) + '%'; | |
svg.appendChild(val); | |
}); | |
state.charts[containerId] = svg; | |
} | |
// Chart: Dot plot (forecasts) | |
function drawDotPlot(containerId, series, opts={}) { | |
const el = document.getElementById(containerId); el.innerHTML = ''; | |
const svg = createSVG(800, 480); el.appendChild(svg); | |
const pad = {l: 70, r: 20, t: 20, b: 60}; | |
const w = 800 - pad.l - pad.r, h = 480 - pad.t - pad.b; | |
const max = Math.max(opts.max || 9, ...series.map(d => d.value)); | |
const min = opts.min || 0; | |
const filtered = series.filter(d => d.value >= (opts.threshold ?? 0)); | |
// axis | |
axis(svg, pad.l, pad.t, w, h, 6, max, v => (v).toFixed(0) + '%'); | |
// baseline 0 | |
const x0 = pad.l, yMid = pad.t + h; | |
// x scale | |
const xScale = v => pad.l + (v - min) / (max - min) * w; | |
// categories y positions | |
const items = filtered; | |
const gap = h / (items.length + 1); | |
items.forEach((d, i) => { | |
const y = pad.t + gap * (i+1); | |
const x = xScale(d.value); | |
const line = document.createElementNS(svg.namespaceURI, 'line'); | |
line.setAttribute('x1', pad.l); line.setAttribute('x2', pad.l + w); line.setAttribute('y1', y); line.setAttribute('y2', y); | |
line.setAttribute('stroke', 'currentColor'); line.setAttribute('opacity', '.08'); | |
svg.appendChild(line); | |
const dot = document.createElementNS(svg.namespaceURI, 'circle'); | |
dot.setAttribute('cx', x); dot.setAttribute('cy', y); dot.setAttribute('r', '8'); | |
dot.setAttribute('fill', d.type === 'gov' ? 'var(--accent-2)' : 'var(--accent-3)'); | |
dot.setAttribute('opacity', '.9'); | |
svg.appendChild(dot); | |
const label = document.createElementNS(svg.namespaceURI, 'text'); | |
label.setAttribute('x', pad.l - 10); label.setAttribute('y', y + 4); | |
label.setAttribute('text-anchor', 'end'); label.setAttribute('font-size', '12'); label.textContent = d.name; | |
svg.appendChild(label); | |
const val = document.createElementNS(svg.namespaceURI, 'text'); | |
val.setAttribute('x', x + 10); val.setAttribute('y', y + 4); val.setAttribute('font-size','12'); val.textContent = d.value.toFixed(1) + '%'; | |
svg.appendChild(val); | |
}); | |
state.charts[containerId] = svg; | |
} | |
// Chart: Line | |
function drawLine(containerId, series, opts={}) { | |
const el = document.getElementById(containerId); el.innerHTML = ''; | |
const svg = createSVG(800, 480); el.appendChild(svg); | |
const pad = {l: 60, r: 20, t: 20, b: 60}; | |
const w = 800 - pad.l - pad.r, h = 480 - pad.t - pad.b; | |
const max = Math.max(opts.max || Math.max(...series.map(d=>d.value)) + 1, ...series.map(d => d.value)); | |
const min = Math.min(opts.min || Math.min(...series.map(d=>d.value)) - 0.5, ...series.map(d => d.value)); | |
axis(svg, pad.l, pad.t, w, h, 6, max, v => (v).toFixed(0) + '%'); | |
const xScale = (i) => pad.l + (i / (series.length - 1)) * w; | |
const yScale = (v) => pad.t + h - ((v - 0) / (max - 0)) * h; | |
const path = document.createElementNS(svg.namespaceURI, 'path'); | |
const d = series.map((p, i) => `${i===0?'M':'L'} ${xScale(i)} ${yScale(p.value)}`).join(' '); | |
path.setAttribute('d', d); | |
path.setAttribute('fill', 'none'); path.setAttribute('stroke', 'var(--accent)'); path.setAttribute('stroke-width', '3'); | |
svg.appendChild(path); | |
// points | |
series.forEach((p, i) => { | |
const c = document.createElementNS(svg.namespaceURI, 'circle'); | |
c.setAttribute('cx', xScale(i)); c.setAttribute('cy', yScale(p.value)); c.setAttribute('r', '5'); | |
c.setAttribute('fill', 'var(--accent)'); svg.appendChild(c); | |
const label = document.createElementNS(svg.namespaceURI, 'text'); | |
label.setAttribute('x', xScale(i)); label.setAttribute('y', pad.t + h + 20); | |
label.setAttribute('text-anchor', 'middle'); label.setAttribute('font-size','12'); label.textContent = p.year; | |
svg.appendChild(label); | |
const val = document.createElementNS(svg.namespaceURI, 'text'); | |
val.setAttribute('x', xScale(i)); val.setAttribute('y', yScale(p.value) - 8); | |
val.setAttribute('text-anchor','middle'); val.setAttribute('font-size','12'); val.textContent = p.value.toFixed(2) + '%'; | |
svg.appendChild(val); | |
}); | |
// trendline | |
const n = series.length; | |
const meanX = (n - 1) / 2; | |
const meanY = series.reduce((a, b) => a + b.value, 0) / n; | |
let num = 0, den = 0; | |
series.forEach((p, i) => { num += (i - meanX) * (p.value - meanY); den += (i - meanX) ** 2; }); | |
const slope = num / den; const intercept = meanY - slope * meanX; | |
const y1 = intercept; const y2 = slope * (n-1) + intercept; | |
const line = document.createElementNS(svg.namespaceURI, 'line'); | |
line.setAttribute('x1', xScale(0)); line.setAttribute('x2', xScale(n-1)); | |
line.setAttribute('y1', yScale(y1)); line.setAttribute('y2', yScale(y2)); | |
line.setAttribute('stroke', 'var(--accent-2)'); line.setAttribute('stroke-width', '2'); line.setAttribute('stroke-dasharray','6,6'); | |
svg.appendChild(line); | |
state.charts[containerId] = svg; | |
} | |
// Chart: Mixed bars (FDI) and labels | |
function drawFDI(containerId, series) { | |
const el = document.getElementById(containerId); el.innerHTML = ''; | |
const svg = createSVG(800, 480); el.appendChild(svg); | |
const pad = {l: 70, r: 20, t: 30, b: 80}; | |
const w = 800 - pad.l - pad.r, h = 480 - pad.t - pad.b; | |
const max = Math.max(...series.map(d => d.value)) + 2; | |
axis(svg, pad.l, pad.t, w, h, 6, max, v => '$' + v.toFixed(0) + 'B'); | |
const bw = w / (series.length * 1.6); | |
series.forEach((d, i) => { | |
const x = pad.l + i * (w / series.length) + (w / series.length - bw) / 2; | |
const y = pad.t + h - (d.value / max) * h; | |
const rect = document.createElementNS(svg.namespaceURI, 'rect'); | |
rect.setAttribute('x', x); rect.setAttribute('y', y); | |
rect.setAttribute('width', bw); rect.setAttribute('height', Math.max(2, pad.t + h - y)); | |
rect.setAttribute('rx','6'); rect.setAttribute('fill', d.color || 'var(--accent)'); | |
svg.appendChild(rect); | |
const lbl = document.createElementNS(svg.namespaceURI, 'text'); | |
lbl.setAttribute('x', x + bw/2); lbl.setAttribute('y', pad.t + h + 20); | |
lbl.setAttribute('text-anchor','middle'); lbl.setAttribute('font-size','12'); lbl.textContent = d.label; | |
svg.appendChild(lbl); | |
const val = document.createElementNS(svg.namespaceURI, 'text'); | |
val.setAttribute('x', x + bw/2); val.setAttribute('y', y - 6); | |
val.setAttribute('text-anchor','middle'); val.setAttribute('font-size','12'); val.textContent = fmt.moneyB(d.value); | |
svg.appendChild(val); | |
}); | |
state.charts[containerId] = svg; | |
} | |
// Chart: Inflation line + forecast markers | |
function drawInflation(containerId, actual, forecasts) { | |
const el = document.getElementById(containerId); el.innerHTML = ''; | |
const svg = createSVG(800, 480); el.appendChild(svg); | |
const pad = {l: 60, r: 20, t: 20, b: 60}; | |
const w = 800 - pad.l - pad.r, h = 480 - pad.t - pad.b; | |
const allVals = [...actual.map(d=>d.value), ...forecasts.map(f=>f.value)]; | |
const max = Math.max(5, ...allVals) + 0.5; | |
axis(svg, pad.l, pad.t, w, h, 5, max, v => v.toFixed(0) + '%'); | |
const xScale = (i) => pad.l + (i / (actual.length - 1)) * w; | |
const yScale = (v) => pad.t + h - (v / max) * h; | |
const path = document.createElementNS(svg.namespaceURI, 'path'); | |
const d = actual.map((p, i) => `${i===0?'M':'L'} ${xScale(i)} ${yScale(p.value)}`).join(' '); | |
path.setAttribute('d', d); | |
path.setAttribute('fill', 'none'); path.setAttribute('stroke', 'var(--accent)'); path.setAttribute('stroke-width','3'); | |
svg.appendChild(path); | |
actual.forEach((p, i) => { | |
const c = document.createElementNS(svg.namespaceURI, 'circle'); | |
c.setAttribute('cx', xScale(i)); c.setAttribute('cy', yScale(p.value)); c.setAttribute('r', '6'); | |
c.setAttribute('fill', 'var(--accent)'); svg.appendChild(c); | |
const label = document.createElementNS(svg.namespaceURI, 'text'); | |
label.setAttribute('x', xScale(i)); label.setAttribute('y', pad.t + h + 20); | |
label.setAttribute('text-anchor','middle'); label.setAttribute('font-size','12'); label.textContent = p.month; | |
svg.appendChild(label); | |
}); | |
// Forecast markers at end | |
forecasts.forEach((f, idx) => { | |
const x = pad.l + w - (idx * 80); | |
const y = yScale(f.value); | |
const rect = document.createElementNS(svg.namespaceURI, 'rect'); | |
rect.setAttribute('x', x - 40); rect.setAttribute('y', y - 10); | |
rect.setAttribute('width', 80); rect.setAttribute('height', 20); | |
rect.setAttribute('rx', '6'); rect.setAttribute('fill', f.color || 'var(--accent-3)'); rect.setAttribute('opacity', '.15'); | |
svg.appendChild(rect); | |
const line = document.createElementNS(svg.namespaceURI, 'line'); | |
line.setAttribute('x1', x); line.setAttribute('x2', x); line.setAttribute('y1', y); line.setAttribute('y2', yScale(actual.at(-1).value)); | |
line.setAttribute('stroke', f.color || 'var(--accent-3)'); line.setAttribute('stroke-dasharray','4,4'); | |
svg.appendChild(line); | |
const dot = document.createElementNS(svg.namespaceURI, 'circle'); | |
dot.setAttribute('cx', x); dot.setAttribute('cy', y); dot.setAttribute('r', '6'); dot.setAttribute('fill', f.color || 'var(--accent-3)'); | |
svg.appendChild(dot); | |
const label = document.createElementNS(svg.namespaceURI, 'text'); | |
label.setAttribute('x', x); label.setAttribute('y', y - 10); label.setAttribute('text-anchor','middle'); label.setAttribute('font-size','12'); label.textContent = f.name + ': ' + f.value.toFixed(1) + '%'; | |
svg.appendChild(label); | |
}); | |
state.charts[containerId] = svg; | |
} | |
// Init charts | |
const initCharts = () => { | |
drawBars('chart-gdp-bars', data.gdpBars, {targetBand: [8.3, 8.5]}); | |
drawDotPlot('chart-forecasts', data.forecast, {threshold: parseFloat(document.getElementById('forecastMin').value)}); | |
drawInflation('chart-inflation', data.inflation, data.inflationForecasts); | |
drawLine('chart-labor', data.unemployment.map((d, i) => ({year: d.label, value: d.value})), {min: 0, max: 5}); | |
drawFDI('chart-fdi', data.fdi); | |
drawLine('chart-history', data.historyQ1.map(d => ({year: d.year, value: d.value})), {min: 0, max: 9}); | |
}; | |
initCharts(); | |
// Controls for charts | |
document.getElementById('forecastMin').addEventListener('input', (e) => { | |
document.getElementById('forecastMinVal').textContent = e.target.value + '%'; | |
drawDotPlot('chart-forecasts', data.forecast, {threshold: parseFloat(e.target.value)}); | |
}); | |
document.getElementById('toggleTargetBand').addEventListener('change', () => { | |
drawBars('chart-gdp-bars', data.gdpBars, {targetBand: [8.3, 8.5]}); | |
}); | |
document.querySelectorAll('.export-chart').forEach(btn => btn.addEventListener('click', e => { | |
const id = e.currentTarget.dataset.chart; | |
const svg = state.charts[id]; | |
if (svg) exportSVGToPNG(svg, id + '.png'); | |
})); | |
// Clipboard for link to section | |
document.querySelectorAll('.link-section').forEach(btn => btn.addEventListener('click', (e) => { | |
const id = e.currentTarget.dataset.target; | |
const url = location.origin + location.pathname + '#' + id; | |
navigator.clipboard.writeText(url).then(() => toast('Section link copied')); | |
})); | |
// KPI copy accessible via Enter | |
document.querySelectorAll('.copy-kpi').forEach(btn => btn.addEventListener('keydown', (e) => { | |
if (e.key === 'Enter') btn.click(); | |
})); | |
// Re-render charts on theme change to ensure color variables apply (SVG inline uses CSS vars) | |
const observerTheme = new MutationObserver(() => { | |
Object.values(state.charts).forEach(svg => {}); // no-op; CSS vars update automatically | |
}); | |
observerTheme.observe(document.documentElement, {attributes: true, attributeFilter: ['data-theme']}); | |
// Responsive redrawing on container resize | |
let resizeTimer; | |
window.addEventListener('resize', () => { | |
clearTimeout(resizeTimer); | |
resizeTimer = setTimeout(() => initCharts(), 150); | |
}); | |
// Feather icons in dynamically created toasts will not need replacement, but ensure all are set at load. | |
</script> | |
</body> | |
</html> |