finance-dashboard/index.html

974 lines
54 KiB
HTML
Raw Permalink Normal View History

2026-06-02 09:40:21 +00:00
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
2026-06-03 16:42:07 +00:00
<title>MacroScope — 學習 · 個股 · 復盤</title>
2026-06-03 09:33:23 +00:00
<link rel="stylesheet" href="https://unpkg.com/vis-network@9.1.9/styles/vis-network.min.css">
2026-06-03 09:21:58 +00:00
<link rel="stylesheet" href="app.css">
2026-06-02 09:40:21 +00:00
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
2026-06-03 16:42:07 +00:00
--bg: #f6f7f4;
2026-06-03 09:33:23 +00:00
--surface: #ffffff;
--card: #ffffff;
2026-06-03 16:42:07 +00:00
--border: rgba(32,40,33,.10);
--text: #202421;
--text2: #667064;
--green: #1f9d66;
--red: #d84f45;
--yellow: #c88a1d;
--blue: #2367c7;
--purple: #7b57c9;
--orange: #d4772f;
--teal: #0f8f8c;
--radius: 12px;
--shadow: 0 14px 40px rgba(32,40,33,.08);
--soft-shadow: 0 1px 2px rgba(32,40,33,.06),0 12px 28px rgba(32,40,33,.06);
--glass: rgba(255,255,255,.82);
2026-06-03 09:33:23 +00:00
}
html{font-size:15px;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","SF Pro Display","PingFang TC","Noto Sans TC",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
2026-06-02 09:40:21 +00:00
body{min-height:100vh;padding:0 0 60px}
a{color:var(--blue);text-decoration:none}
/* ── Header ── */
.header{
position:sticky;top:0;z-index:100;
2026-06-03 09:33:23 +00:00
background:var(--glass);
backdrop-filter:saturate(180%) blur(20px);
-webkit-backdrop-filter:saturate(180%) blur(20px);
2026-06-02 09:40:21 +00:00
border-bottom:1px solid var(--border);
2026-06-03 16:42:07 +00:00
padding:12px 32px;
2026-06-02 09:40:21 +00:00
display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;
}
2026-06-03 16:42:07 +00:00
.logo{display:flex;align-items:center;gap:10px;font-size:1.02rem;font-weight:760;letter-spacing:0}
.logo-icon{width:30px;height:30px;border-radius:8px;background:#202421;color:#f7f3e8;display:flex;align-items:center;justify-content:center;font-size:.82rem}
2026-06-02 09:40:21 +00:00
.header-right{display:flex;align-items:center;gap:16px}
.last-updated{font-size:.8rem;color:var(--text2)}
.nav-links{display:flex;gap:6px;flex-wrap:wrap}
.nav-links a{
padding:6px 14px;border-radius:6px;font-size:.85rem;font-weight:500;color:var(--text);cursor:pointer;
transition:background .15s,color .15s;
}
.nav-links a.active{background:rgba(77,166,255,.15);color:var(--blue)}
.nav-links a:hover{background:rgba(77,166,255,.08)}
.refresh-btn{
background:var(--surface);border:1px solid var(--border);color:var(--text2);
2026-06-03 16:42:07 +00:00
padding:8px 12px;border-radius:9px;font-size:.8rem;cursor:pointer;transition:.15s;
box-shadow:0 1px 2px rgba(32,40,33,.04);
2026-06-02 09:40:21 +00:00
}
.refresh-btn:hover{border-color:var(--blue);color:var(--blue)}
/* ── 導讀條(如何閱讀)── */
.guide{
margin:20px 32px 0;background:var(--surface);border:1px solid var(--border);
2026-06-03 16:42:07 +00:00
border-radius:var(--radius);padding:18px 20px;box-shadow:var(--soft-shadow);
2026-06-02 09:40:21 +00:00
}
2026-06-03 16:42:07 +00:00
.guide-title{font-size:.92rem;font-weight:760;margin-bottom:12px;display:flex;align-items:center;gap:8px}
.guide-title .tag{font-size:.68rem;font-weight:760;color:#f7f3e8;background:#202421;padding:3px 8px;border-radius:20px}
2026-06-02 09:40:21 +00:00
.guide-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px 24px;font-size:.78rem;color:var(--text2);line-height:1.55}
.guide-grid b{color:var(--text);font-weight:600}
.legend-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px;vertical-align:middle}
/* ── Macro Signal Bar ── */
2026-06-03 16:42:07 +00:00
.macro-hero{
margin:22px 32px 0;
display:grid;grid-template-columns:minmax(280px,1.05fr) minmax(360px,1.7fr) minmax(240px,.9fr);
gap:18px;align-items:stretch;
}
.hero-panel,.coverage-panel{
background:var(--surface);border:1px solid var(--border);border-radius:16px;
box-shadow:var(--shadow);padding:22px 24px;position:relative;overflow:hidden;
}
.hero-panel::before{
content:'';position:absolute;inset:0 auto 0 0;width:6px;background:var(--hero-color,var(--blue));
}
.hero-kicker{font-size:.72rem;color:var(--text2);font-weight:760;letter-spacing:.08em;margin-bottom:12px;text-transform:uppercase}
.hero-title{font-size:1.65rem;font-weight:820;line-height:1.18;margin-bottom:10px;letter-spacing:0}
.hero-copy{font-size:.88rem;color:var(--text2);line-height:1.7;max-width:620px}
.hero-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px}
.action-link{
border:1px solid var(--border);background:#f9faf7;color:var(--text);
border-radius:10px;padding:8px 12px;font-size:.8rem;font-weight:720;cursor:pointer;
}
.action-link:hover{border-color:var(--blue);color:var(--blue)}
2026-06-02 09:40:21 +00:00
.signal-bar{
2026-06-03 16:42:07 +00:00
margin:0;background:var(--card);border:1px solid var(--border);border-radius:16px;
padding:20px 24px;display:grid;grid-template-columns:160px 1fr;gap:18px;align-items:center;
box-shadow:var(--shadow);
2026-06-02 09:40:21 +00:00
}
2026-06-03 16:42:07 +00:00
.signal-bar .signal-regime{grid-column:1/-1;text-align:left;border-top:1px solid var(--border);padding-top:14px;display:flex;align-items:center;justify-content:space-between;gap:12px}
.signal-bar .signal-regime .label{margin:0}
2026-06-02 09:40:21 +00:00
.signal-score{text-align:center;position:relative}
.signal-score .label{font-size:.75rem;color:var(--text2);letter-spacing:.04em;margin-bottom:4px;display:flex;align-items:center;justify-content:center;gap:6px}
2026-06-03 16:42:07 +00:00
.signal-score .value{font-size:3.2rem;font-weight:820;line-height:1}
2026-06-02 09:40:21 +00:00
.signal-score .sublabel{font-size:.75rem;color:var(--text2);margin-top:4px}
2026-06-03 16:42:07 +00:00
.signal-details{display:grid;grid-template-columns:repeat(auto-fit,minmax(92px,1fr));gap:10px}
.signal-pill{padding:11px 12px;border-radius:10px;text-align:left;background:#f9faf7;border:1px solid var(--border)}
.signal-pill .pill-label{font-size:.7rem;color:var(--text2);letter-spacing:.02em;margin-bottom:4px;line-height:1.35}
2026-06-02 09:40:21 +00:00
.signal-pill .pill-value{font-size:1rem;font-weight:600}
.signal-regime{text-align:center}
.signal-regime .label{font-size:.75rem;color:var(--text2);letter-spacing:.04em;margin-bottom:8px}
.regime-badge{display:inline-block;padding:8px 20px;border-radius:20px;font-weight:700;font-size:.9rem;letter-spacing:.02em}
2026-06-03 16:42:07 +00:00
.coverage-panel{display:flex;flex-direction:column;gap:14px}
.coverage-title{font-size:.92rem;font-weight:780}
.coverage-list{display:grid;gap:10px}
.coverage-row{display:flex;justify-content:space-between;gap:12px;font-size:.8rem;color:var(--text2);border-top:1px solid var(--border);padding-top:10px}
.coverage-row:first-child{border-top:none;padding-top:0}
.coverage-row b{font-size:1rem;color:var(--text)}
.coverage-note{font-size:.76rem;color:var(--text2);line-height:1.6;background:#f9faf7;border:1px solid var(--border);border-radius:10px;padding:10px 12px}
.coverage-good{color:var(--green)}.coverage-warn{color:var(--orange)}
2026-06-02 09:40:21 +00:00
/* ── Section ── */
.section{margin:28px 32px 0}
.section-header{display:flex;align-items:center;gap:10px;margin-bottom:6px}
.section-icon{width:28px;height:28px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:.85rem}
.section-title{font-size:1.05rem;font-weight:600;letter-spacing:-.01em}
.section-subtitle{font-size:.75rem;color:var(--text2);margin-left:8px}
.section-intro{font-size:.82rem;color:var(--text2);line-height:1.65;margin:0 0 14px 38px;max-width:880px}
/* ── Card Grid ── */
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}
.card{
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
2026-06-03 16:42:07 +00:00
padding:18px 20px;transition:border-color .2s,box-shadow .2s,transform .2s;position:relative;
2026-06-02 09:40:21 +00:00
display:flex;flex-direction:column;
2026-06-03 16:42:07 +00:00
box-shadow:0 1px 2px rgba(32,40,33,.04);
2026-06-02 09:40:21 +00:00
}
2026-06-03 16:42:07 +00:00
.card:hover{border-color:rgba(35,103,199,.24);box-shadow:var(--soft-shadow);transform:translateY(-2px)}
2026-06-02 09:40:21 +00:00
.card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px}
.card-labels{display:flex;flex-direction:column;gap:1px;min-width:0}
.card-label{font-size:.82rem;color:var(--text);font-weight:600;line-height:1.3}
.card-label-en{font-size:.66rem;color:var(--text2);letter-spacing:.02em}
.card-top-right{display:flex;align-items:center;gap:6px;flex-shrink:0}
.badge{padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;white-space:nowrap}
.badge.good{background:rgba(0,212,170,.12);color:var(--green)}
.badge.bad{background:rgba(255,77,106,.12);color:var(--red)}
.badge.neutral{background:rgba(136,153,170,.14);color:var(--text2)}
.info-btn{
width:18px;height:18px;border-radius:50%;border:1px solid var(--border);background:var(--surface);
color:var(--text2);font-size:.7rem;line-height:1;cursor:help;flex-shrink:0;
display:flex;align-items:center;justify-content:center;transition:.15s;font-weight:700;
}
.info-btn:hover,.info-btn:focus{border-color:var(--blue);color:var(--blue);outline:none}
.sub-tag{font-size:.62rem;color:var(--orange);background:rgba(255,138,77,.12);padding:1px 6px;border-radius:4px;align-self:flex-start;margin-bottom:6px}
.card-value{font-size:1.75rem;font-weight:700;line-height:1.1;margin-bottom:2px}
.card-change{font-size:.75rem;font-weight:500;margin-bottom:10px;min-height:1em}
2026-06-03 16:42:07 +00:00
.card-human{font-size:.74rem;color:var(--text2);line-height:1.45;margin:-2px 0 10px;min-height:2.1em}
.card-context{
font-size:.72rem;line-height:1.45;margin:-2px 0 8px;padding:6px 8px;border-radius:8px;
background:rgba(35,103,199,.06);border:1px solid rgba(35,103,199,.12);color:var(--text2);
}
.card-context.good{background:rgba(31,157,102,.07);border-color:rgba(31,157,102,.18);color:#1a6b45}
.card-context.bad{background:rgba(216,79,69,.07);border-color:rgba(216,79,69,.16);color:#9a3a32}
.card-context.warn{background:rgba(200,138,29,.08);border-color:rgba(200,138,29,.18);color:#8a5a12}
2026-06-02 09:40:21 +00:00
.card-sparkline{height:36px;margin-top:auto;opacity:.9}
.card-meta{display:flex;justify-content:space-between;gap:8px;font-size:.66rem;color:var(--text2);margin-top:8px}
/* ── Yield Curve (wide card) ── */
.card.wide{grid-column:span 2}
.c-green{color:var(--green)}.c-red{color:var(--red)}.c-yellow{color:var(--yellow)}.c-blue{color:var(--blue)}.c-purple{color:var(--purple)}.c-orange{color:var(--orange)}.c-text{color:var(--text)}.c-text2{color:var(--text2)}
/* ── Tooltip多行解釋── */
#tooltip{
2026-06-03 16:42:07 +00:00
position:fixed;z-index:1000;max-width:360px;background:#202421;border:1px solid rgba(255,255,255,.12);
2026-06-02 09:40:21 +00:00
border-radius:8px;padding:12px 14px;box-shadow:var(--shadow);font-size:.76rem;line-height:1.6;
2026-06-03 16:42:07 +00:00
color:#c7d0c5;opacity:0;pointer-events:none;transition:opacity .12s;
2026-06-02 09:40:21 +00:00
}
#tooltip.show{opacity:1}
2026-06-03 16:42:07 +00:00
#tooltip .tip-title{color:#fff;font-weight:700;font-size:.82rem;margin-bottom:8px}
2026-06-02 09:40:21 +00:00
#tooltip .tip-row{margin-bottom:7px}
#tooltip .tip-row:last-child{margin-bottom:0}
#tooltip .tip-k{color:var(--blue);font-weight:600;margin-right:4px}
#tooltip .tip-foot{margin-top:9px;padding-top:8px;border-top:1px solid var(--border);font-size:.68rem;color:var(--text2);display:flex;justify-content:space-between;gap:10px}
2026-06-03 16:42:07 +00:00
#tooltip .tip-context{margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,.1)}
#tooltip .tip-link-hint{margin-top:8px;font-size:.68rem;color:#8fa0ff}
2026-06-02 09:40:21 +00:00
#tooltip .tip-break{display:flex;justify-content:space-between;gap:12px;margin-bottom:4px}
#tooltip .tip-break .d-pos{color:var(--green)}#tooltip .tip-break .d-neg{color:var(--red)}
/* ── 載入 / 錯誤 ── */
.state{margin:60px 32px;text-align:center;color:var(--text2)}
.state h2{color:var(--text);font-size:1.1rem;margin-bottom:10px}
.state .spinner{width:34px;height:34px;border:3px solid var(--border);border-top-color:var(--blue);border-radius:50%;margin:0 auto 18px;animation:spin .8s linear infinite}
.state .err-box{max-width:520px;margin:0 auto;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:24px;text-align:left;line-height:1.7}
.state code{background:var(--surface);padding:2px 6px;border-radius:4px;color:var(--yellow);font-size:.85em}
.state .retry{margin-top:16px;background:var(--blue);color:#08111d;border:none;padding:8px 18px;border-radius:6px;font-weight:600;cursor:pointer}
.degraded-note{margin:14px 32px 0;font-size:.74rem;color:var(--orange);background:rgba(255,138,77,.08);border:1px solid rgba(255,138,77,.2);border-radius:8px;padding:8px 14px}
@keyframes spin{to{transform:rotate(360deg)}}
/* ── 走勢大圖 Modal ── */
.card[data-key]{cursor:pointer}
.card[data-key]::after{content:'點擊看走勢';position:absolute;bottom:8px;right:12px;font-size:.6rem;color:var(--text2);opacity:0;transition:opacity .15s}
.card[data-key]:hover::after{opacity:.6}
#scoreClick{cursor:pointer}
#modalOverlay{
2026-06-03 09:33:23 +00:00
position:fixed;inset:0;z-index:500;background:rgba(0,0,0,.35);backdrop-filter:blur(12px);
2026-06-02 09:40:21 +00:00
display:none;align-items:center;justify-content:center;padding:20px;
}
#modalOverlay.show{display:flex}
.modal-panel{
2026-06-03 09:33:23 +00:00
background:var(--card);border:1px solid var(--border);border-radius:18px;
width:min(820px,100%);max-height:90vh;overflow:auto;box-shadow:0 24px 80px rgba(0,0,0,.15);
2026-06-02 09:40:21 +00:00
padding:22px 24px;animation:fadeInUp .25s ease both;
}
.modal-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:4px}
.modal-title{font-size:1.15rem;font-weight:700}
.modal-title .en{font-size:.72rem;color:var(--text2);font-weight:400;margin-left:6px}
.modal-now{font-size:1.05rem;font-weight:700;margin-top:2px}
.modal-close{background:var(--surface);border:1px solid var(--border);color:var(--text2);width:30px;height:30px;border-radius:8px;font-size:1rem;cursor:pointer;flex-shrink:0}
.modal-close:hover{border-color:var(--red);color:var(--red)}
.range-btns{display:flex;gap:6px;margin:14px 0}
.range-btn{background:var(--surface);border:1px solid var(--border);color:var(--text2);padding:5px 14px;border-radius:6px;font-size:.8rem;cursor:pointer;transition:.15s}
.range-btn:hover{border-color:var(--blue)}
2026-06-03 09:33:23 +00:00
.range-btn.active{background:var(--blue);color:#fff;border-color:var(--blue)}
2026-06-02 09:40:21 +00:00
.chart-wrap{position:relative;width:100%;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:8px}
.chart-wrap svg{display:block;width:100%;height:auto}
.chart-empty{padding:50px 0;text-align:center;color:var(--text2);font-size:.85rem}
.modal-tip{margin-top:14px;font-size:.76rem;color:var(--text2);line-height:1.6;border-top:1px solid var(--border);padding-top:12px}
.modal-tip .tip-k{color:var(--blue);font-weight:600;margin-right:4px}
.modal-foot{display:flex;justify-content:space-between;font-size:.68rem;color:var(--text2);margin-top:8px}
/* ── 歷史殷鑑(危機案例)── */
.episode-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:16px}
.episode{
background:var(--card);border:1px solid var(--border);border-radius:var(--radius);
padding:18px 20px;display:flex;flex-direction:column;
border-top:3px solid var(--ec);transition:border-color .2s,box-shadow .2s;
}
.episode:hover{box-shadow:0 0 22px rgba(0,0,0,.25)}
.ep-head{display:flex;align-items:center;gap:12px;margin-bottom:10px}
.ep-emoji{font-size:1.5rem;line-height:1}
.ep-title{font-size:1rem;font-weight:700;display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
.ep-period{font-size:.72rem;color:var(--text2);font-weight:500}
.ep-type{font-size:.64rem;font-weight:600;color:var(--ec);background:color-mix(in srgb,var(--ec) 14%,transparent);padding:2px 8px;border-radius:20px;display:inline-block;margin-top:3px}
.ep-summary{font-size:.78rem;color:var(--text2);line-height:1.6;margin-bottom:12px}
.ep-sig-title{font-size:.7rem;font-weight:600;color:var(--text);letter-spacing:.03em;margin-bottom:7px;display:flex;align-items:center;gap:6px}
.ep-sig-title::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--ec)}
.ep-sigs{display:flex;flex-direction:column;gap:7px;margin-bottom:12px}
.ep-sig{
font-size:.74rem;line-height:1.55;color:var(--text2);background:var(--surface);
border:1px solid var(--border);border-radius:7px;padding:7px 10px;cursor:pointer;transition:.15s;
}
.ep-sig:hover{border-color:var(--ec);color:var(--text)}
.ep-sig b{color:var(--text);font-weight:600}
.ep-sig .sig-go{color:var(--ec);font-size:.66rem;margin-left:4px;opacity:0;transition:.15s}
.ep-sig:hover .sig-go{opacity:1}
.ep-lesson{font-size:.76rem;line-height:1.6;color:var(--text);background:color-mix(in srgb,var(--ec) 7%,transparent);border-radius:7px;padding:9px 11px;margin-bottom:8px}
.ep-lesson b{color:var(--ec)}
.ep-watch{font-size:.74rem;line-height:1.6;color:var(--text2);margin-bottom:14px}
.ep-watch b{color:var(--text)}
.ep-btn{margin-top:auto;background:var(--surface);border:1px solid var(--border);color:var(--ec);padding:8px 14px;border-radius:7px;font-size:.78rem;font-weight:600;cursor:pointer;transition:.15s;width:100%}
.ep-btn:hover{border-color:var(--ec);background:color-mix(in srgb,var(--ec) 10%,transparent)}
.ep-legend{font-size:.74rem;color:var(--text2);margin:0 0 14px 38px;line-height:1.6;max-width:880px}
.ep-legend .ev-chip{display:inline-flex;align-items:center;gap:4px;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:2px 9px;margin:2px 4px 2px 0;font-size:.72rem}
/* ── Responsive ── */
@media(max-width:900px){
2026-06-03 16:42:07 +00:00
.macro-hero{grid-template-columns:1fr;margin-left:16px;margin-right:16px}
2026-06-02 09:40:21 +00:00
.signal-bar{grid-template-columns:1fr;gap:16px;text-align:center}
.signal-details{grid-template-columns:repeat(3,1fr)}
.card.wide{grid-column:span 1}
.header{padding:12px 16px}
.section{margin:20px 16px 0}
.signal-bar,.guide,.degraded-note{margin-left:16px;margin-right:16px}
.section-intro{margin-left:0}
}
@media(max-width:600px){
.card-grid{grid-template-columns:1fr}
.signal-details{grid-template-columns:repeat(2,1fr)}
2026-06-03 16:42:07 +00:00
.signal-bar .signal-regime{display:block;text-align:center}
2026-06-02 09:40:21 +00:00
}
@keyframes fadeInUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
.section,.signal-bar,.guide{animation:fadeInUp .45s ease both}
</style>
</head>
<body>
<!-- ─── Header ─── -->
<header class="header">
<div class="logo">
2026-06-03 09:21:58 +00:00
<div class="logo-icon">E</div>
2026-06-03 16:42:07 +00:00
MacroScope
2026-06-02 09:40:21 +00:00
</div>
2026-06-03 09:21:58 +00:00
<nav class="view-tabs" id="viewTabs">
<a data-view="macro" class="active">總經</a>
2026-06-03 16:42:07 +00:00
<a data-view="calendar">日曆</a>
2026-06-03 09:21:58 +00:00
<a data-view="learn">學習教材</a>
<a data-view="stock">個股工具</a>
<a data-view="journal">交易復盤</a>
2026-06-04 01:35:37 +00:00
<a data-view="settings">AI 設定</a>
2026-06-03 09:21:58 +00:00
</nav>
2026-06-02 09:40:21 +00:00
<div class="header-right">
2026-06-03 09:21:58 +00:00
<nav class="nav-links" id="navLinks"></nav>
2026-06-02 09:40:21 +00:00
<span class="last-updated" id="lastUpdated"></span>
2026-06-03 16:42:07 +00:00
<button class="refresh-btn" id="refreshBtn" title="檢查並補上最新資料">↻ 更新</button>
2026-06-02 09:40:21 +00:00
</div>
</header>
<main id="main">
2026-06-03 09:21:58 +00:00
<section class="view" id="view-macro">
<div class="state" id="loadingState">
<div class="spinner"></div>
正在抓取真實總經資料…
</div>
</section>
2026-06-03 16:42:07 +00:00
<section class="view" id="view-calendar" hidden></section>
2026-06-03 09:21:58 +00:00
<section class="view" id="view-learn" hidden></section>
<section class="view" id="view-stock" hidden></section>
<section class="view" id="view-journal" hidden></section>
2026-06-04 01:35:37 +00:00
<section class="view" id="view-settings" hidden></section>
2026-06-02 09:40:21 +00:00
</main>
2026-06-04 01:35:37 +00:00
<div id="aiDock" class="ai-dock"></div>
2026-06-02 09:40:21 +00:00
<!-- 浮動說明框 -->
<div id="tooltip" role="tooltip"></div>
<!-- 走勢大圖 -->
<div id="modalOverlay" role="dialog" aria-modal="true">
<div class="modal-panel" id="modalPanel">
<div class="modal-head">
<div>
<div class="modal-title" id="modalTitle"></div>
<div class="modal-now" id="modalNow"></div>
</div>
<button class="modal-close" id="modalClose" aria-label="關閉"></button>
</div>
<div class="range-btns" id="rangeBtns"></div>
<div class="chart-wrap" id="chartWrap"></div>
<div class="modal-tip" id="modalTip"></div>
<div class="modal-foot" id="modalFoot"></div>
</div>
</div>
<script>
// ═══════════════════════════════════════════════════════════
// 顏色對應colorKey → CSS 變數 / 十六進位)
// ═══════════════════════════════════════════════════════════
2026-06-03 09:33:23 +00:00
const HEX = {green:'#34c759',red:'#ff3b30',yellow:'#ff9500',blue:'#0071e3',purple:'#af52de',orange:'#ff9500',text:'#1d1d1f',text2:'#86868b'};
2026-06-02 09:40:21 +00:00
const cssVar = (k)=>`var(--${k})`;
// 給每張卡片的說明資料key → {label, tip, substitute}),供 tooltip 使用
const TIPS = {};
// 卡片基本資訊key → {label, labelEn, colorKey, value}),供走勢大圖使用
const CARD_META = {};
// 歷史事件標記與危機案例(由 /api/events 載入)
let EVENTS = [];
let EPISODES = [];
// ═══════════════════════════════════════════════════════════
// Sparkline SVG吃真實歷史資料
// ═══════════════════════════════════════════════════════════
function sparkle(data,w=180,h=36,color){
if(!data||data.length<2) return '';
const mn=Math.min(...data),mx=Math.max(...data),rng=mx-mn||1;
const pts=data.map((v,i)=>{
const x=(i/(data.length-1))*w;
const y=h-4-((v-mn)/rng)*(h-8);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const lastX=w,lastY=h-4-((data[data.length-1]-mn)/rng)*(h-8);
const id='g_'+color.replace('#','');
const areaPath=`M0,${h} `+pts.map(p=>`L${p}`).join(' ')+` L${w},${h} Z`;
const linePath=pts.map(p=>`L${p}`).join(' ').replace(/^L/,'M');
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${color}" stop-opacity="0.3"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0.02"/>
</linearGradient></defs>
<path d="${areaPath}" fill="url(#${id})"/>
<path d="${linePath}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="${lastX.toFixed(1)}" cy="${lastY.toFixed(1)}" r="2.5" fill="${color}"/>
</svg>`;
}
// ═══════════════════════════════════════════════════════════
// 卡片 HTML
// ═══════════════════════════════════════════════════════════
function arrow(dir){return dir==='up'?'▲':dir==='down'?'▼':'●';}
function cardHTML(c){
2026-06-03 16:42:07 +00:00
TIPS[c.key]={label:c.label,tip:c.tip,substitute:c.substitute,context:c.context};
2026-06-02 09:40:21 +00:00
CARD_META[c.key]={label:c.label,labelEn:c.labelEn,colorKey:c.valueColorKey,value:c.value};
const valColor=cssVar(c.valueColorKey);
const chColor=cssVar(c.changeColorKey);
const sparkHex=HEX[c.valueColorKey]||HEX.blue;
const subTag=c.substitute?`<span class="sub-tag" title="免費替代指標">替代:${c.substitute}</span>`:'';
const change=c.change?`<div class="card-change" style="color:${chColor}">${arrow(c.dir)} ${c.change}</div>`:`<div class="card-change"></div>`;
2026-06-03 16:42:07 +00:00
const ctxTone=c.context?.tone&&c.context.tone!=='neutral'?` ${c.context.tone}`:'';
const ctxLine=c.context?.summary?`<div class="card-context${ctxTone}">${c.context.summary}</div>`:'';
2026-06-02 09:40:21 +00:00
return `
<div class="card" data-key="${c.key}" role="button" tabindex="0" aria-label="${c.label},點擊看走勢">
<div class="card-top">
<div class="card-labels">
<span class="card-label">${c.label}</span>
<span class="card-label-en">${c.labelEn||''}</span>
</div>
<div class="card-top-right">
<span class="badge ${c.badgeKind}">${c.badge}</span>
<button class="info-btn" data-tip-key="${c.key}" aria-label="說明:${c.label}" tabindex="0">?</button>
</div>
</div>
${subTag}
<div class="card-value" style="color:${valColor}">${c.value}</div>
${change}
2026-06-03 16:42:07 +00:00
${ctxLine}
2026-06-02 09:40:21 +00:00
<div class="card-sparkline">${sparkle(c.spark,180,36,sparkHex)}</div>
2026-06-03 16:42:07 +00:00
<div class="card-meta"><span>資料日 ${c.asOf||'—'}</span><span>點卡片看歷史走勢</span></div>
2026-06-02 09:40:21 +00:00
</div>`;
}
// ─── 殖利率曲線(真實資料)───
function yieldCurveHTML(yc){
if(!yc||!yc.maturities||yc.maturities.length<2) return '';
const maturities=yc.maturities, yields=yc.yields, prevYields=yc.prevYields;
const w=520,h=150,padL=40,padB=25,padT=14,padR=10;
const plotW=w-padL-padR, plotH=h-padB-padT;
const all=yields.concat(prevYields);
let yMin=Math.min(...all), yMax=Math.max(...all);
const pad=(yMax-yMin)*0.15||0.3; yMin-=pad; yMax+=pad;
const yRange=yMax-yMin||1;
const toX=(i)=>padL+(i/(maturities.length-1))*plotW;
const toY=(v)=>padT+(1-(v-yMin)/yRange)*plotH;
const cur=yields.map((y,i)=>`${i===0?'M':'L'}${toX(i).toFixed(1)},${toY(y).toFixed(1)}`).join(' ');
const prev=prevYields.map((y,i)=>`${i===0?'M':'L'}${toX(i).toFixed(1)},${toY(y).toFixed(1)}`).join(' ');
let grid='';
const step=(yMax-yMin)/4;
for(let k=0;k<=4;k++){const v=yMin+step*k;
2026-06-03 09:33:23 +00:00
grid+=`<line x1="${padL}" y1="${toY(v)}" x2="${w-padR}" y2="${toY(v)}" stroke="rgba(0,0,0,.06)"/>`;
grid+=`<text x="${padL-5}" y="${toY(v)+4}" fill="#86868b" font-size="9" text-anchor="end">${v.toFixed(1)}%</text>`;}
maturities.forEach((m,i)=>{grid+=`<text x="${toX(i)}" y="${h-6}" fill="#86868b" font-size="9" text-anchor="middle">${m}</text>`;});
2026-06-02 09:40:21 +00:00
TIPS['yield_curve']={label:'殖利率曲線',tip:{
what:'不同到期天數3個月到30年的公債殖利率連成的曲線。',
how:'正常是右上斜(長率>短率)。若左高右低(倒掛),代表短率高於長率。',
impact:'反向警訊:倒掛在歷史上是相當可靠的衰退前兆。虛線為一個月前比較。',
source:'FRED · DGS 系列', freq:'每日'},substitute:null};
return `
<div class="card wide">
<div class="card-top">
<div class="card-labels">
<span class="card-label">殖利率曲線</span>
<span class="card-label-en">Yield Curve</span>
</div>
<div class="card-top-right">
<span class="badge ${yc.inverted?'bad':'good'}">${yc.inverted?'倒掛':'正常'}</span>
<button class="info-btn" data-tip-key="yield_curve" aria-label="說明:殖利率曲線" tabindex="0">?</button>
</div>
</div>
<svg width="100%" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
${grid}
<path d="${prev}" fill="none" stroke="#8899aa" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.5"/>
<path d="${cur}" fill="none" stroke="#4da6ff" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
${yields.map((y,i)=>`<circle cx="${toX(i).toFixed(1)}" cy="${toY(y).toFixed(1)}" r="3.2" fill="#4da6ff" stroke="#111822" stroke-width="1.5"/>`).join('')}
<line x1="${padL+10}" y1="${padT}" x2="${padL+30}" y2="${padT}" stroke="#4da6ff" stroke-width="2"/>
<text x="${padL+34}" y="${padT+4}" fill="#4da6ff" font-size="9">目前</text>
<line x1="${padL+78}" y1="${padT}" x2="${padL+98}" y2="${padT}" stroke="#8899aa" stroke-width="1.5" stroke-dasharray="4,3" opacity=".6"/>
2026-06-03 09:33:23 +00:00
<text x="${padL+102}" y="${padT+4}" fill="#86868b" font-size="9">一個月前</text>
2026-06-02 09:40:21 +00:00
</svg>
</div>`;
}
// ═══════════════════════════════════════════════════════════
// 渲染整頁
// ═══════════════════════════════════════════════════════════
function guideHTML(){
return `
<div class="guide">
2026-06-03 16:42:07 +00:00
<div class="guide-title"><span class="tag">閱讀順序</span>不要從每個數字開始,先問三個問題</div>
2026-06-02 09:40:21 +00:00
<div class="guide-grid">
2026-06-03 16:42:07 +00:00
<div><b>1. 風險環境</b>:先看健康分數與景氣狀態,只判斷現在是順風、逆風,還是需要保留現金。</div>
<div><b>2. 壓力來源</b>:再看哪一組在拖累:利率、通膨、就業、信用或市場情緒。這會決定你該研究哪類資產。</div>
<div><b>3. 證據細節</b>:最後才點卡片看長期走勢與事件標記。不要只看最新值,轉折比單點更重要。</div>
<div><b>顏色</b><span class="legend-dot" style="background:var(--green)"></span>綠=偏好訊號、<span class="legend-dot" style="background:var(--red)"></span>紅=警訊、<span class="legend-dot" style="background:var(--yellow)"></span>黃=中性、<span class="legend-dot" style="background:var(--blue)"></span>藍=純數值。</div>
<div><b>反向指標</b>失業率、VIX、信用利差、衰退機率這類「越高越糟」所以下降才是改善。</div>
<div><b>?</b>:滑鼠移上去或點一下,看「這數字代表什麼、歷史上算高還是低、會影響什麼」。卡片上的藍色小字是近 20 年百分位摘要。</div>
<div><b>資料缺口</b>:付費指標會以免費替代資料呈現,抓不到的項目會列在資料覆蓋狀態,不再混在表格裡讓人誤判。</div>
2026-06-02 09:40:21 +00:00
</div>
</div>`;
}
2026-06-03 16:42:07 +00:00
function macroHeroHTML(data, scoreColor, signals){
const total=(data.groups||[]).reduce((n,g)=>n+(g.cards?g.cards.length:0),0);
const substitute=(data.groups||[]).flatMap(g=>g.cards||[]).filter(c=>c.substitute).length;
const degraded=(data.degraded||[]).length;
const scored=(data.breakdown||[]).length;
const score=Number(data.score);
let title='先看風向,再決定要不要出手';
let copy='總經頁會把資料整理成一個「投資天氣」:不是要你背指標,而是快速知道現在順風在哪、風險在哪。';
if(!Number.isNaN(score)){
if(score>=70){title='環境偏順風,但仍要確認估值與部位';copy='健康分數偏高,代表多數總經訊號對風險資產較友善。接著看信用、通膨是否同步支持,避免只被股價情緒帶走。';}
else if(score>=45){title='環境混合,適合分批與等待確認';copy='訊號並不單邊,這時候最重要的是辨認拖累來源。先縮小觀察清單,等數據方向更一致再加大部位。';}
else {title='環境偏逆風,重點是防守與耐心';copy='健康分數偏低,代表風險資產面臨較多壓力。先保護本金、觀察信用和就業是否止穩,再談積極進場。';}
}
return `
<div class="macro-hero">
<section class="hero-panel" style="--hero-color:${scoreColor}">
<div class="hero-kicker">Market read</div>
<div class="hero-title">${title}</div>
<div class="hero-copy">${copy}</div>
<div class="hero-actions">
<button class="action-link" data-scroll-target="group-rates">看利率壓力</button>
<button class="action-link" data-scroll-target="group-money">看信用壓力</button>
<button class="action-link" data-scroll-target="group-history">對照歷史</button>
</div>
</section>
<section class="signal-bar">
<div class="signal-score">
<div class="label">總經健康分數 <button class="info-btn" data-tip-key="__score" aria-label="分數計算說明" tabindex="0">?</button></div>
<div class="value" id="scoreClick" style="color:${scoreColor}" role="button" tabindex="0" title="點擊看分數走勢">${data.score ?? '--'}</div>
<div class="sublabel">/ 100 · 點擊看走勢</div>
</div>
<div class="signal-details">${signals}</div>
<div class="signal-regime">
<div class="label">目前景氣狀態</div>
<div class="regime-badge" style="background:${hexA(data.regime?data.regime.colorKey:'yellow',.15)};color:${scoreColor}">${data.regime?data.regime.label:'--'}</div>
</div>
</section>
<aside class="coverage-panel">
<div class="coverage-title">資料覆蓋狀態</div>
<div class="coverage-list">
<div class="coverage-row"><span>已接入指標</span><b>${total}</b></div>
<div class="coverage-row"><span>納入健康分數</span><b>${scored}</b></div>
<div class="coverage-row"><span>免費替代指標</span><b>${substitute}</b></div>
<div class="coverage-row"><span>暫時缺資料</span><b class="${degraded?'coverage-warn':'coverage-good'}">${degraded}</b></div>
</div>
<div class="coverage-note">${degraded?`有 ${degraded} 個資料源暫時抓不到,頁面仍會顯示其他真實資料。`:'目前主要資料源可用;付費資料已用免費替代指標補上。'}</div>
</aside>
</div>`;
}
2026-06-02 09:40:21 +00:00
function render(data){
2026-06-03 09:21:58 +00:00
const main=document.getElementById('view-macro');
2026-06-02 09:40:21 +00:00
const scoreColor=cssVar(data.regime?data.regime.colorKey:'yellow');
// 訊號 pills
const signals=(data.signals||[]).map(s=>
`<div class="signal-pill"><div class="pill-label">${s.label}</div><div class="pill-value" style="color:${cssVar(s.colorKey)}">${s.value}</div></div>`
).join('');
// 健康分數說明breakdown放進 TIPS
TIPS['__score']={label:'總經健康分數怎麼算',breakdown:data.breakdown||[]};
2026-06-03 16:42:07 +00:00
let html = macroHeroHTML(data, scoreColor, signals) + guideHTML();
2026-06-02 09:40:21 +00:00
// 降級提示
if(data.degraded&&data.degraded.length){
html += `<div class="degraded-note">提醒:有 ${data.degraded.length} 個指標暫時抓取失敗(${data.degraded.slice(0,3).map(d=>d.label).join('、')}${data.degraded.length>3?'…':''}),其餘為真實資料。</div>`;
}
// 各分組
const nav=[];
(data.groups||[]).forEach(g=>{
if(!g.cards||g.cards.length===0) return;
nav.push(`<a data-target="group-${g.key}">${g.title}</a>`);
let cards;
if(g.key==='rates'){
// 把殖利率曲線插進利率組(放在利差之後)
const arr=g.cards.map(cardHTML);
arr.splice(Math.min(4,arr.length),0,yieldCurveHTML(data.yieldCurve));
cards=arr.join('');
}else{
cards=g.cards.map(cardHTML).join('');
}
html += `
<div class="section" id="group-${g.key}">
<div class="section-header">
<div class="section-icon" style="background:${hexA(g.colorKey,.15)};color:${cssVar(g.colorKey)}">${g.icon}</div>
<div class="section-title">${g.title}</div>
<div class="section-subtitle">${g.titleEn}</div>
</div>
<p class="section-intro">${g.intro}</p>
<div class="card-grid">${cards}</div>
</div>`;
});
// 歷史殷鑑(危機案例)
if(EPISODES&&EPISODES.length){
nav.push(`<a data-target="group-history">歷史殷鑑</a>`);
html += episodesSectionHTML();
}
main.innerHTML=html;
document.getElementById('navLinks').innerHTML=nav.join('');
// 更新時間
const t=new Date(data.updatedAt||Date.now());
document.getElementById('lastUpdated').textContent=
`更新:${t.toLocaleDateString('zh-TW')} ${t.toLocaleTimeString('zh-TW',{hour:'2-digit',minute:'2-digit'})}${data.cached?'(快取)':''}`;
bindNav();
bindTooltips();
bindCardClicks();
bindEpisodes();
2026-06-03 16:42:07 +00:00
document.querySelectorAll('[data-scroll-target]').forEach(btn=>btn.addEventListener('click',()=>{
const el=document.getElementById(btn.dataset.scrollTarget);
if(el) window.scrollTo({top:el.offsetTop-70,behavior:'smooth'});
}));
2026-06-02 09:40:21 +00:00
const sc=document.getElementById('scoreClick');
if(sc){sc.addEventListener('click',openScoreModal);sc.addEventListener('keydown',e=>{if(e.key==='Enter')openScoreModal();});}
}
function bindCardClicks(){
document.querySelectorAll('#main .card[data-key]').forEach(el=>{
const key=el.dataset.key;
el.addEventListener('click',(e)=>{ if(e.target.closest('.info-btn'))return; openModal(key); });
el.addEventListener('keydown',(e)=>{ if(e.key==='Enter'||e.key===' '){e.preventDefault();openModal(key);} });
});
}
function hexA(colorKey,a){
const map={green:'0,212,170',red:'255,77,106',yellow:'255,193,77',blue:'77,166,255',purple:'179,136,255',orange:'255,138,77'};
return `rgba(${map[colorKey]||map.yellow},${a})`;
}
// ─── 歷史殷鑑(危機案例)───
function episodesSectionHTML(){
const evChips=(EVENTS||[]).map(e=>`<span class="ev-chip">${e.emoji} ${e.label}${e.date.slice(0,4)}</span>`).join('');
const cards=EPISODES.map(ep=>{
const ec=cssVar(ep.colorKey||'red');
const sigs=ep.signals.map(s=>
`<div class="ep-sig" data-key="${s.key}"><b>${s.label}</b>${s.text}<span class="sig-go">看走勢 →</span></div>`
).join('');
return `
<div class="episode" style="--ec:${ec}">
<div class="ep-head">
<span class="ep-emoji">${ep.emoji}</span>
<div>
<div class="ep-title">${ep.title}<span class="ep-period">${ep.period}</span></div>
<span class="ep-type">${ep.type==='recovery'?'復甦範例':'危機'}</span>
</div>
</div>
<p class="ep-summary">${ep.summary}</p>
<div class="ep-sig-title">當時出現的預警訊號</div>
<div class="ep-sigs">${sigs}</div>
<div class="ep-lesson"><b>啟示|</b>${ep.lesson}</div>
<div class="ep-watch"><b>現在可觀察|</b>${ep.watchNow}</div>
<button class="ep-btn" data-key="${ep.focusKey}">看當時走勢(${CARD_META[ep.focusKey]?CARD_META[ep.focusKey].label:ep.focusKey})→</button>
</div>`;
}).join('');
return `
<div class="section" id="group-history">
<div class="section-header">
<div class="section-icon" style="background:${hexA('red',.15)};color:var(--red)">📜</div>
<div class="section-title">歷史殷鑑</div>
<div class="section-subtitle">Crisis Playbook</div>
</div>
<p class="section-intro">回顧幾次重大危機與反彈:當時哪些總經指標「提前」出現異常?學會辨識這些訊號,下一次就能更早觀察。點任一訊號或卡片,會打開該指標的長期走勢,並標出事件發生的時點。</p>
<p class="ep-legend">走勢大圖上的標記:${evChips}</p>
<div class="episode-grid">${cards}</div>
</div>`;
}
function bindEpisodes(){
document.querySelectorAll('#group-history .ep-sig, #group-history .ep-btn').forEach(el=>{
el.addEventListener('click',()=>{ const k=el.dataset.key; if(k) openModal(k,'max'); });
});
}
// ─── 導覽列:平滑捲動 + 捲動高亮 ───
function bindNav(){
document.querySelectorAll('#navLinks a').forEach(a=>{
a.addEventListener('click',()=>{
const el=document.getElementById(a.dataset.target);
if(el) window.scrollTo({top:el.offsetTop-70,behavior:'smooth'});
});
});
}
window.addEventListener('scroll',()=>{
const links=[...document.querySelectorAll('#navLinks a')];
let cur=null;
links.forEach(a=>{
const el=document.getElementById(a.dataset.target);
if(el&&el.offsetTop-90<=window.scrollY) cur=a;
});
links.forEach(a=>a.classList.toggle('active',a===cur));
});
// ─── Tooltip ───
const tooltipEl=document.getElementById('tooltip');
function tipContent(key){
if(key==='__score'){
const b=TIPS['__score'].breakdown||[];
const rows=b.length?b.map(x=>`<div class="tip-break"><span>${x.label}${x.note}</span><span class="${x.delta>=0?'d-pos':'d-neg'}">${x.delta>=0?'+':''}${x.delta}</span></div>`).join(''):'<div class="tip-row">尚無足夠資料計分。</div>';
return `<div class="tip-title">總經健康分數怎麼算</div><div class="tip-row" style="margin-bottom:8px">從 50 分(中性)出發,依下列規則加減:</div>${rows}`;
}
const t=TIPS[key]; if(!t||!t.tip) return '';
const tip=t.tip;
2026-06-03 16:42:07 +00:00
const ctx=tip.context;
let html=`<div class="tip-title">${t.label}</div>
<div class="tip-row"><span class="tip-k">這是什麼</span>${tip.what}</div>`;
if(ctx?.number) html+=`<div class="tip-row"><span class="tip-k">這數字</span>${ctx.number}</div>`;
if(ctx?.history) html+=`<div class="tip-row"><span class="tip-k">歷史上</span>${ctx.history}</div>`;
if(ctx?.affects||tip.impact) html+=`<div class="tip-row"><span class="tip-k">會影響</span>${ctx?.affects||tip.impact}</div>`;
html+=`<div class="tip-row"><span class="tip-k">怎麼看</span>${tip.how}</div>
<div class="tip-foot"><span>${tip.source||''}</span><span>${tip.freq||''}</span></div>
<div class="tip-link-hint">點卡片可看幾十年走勢,對照 2008、2020 等轉折。</div>`;
return html;
2026-06-02 09:40:21 +00:00
}
function showTip(btn){
const key=btn.dataset.tipKey;
const html=tipContent(key);
if(!html) return;
tooltipEl.innerHTML=html;
tooltipEl.classList.add('show');
const r=btn.getBoundingClientRect();
const tw=tooltipEl.offsetWidth, th=tooltipEl.offsetHeight;
let left=r.left+r.width/2-tw/2;
left=Math.max(10,Math.min(left,window.innerWidth-tw-10));
let top=r.top-th-10;
if(top<10) top=r.bottom+10; // 上方空間不足就放下方
tooltipEl.style.left=left+'px';
tooltipEl.style.top=top+'px';
}
function hideTip(){tooltipEl.classList.remove('show');}
function bindTooltips(){
document.querySelectorAll('.info-btn').forEach(btn=>{
btn.addEventListener('mouseenter',()=>showTip(btn));
btn.addEventListener('mouseleave',hideTip);
btn.addEventListener('focus',()=>showTip(btn));
btn.addEventListener('blur',hideTip);
btn.addEventListener('click',(e)=>{e.preventDefault();tooltipEl.classList.contains('show')?hideTip():showTip(btn);});
});
}
// ═══════════════════════════════════════════════════════════
// 走勢大圖 Modal
// ═══════════════════════════════════════════════════════════
const overlay=document.getElementById('modalOverlay');
let MODAL={key:null,range:'1y',isScore:false};
let CHARTGEO={};
const RANGES=[['1m','1個月'],['6m','6個月'],['1y','1年'],['5y','5年'],['10y','10年'],['max','全部']];
function openModal(key,range){
2026-06-03 09:21:58 +00:00
// 預設開啟即顯示「全部」長期走勢,直接看到十年以上歷史與事件標記
MODAL={key,range:range||'max',isScore:false};
2026-06-02 09:40:21 +00:00
const meta=CARD_META[key]||{label:key};
2026-06-04 01:35:37 +00:00
window.setAIFocus?.({type:'macro-indicator',key,label:meta.label||key,range:MODAL.range});
2026-06-02 09:40:21 +00:00
document.getElementById('modalTitle').innerHTML=`${meta.label}<span class="en">${meta.labelEn||''}</span>`;
const now=document.getElementById('modalNow');
now.textContent=meta.value?('目前:'+meta.value):'';
now.style.color=meta.colorKey?cssVar(meta.colorKey):'var(--text)';
renderRangeBtns();
const tip=TIPS[key]&&TIPS[key].tip;
2026-06-03 16:42:07 +00:00
const ctx=tip&&tip.context;
let modalTip='';
if(ctx){
modalTip=`<div><span class="tip-k">這數字</span>${ctx.number}</div>`;
modalTip+=`<div style="margin-top:8px"><span class="tip-k">歷史上</span>${ctx.history}</div>`;
modalTip+=`<div style="margin-top:8px"><span class="tip-k">會影響</span>${ctx.affects||tip.impact||''}</div>`;
}else if(tip) modalTip=`<div><span class="tip-k">怎麼看</span>${tip.how}</div>`;
document.getElementById('modalTip').innerHTML=modalTip;
2026-06-02 09:40:21 +00:00
document.getElementById('modalFoot').textContent=tip?`${tip.source||''} ${tip.freq||''}`:'';
overlay.classList.add('show');
loadSeries();
}
function openScoreModal(){
MODAL={key:'__score',range:'max',isScore:true};
2026-06-04 01:35:37 +00:00
window.setAIFocus?.({type:'macro-score',key:'__score',label:'總經健康分數',range:'max'});
2026-06-02 09:40:21 +00:00
document.getElementById('modalTitle').innerHTML='總經健康分數走勢<span class="en">Macro Health Score</span>';
document.getElementById('modalNow').textContent='';
document.getElementById('rangeBtns').innerHTML='';
document.getElementById('modalTip').innerHTML='<div><span class="tip-k">說明</span>每天記錄一筆分數,會隨使用天數累積成走勢。</div>';
document.getElementById('modalFoot').textContent='本機累積資料';
overlay.classList.add('show');
loadScoreHistory();
}
function renderRangeBtns(){
document.getElementById('rangeBtns').innerHTML=RANGES.map(([r,l])=>
`<button class="range-btn ${r===MODAL.range?'active':''}" data-range="${r}">${l}</button>`).join('');
document.querySelectorAll('#rangeBtns .range-btn').forEach(b=>
b.addEventListener('click',()=>{MODAL.range=b.dataset.range;renderRangeBtns();loadSeries();}));
}
async function loadSeries(){
const wrap=document.getElementById('chartWrap');
wrap.innerHTML='<div class="chart-empty">載入中…</div>';
try{
const res=await fetch(`/api/series/${encodeURIComponent(MODAL.key)}?range=${MODAL.range}`);
const data=await res.json();
if(!data.points||data.points.length<2){wrap.innerHTML='<div class="chart-empty">此區間資料不足。</div>';return;}
const color=HEX[(CARD_META[MODAL.key]&&CARD_META[MODAL.key].colorKey)||'blue']||HEX.blue;
wrap.innerHTML=lineChart(data.points,{format:data.format,decimals:data.decimals,color,events:EVENTS});
bindChartHover(data.points,{format:data.format,decimals:data.decimals});
2026-06-03 09:21:58 +00:00
// 底部標出此指標實際的資料起訖,讓人一眼知道能回看多久
const sp=data.points,src=(TIPS[MODAL.key]&&TIPS[MODAL.key].tip&&TIPS[MODAL.key].tip.source)||'';
document.getElementById('modalFoot').textContent=`資料區間 ${sp[0].date} ~ ${sp[sp.length-1].date} ${src}`;
2026-06-02 09:40:21 +00:00
}catch(e){wrap.innerHTML='<div class="chart-empty">載入失敗。</div>';}
}
async function loadScoreHistory(){
const wrap=document.getElementById('chartWrap');
wrap.innerHTML='<div class="chart-empty">載入中…</div>';
try{
const res=await fetch('/api/score-history');
const data=await res.json();
const pts=(data.points||[]).map(p=>({date:p.date,val:p.score}));
if(pts.length<2){wrap.innerHTML='<div class="chart-empty">分數走勢從今天開始累積,明天起就會出現曲線。</div>';return;}
wrap.innerHTML=lineChart(pts,{format:'num0',decimals:0,color:HEX.blue,yMin:0,yMax:100});
bindChartHover(pts,{format:'num0',decimals:0});
}catch(e){wrap.innerHTML='<div class="chart-empty">載入失敗。</div>';}
}
function closeModal(){overlay.classList.remove('show');}
document.getElementById('modalClose').addEventListener('click',closeModal);
overlay.addEventListener('click',e=>{if(e.target===overlay)closeModal();});
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal();});
// 數值格式化(與後端一致,給座標軸用)
function fmtVal(v,format,d){
d=d??2;
const c=(n,dd)=>n.toLocaleString('en-US',{minimumFractionDigits:dd,maximumFractionDigits:dd});
switch(format){
case 'pct':case 'pct_signed':return v.toFixed(d)+'%';
case 'bp':return Math.round(v)+'bp';
case 'num0':return c(v,0);
case 'num1':return v.toFixed(1);
case 'num2':case 'num2_signed':return v.toFixed(2);
case 'k':case 'k_signed':return Math.round(v)+'K';
case 'trillions':return '$'+v.toFixed(d)+'T';
case 'usd':return '$'+v.toFixed(d);
case 'usd0':return '$'+c(v,0);
default:return v.toFixed(d);
}
}
// 在已排序(日期遞增)的序列中,找最接近指定日期的索引;超出範圍回 -1
function nearestIdxByDate(points,dateStr){
const t=new Date(dateStr).getTime();
if(!points.length) return -1;
if(t<new Date(points[0].date).getTime()||t>new Date(points[points.length-1].date).getTime()) return -1;
let best=0,bestDiff=Infinity;
for(let i=0;i<points.length;i++){
const d=Math.abs(new Date(points[i].date).getTime()-t);
if(d<bestDiff){bestDiff=d;best=i;}
}
return best;
}
function lineChart(points,opts){
const w=720,h=320,padL=58,padR=16,padT=24,padB=34;
const plotW=w-padL-padR, plotH=h-padT-padB;
const vals=points.map(p=>p.val);
let yMin=opts.yMin!=null?opts.yMin:Math.min(...vals);
let yMax=opts.yMax!=null?opts.yMax:Math.max(...vals);
if(yMin===yMax){yMin-=1;yMax+=1;}
if(opts.yMin==null){const p=(yMax-yMin)*0.08;yMin-=p;yMax+=p;}
const yRange=yMax-yMin||1;
const n=points.length;
CHARTGEO={padL,plotW,padT,plotH,n,yMin,yRange,w,h};
const toX=i=>padL+(i/(n-1))*plotW;
const toY=v=>padT+(1-(v-yMin)/yRange)*plotH;
let grid='';const ticks=5;
for(let k=0;k<=ticks;k++){const v=yMin+(yRange*k/ticks);const y=toY(v);
2026-06-03 09:33:23 +00:00
grid+=`<line x1="${padL}" y1="${y.toFixed(1)}" x2="${w-padR}" y2="${y.toFixed(1)}" stroke="rgba(0,0,0,.06)"/>`;
grid+=`<text x="${padL-8}" y="${(y+3.5).toFixed(1)}" fill="#86868b" font-size="11" text-anchor="end">${fmtVal(v,opts.format,opts.decimals)}</text>`;}
2026-06-02 09:40:21 +00:00
let xlab='';const xt=Math.min(5,n);
for(let k=0;k<xt;k++){const idx=Math.round(k*(n-1)/(xt-1));const dt=points[idx].date;
2026-06-03 09:33:23 +00:00
xlab+=`<text x="${toX(idx).toFixed(1)}" y="${h-12}" fill="#86868b" font-size="10" text-anchor="middle">${dt.slice(2,7).replace('-','/')}</text>`;}
2026-06-02 09:40:21 +00:00
let zero='';if(yMin<0&&yMax>0){const zy=toY(0);zero=`<line x1="${padL}" y1="${zy.toFixed(1)}" x2="${w-padR}" y2="${zy.toFixed(1)}" stroke="rgba(255,255,255,.18)" stroke-dasharray="3,3"/>`;}
// 歷史事件垂直標記(只畫落在此區間內的)
let marks='';
(opts.events||[]).forEach(ev=>{
const idx=nearestIdxByDate(points,ev.date);
if(idx<0) return;
const x=toX(idx);
marks+=`<g><title>${ev.date} ${ev.label}</title>`+
`<line x1="${x.toFixed(1)}" y1="${padT}" x2="${x.toFixed(1)}" y2="${(padT+plotH).toFixed(1)}" stroke="rgba(255,193,77,.45)" stroke-width="1" stroke-dasharray="3,3"/>`+
`<text x="${x.toFixed(1)}" y="${(padT-7).toFixed(1)}" fill="#ffc14d" font-size="12" text-anchor="middle">${ev.emoji}</text>`+
`</g>`;
});
const linePts=points.map((p,i)=>`${i===0?'M':'L'}${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ');
const area=`M${padL},${(padT+plotH).toFixed(1)} `+points.map((p,i)=>`L${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' ')+` L${(padL+plotW).toFixed(1)},${(padT+plotH).toFixed(1)} Z`;
const last=points[n-1];
return `<svg id="bigChart" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
<defs><linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${opts.color}" stop-opacity=".25"/><stop offset="100%" stop-color="${opts.color}" stop-opacity="0"/></linearGradient></defs>
${grid}${xlab}${zero}
<path d="${area}" fill="url(#bgGrad)"/>
<path d="${linePts}" fill="none" stroke="${opts.color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
${marks}
<circle cx="${toX(n-1).toFixed(1)}" cy="${toY(last.val).toFixed(1)}" r="3.5" fill="${opts.color}"/>
2026-06-03 09:33:23 +00:00
<g id="hoverG" style="display:none"><line id="hoverLine" y1="${padT}" y2="${padT+plotH}" stroke="#8899aa" stroke-dasharray="3,3"/><circle id="hoverDot" r="4" fill="${opts.color}" stroke="#ffffff" stroke-width="2"/></g>
2026-06-02 09:40:21 +00:00
<rect id="hoverArea" x="${padL}" y="${padT}" width="${plotW}" height="${plotH}" fill="transparent" style="cursor:crosshair"/>
2026-06-03 09:33:23 +00:00
<text id="hoverText" fill="#1d1d1f" font-size="11.5" font-weight="600" text-anchor="middle" style="display:none"></text>
2026-06-02 09:40:21 +00:00
</svg>`;
}
function bindChartHover(points,opts){
const svg=document.getElementById('bigChart');if(!svg)return;
const g=CHARTGEO;
const area=document.getElementById('hoverArea');
const hg=document.getElementById('hoverG'),line=document.getElementById('hoverLine'),dot=document.getElementById('hoverDot'),txt=document.getElementById('hoverText');
const toX=i=>g.padL+(i/(g.n-1))*g.plotW;
const toY=v=>g.padT+(1-(v-g.yMin)/g.yRange)*g.plotH;
function move(evt){
const r=svg.getBoundingClientRect();
const sx=(evt.clientX-r.left)*(g.w/r.width);
let i=Math.round(((sx-g.padL)/g.plotW)*(g.n-1));
i=Math.max(0,Math.min(g.n-1,i));
const p=points[i],x=toX(i),y=toY(p.val);
hg.style.display='';line.setAttribute('x1',x);line.setAttribute('x2',x);
dot.setAttribute('cx',x);dot.setAttribute('cy',y);
txt.style.display='';txt.setAttribute('x',Math.max(46,Math.min(g.w-46,x)));txt.setAttribute('y',g.padT+11);
txt.textContent=`${p.date} ${fmtVal(p.val,opts.format,opts.decimals)}`;
}
area.addEventListener('mousemove',move);
area.addEventListener('mouseleave',()=>{hg.style.display='none';txt.style.display='none';});
}
// ═══════════════════════════════════════════════════════════
// 載入資料
// ═══════════════════════════════════════════════════════════
async function load(fresh){
2026-06-03 09:21:58 +00:00
const main=document.getElementById('view-macro');
2026-06-02 09:40:21 +00:00
main.innerHTML=`<div class="state"><div class="spinner"></div>正在抓取真實總經資料…</div>`;
try{
const [res,evRes]=await Promise.all([
fetch('/api/macro'+(fresh?'?fresh=1':'')),
fetch('/api/events').catch(()=>null),
]);
const data=await res.json();
if(!res.ok){ renderError(data); return; }
if(evRes&&evRes.ok){ try{const ev=await evRes.json();EVENTS=ev.events||[];EPISODES=ev.episodes||[];}catch{} }
render(data);
}catch(err){
renderError({message:'無法連線到伺服器。請確認伺服器已啟動npm start。',detail:String(err)});
}
}
function renderError(data){
2026-06-03 09:21:58 +00:00
const main=document.getElementById('view-macro');
2026-06-02 09:40:21 +00:00
if(data&&data.error==='missing_api_key'){
main.innerHTML=`<div class="state"><div class="err-box">
<h2>還差一步:設定免費的 FRED 金鑰</h2>
2026-06-04 01:35:37 +00:00
<p>本儀表板的真實資料來自美國聖路易聯儲的 FRED。你可以到頂部「AI 設定」頁貼上金鑰,系統會寫入本機 <code>.env</code></p>
2026-06-02 09:40:21 +00:00
<ol style="margin:12px 0 0 18px">
<li><a href="${data.hint}" target="_blank" rel="noopener">FRED 申請頁面</a> 註冊並取得免費金鑰</li>
2026-06-04 01:35:37 +00:00
<li>切到「AI 設定」頁,把金鑰貼到 <code>FRED_API_KEY</code></li>
<li>按「儲存設定」後回來重新載入總經頁</li>
2026-06-02 09:40:21 +00:00
</ol>
<button class="retry" onclick="load(true)">我設定好了,重新載入</button>
</div></div>`;
return;
}
main.innerHTML=`<div class="state"><div class="err-box">
<h2>載入失敗</h2>
<p>${(data&&data.message)||'發生未知錯誤。'}</p>
${data&&data.detail?`<p style="color:var(--text2);font-size:.8rem;margin-top:8px">${data.detail}</p>`:''}
<button class="retry" onclick="load(true)">重新載入</button>
</div></div>`;
}
document.getElementById('refreshBtn').addEventListener('click',()=>load(true));
document.addEventListener('DOMContentLoaded',()=>load(false));
</script>
2026-06-03 09:33:23 +00:00
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
2026-06-03 16:42:07 +00:00
<script src="lib/glossary.js"></script>
<script src="lib/learn-html.js"></script>
2026-06-03 09:21:58 +00:00
<script src="app.js"></script>
2026-06-02 09:40:21 +00:00
</body>
</html>