finance-dashboard/lib/learn-html.js

360 lines
18 KiB
JavaScript
Raw Normal View History

2026-06-03 16:42:07 +00:00
// 學習教材 HTML 渲染:白話化、互動路徑、案例→通用原則
(function (global) {
const KIND_LABEL = {
overview: '課綱總覽', principleMap: '原則地圖', quiz: '練習題庫',
category: '學習分類', case: '案例講解', principle: '投資原則',
term: '名詞', company: '公司', episode: '單集',
};
const LEARN_PATHS = {
market: {
title: '我想知道:現在大環境適不適合加碼?',
lead: '先別猜明天漲跌。用利率、通膨、就業、信用四條線,決定你該積極還是保守。',
steps: [
{ title: '① 讀懂大環境在說什麼', body: '利率往哪走、通膨高不高、就業強不強——這三個決定「順風還是逆風」。', read: { kind: 'category', id: '總經與利率', label: '總經與利率(分類)' }, practice: { view: 'macro', label: '打開總經儀表板' } },
{ title: '② 用一套流程決定倉位', body: '不是滿倉或空手,而是「這週該偏進攻還是偏防守」。', read: { kind: 'case', id: '總經數據怎麼看', label: '案例:總經數據怎麼看' }, practice: { view: 'macro', label: '對照 CPI、非農卡片' } },
{ title: '③ 把原則記下來', body: '每次調倉都寫「為什麼」,避免事後用結果論合理化。', read: { kind: 'category', id: '交易與資金管理', label: '交易與資金管理' }, practice: { view: 'journal', label: '寫一筆復盤' } },
],
},
stock: {
title: '我想知道:這家公司值不值得研究?',
lead: '把財報、生意模式、估值、趨勢拆成檢查清單,重複用在新公司上。',
steps: [
{ title: '① 財報在說什麼', body: '營收、毛利、EPS、財測——先會讀再談貴不貴。', read: { kind: 'category', id: '財報基本功', label: '財報基本功' }, read2: { kind: 'case', id: 'NVIDIA財報怎麼看', label: '案例:財報怎麼看' }, practice: { view: 'stock', label: '個股工具 · 財報健檢' } },
{ title: '② 生意好不好、有沒有護城河', body: '數字背後是定價權與產業位置,別只看熱門題材。', read: { kind: 'category', id: '護城河與商業模式', label: '護城河與商業模式' }, practice: { view: 'stock', label: '個股工具 · 投資地圖' } },
{ title: '③ 用案例練「可重複的判斷」', body: 'NVDA、台積電只是例子重點是抽出你下次也能用的問題。', read: { kind: 'case', id: 'NVIDIA決策複盤', label: '案例:決策複盤(框架)' }, practice: { view: 'stock', label: '查一檔你關心的股票' } },
],
},
trade: {
title: '我想知道:這筆交易哪裡做對、哪裡做錯?',
lead: '賺賠是結果;真正要檢查的是當初的理由、風險與紀律有沒有成立。',
steps: [
{ title: '① 避開結果論', body: '賺了不代表判斷對,賠了也不代表一定錯——先分清楚。', read: { kind: 'principle', id: '原則九十六結果論陷阱Outcome Bias', label: '原則:結果論陷阱' }, practice: { view: 'journal', label: '打開交易復盤' } },
{ title: '② 進出場要有依據', body: '進場理由、停損/減倉規則寫清楚,復盤才有東西可改。', read: { kind: 'category', id: '交易與資金管理', label: '交易與資金管理' }, practice: { view: 'journal', label: '新增一筆交易' } },
{ title: '③ 定期回頭對照原則', body: '把常犯的錯連回具體原則,下次遇到類似情境才改得動。', read: { kind: 'principleMap', id: '心法地圖', label: '原則地圖(分群索引)' }, practice: { view: 'learn', section: 'quiz', label: '做練習題' } },
],
},
};
const PRINCIPLE_GROUPS = [
{ id: 'macro', label: '大局與倉位', re: /降息|升息|通膨|總經|倉位|現金|信用|利率|PMI|非農|CPI|風險偏好/ },
{ id: 'research', label: '研究與選股', re: /Capex|毛利率|財報|估值|定價權|護城河|供給|營收|EPS|財測|產業/ },
{ id: 'trade', label: '交易與紀律', re: /減倉|停損|結果論|賣|買|趨勢|紀律|倉位|觸發|弱|強|復盤/ },
{ id: 'mind', label: '心態與認知', re: /新聞|情緒|認知|耐心|時間|概率|陷阱|偏誤/ },
];
function deEmmy(text) {
return String(text || '')
.replace(/Emmy 投資心法/g, '投資原則庫')
.replace(/Emmy 投資台/g, '投資學習台')
.replace(/\bEmmy\b/g, '講者')
.replace(/110 條原則/g, '完整原則庫')
.replace(/心法地圖/g, '原則地圖')
.replace(/心法/g, '原則');
}
function cleanPrincipleTitle(title) {
return deEmmy(String(title || ''))
.replace(/^原則[^:]+[:]\s*/, '')
.trim();
}
function extractWikiLinks(body) {
const links = [];
const re = /\[\[([^\]]+)\]\]/g;
let m;
while ((m = re.exec(String(body || '')))) links.push(m[1].trim());
return links;
}
function resolveLinkTarget(raw, linkMap) {
if (!raw || !linkMap) return null;
const key = raw.trim();
return linkMap[key] || linkMap[key.split('#').pop()] || linkMap[key.split('/').pop()] || null;
}
function extractPrinciples(note, linkMap, principles) {
const out = [];
const seen = new Set();
const add = (p) => {
if (!p || seen.has(p.id)) return;
seen.add(p.id);
out.push(p);
};
for (const raw of extractWikiLinks(note.body)) {
const hit = resolveLinkTarget(raw, linkMap);
if (hit && hit.kind === 'principle') {
const p = (principles || []).find(x => x.id === hit.id);
add(p || { id: hit.id, title: raw.split('#').pop() || raw });
}
if (/投資心法#|投資原則#|原則/.test(raw)) {
const id = raw.includes('#') ? raw.split('#').slice(1).join('#') : raw;
const p = (principles || []).find(x => x.id === id || x.title === id);
add(p || { id, title: id });
}
}
return out.slice(0, 12);
}
function leadFromNote(note) {
if (note.summary) return deEmmy(note.summary);
const body = deEmmy(note.body || '');
for (const line of body.split('\n')) {
let l = line.trim();
if (!l || /^#/.test(l)) continue;
if (l.startsWith('>')) l = l.replace(/^>\s?/, '');
l = l.replace(/\[\[([^\]|]+)(\|[^\]]+)?\]\]/g, '$1').replace(/[*`]/g, '').trim();
if (l.length > 12) return l.slice(0, 160);
}
return '';
}
function buildTocFromMarkdown(md) {
const items = [];
for (const line of String(md || '').split('\n')) {
const m = line.match(/^(#{2,3})\s+(.+)$/);
if (!m) continue;
const level = m[1].length;
const text = deEmmy(m[2].replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, (_, a, _b, c) => c || a)).trim();
const id = 'sec-' + items.length;
items.push({ level, text, id });
}
return items.slice(0, 14);
}
function renderPathSteps(pathId, path) {
return path.steps.map((step, i) => {
const links = [
step.read ? `<button type="button" class="la-link" data-note-kind="${step.read.kind}" data-note-id="${step.read.id}">${step.read.label}</button>` : '',
step.read2 ? `<button type="button" class="la-link" data-note-kind="${step.read2.kind}" data-note-id="${step.read2.id}">${step.read2.label}</button>` : '',
].filter(Boolean).join('');
const practice = step.practice
? `<button type="button" class="la-practice" data-view="${step.practice.view || ''}" data-section="${step.practice.section || ''}">${step.practice.label}</button>`
: '';
return `<details class="path-step" ${i === 0 ? 'open' : ''}>
<summary><span class="path-n">${i + 1}</span><strong>${deEmmy(step.title)}</strong></summary>
<p>${deEmmy(step.body)}</p>
<div class="path-actions">${links}${practice}</div>
</details>`;
}).join('');
}
function renderHome(opts) {
const esc = opts.escapeHtml;
const cards = [
{ id: 'market', icon: '🌐', ...LEARN_PATHS.market },
{ id: 'stock', icon: '📊', ...LEARN_PATHS.stock },
{ id: 'trade', icon: '📝', ...LEARN_PATHS.trade },
];
return `
<div class="learning-board">
<div class="board-copy">
<div class="eyebrow">從問題開始</div>
<h2>選一個你現在真的想回答的問題</h2>
<p>不用從頭讀完展開下面步驟依序讀一篇 連到工具 做一次判斷同一套問題可以反覆用在不同股票與不同月份</p>
</div>
<div class="learning-cards">
${cards.map(c => `<button type="button" class="learning-card" data-path="${c.id}">
<span class="lc-step">${esc(c.icon)}</span>
<strong>${esc(c.title)}</strong>
<span>${esc(c.lead)}</span>
<em>展開學習步驟</em>
</button>`).join('')}
</div>
</div>
<div id="learnPathHost" class="learn-path-host" hidden>
<div class="learn-path-head">
<button type="button" class="back-link" id="learnPathBack"> 換一個問題</button>
<h2 id="learnPathTitle"></h2>
<p id="learnPathLead"></p>
</div>
<div id="learnPathSteps" class="learn-path-steps"></div>
</div>
<div class="practice-strip">
<div><b></b><span></span></div>
<div><b></b><span></span></div>
<div><b></b><span></span></div>
</div>
<div class="module-grid learn-shortcuts">
<div class="module-card" data-section-jump="principleMap"><div class="mod-name">原則地圖</div><div class="mod-meta">110+ </div></div>
<div class="module-card" data-section-jump="cases"><div class="mod-name">案例講解</div><div class="mod-meta">/events </div></div>
<div class="module-card" data-section-jump="quiz"><div class="mod-name">練習題庫</div><div class="mod-meta"></div></div>
</div>`;
}
function bindHome(container, handlers) {
const host = container.querySelector('#learnPathHost');
const stepsEl = container.querySelector('#learnPathSteps');
const titleEl = container.querySelector('#learnPathTitle');
const leadEl = container.querySelector('#learnPathLead');
const showPath = (id) => {
const path = LEARN_PATHS[id];
if (!path) return;
container.querySelector('.learning-board').hidden = true;
container.querySelector('.practice-strip').hidden = true;
container.querySelector('.learn-shortcuts').hidden = true;
host.hidden = false;
titleEl.textContent = path.title;
leadEl.textContent = path.lead;
stepsEl.innerHTML = renderPathSteps(id, path);
bindPathSteps(stepsEl, handlers);
host.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
container.querySelector('#learnPathBack')?.addEventListener('click', () => {
host.hidden = true;
container.querySelector('.learning-board').hidden = false;
container.querySelector('.practice-strip').hidden = false;
container.querySelector('.learn-shortcuts').hidden = false;
});
container.querySelectorAll('.learning-card[data-path]').forEach(btn => {
btn.addEventListener('click', () => showPath(btn.dataset.path));
});
container.querySelectorAll('[data-section-jump]').forEach(el => {
el.addEventListener('click', () => handlers.showSection(el.dataset.sectionJump));
});
}
function bindPathSteps(root, handlers) {
root.querySelectorAll('.la-link[data-note-kind]').forEach(btn => {
btn.addEventListener('click', () => handlers.openNote(btn.dataset.noteKind, btn.dataset.noteId));
});
root.querySelectorAll('.la-practice[data-view]').forEach(btn => {
btn.addEventListener('click', () => {
const v = btn.dataset.view;
if (v === 'learn' && btn.dataset.section) handlers.showSection(btn.dataset.section);
else if (v && v !== 'learn') handlers.goView(v);
});
});
}
function renderArticle(note, opts) {
const esc = opts.escapeHtml;
const kind = note.kind || '';
const kindLabel = KIND_LABEL[kind] || '筆記';
const title = deEmmy(note.title || note.id || '');
const lead = leadFromNote(note);
const toc = buildTocFromMarkdown(note.body);
const principles = (kind === 'case') ? extractPrinciples(note, opts.linkMap, opts.principles) : [];
const fm = note.frontmatter || {};
let tags = '';
if (fm.ticker) tags += `<span class="fm-tag">代號 ${esc([].concat(fm.ticker).join(' / '))}</span>`;
if (fm.sector) tags += `<span class="fm-tag">${esc(fm.sector)}</span>`;
if (fm.category) tags += `<span class="fm-tag">${esc(fm.category)}</span>`;
const principlePanel = principles.length ? `
<section class="la-principles">
<div class="la-principles-head">
<h2>可抽出的通用原則</h2>
<p>這些原則不只適用這一檔公司下次遇到類似情境可以直接拿來問自己</p>
</div>
<div class="la-principle-chips">
${principles.map(p => `<button type="button" class="principle-chip" data-note-kind="principle" data-note-id="${esc(p.id)}">${esc(cleanPrincipleTitle(p.title))}</button>`).join('')}
</div>
</section>` : '';
const tocHtml = toc.length > 2 ? `
<nav class="la-toc" aria-label="本篇目錄">
<div class="la-toc-title">本篇目錄</div>
<div class="la-toc-links">${toc.map(t => `<a href="#${esc(t.id)}" class="lv${t.level}">${esc(t.text)}</a>`).join('')}</div>
</nav>` : '';
let bodyHtml = opts.renderMarkdown(deEmmy(note.body || ''));
toc.forEach((t, i) => {
const re = new RegExp(`(<h${t.level}>)([^<]*${t.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').slice(0, 12)}[^<]*)`, 'i');
if (i === 0) bodyHtml = bodyHtml.replace(re, `$1 id="${t.id}" $2`);
});
const toolActions = [
{ view: 'macro', label: '總經儀表板', sub: '對照利率、通膨' },
{ view: 'stock', label: '個股工具', sub: '查財報、投資地圖' },
{ view: 'journal', label: '交易復盤', sub: '記錄判斷與檢討' },
];
return `
<div class="note-toolbar">
<span class="back-link" id="noteBack"> 返回</span>
${note.kind && note.id ? '<button type="button" class="btn ghost sm" id="noteGraphBtn">🔗 周邊圖譜</button>' : ''}
</div>
${tags ? `<div class="note-frontmatter">${tags}</div>` : ''}
<article class="learn-article">
<header class="la-head">
<div class="la-kind">${esc(kindLabel)}</div>
<h1 class="la-title">${esc(title)}</h1>
${lead ? `<p class="la-lead">${esc(lead)}</p>` : ''}
</header>
${principlePanel}
${tocHtml}
<div class="learn-body md">${bodyHtml}</div>
<footer class="la-footer">
<div class="la-footer-title">把這篇用出去</div>
<div class="la-tool-grid">
${toolActions.map(a => `<button type="button" class="la-tool-card" data-view="${a.view}">
<strong>${a.label}</strong><span>${a.sub}</span>
</button>`).join('')}
</div>
</footer>
</article>`;
}
function bindArticle(container, handlers) {
container.querySelector('#noteBack')?.addEventListener('click', handlers.onBack);
container.querySelector('#noteGraphBtn')?.addEventListener('click', handlers.onGraph);
container.querySelectorAll('.principle-chip[data-note-id], .la-link[data-note-id]').forEach(btn => {
btn.addEventListener('click', () => handlers.openNote(btn.dataset.noteKind, btn.dataset.noteId));
});
container.querySelectorAll('.la-tool-card[data-view]').forEach(btn => {
btn.addEventListener('click', () => handlers.goView(btn.dataset.view));
});
container.querySelectorAll('.la-toc a').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
const el = container.querySelector(a.getAttribute('href'));
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
}
function groupPrinciples(principles) {
const groups = PRINCIPLE_GROUPS.map(g => ({ ...g, items: [] }));
const other = { id: 'other', label: '其他', items: [] };
for (const p of principles || []) {
const title = cleanPrincipleTitle(p.title);
const hit = groups.find(g => g.re.test(title));
(hit || other).items.push({ ...p, cleanTitle: title });
}
const out = groups.filter(g => g.items.length);
if (other.items.length) out.push(other);
return out;
}
function renderPrincipleGroups(principles, esc) {
const groups = groupPrinciples(principles);
return groups.map(g => `
<details class="principle-group" open>
<summary>${esc(g.label)} <span class="pg-count">${g.items.length}</span></summary>
<div class="module-grid pg-grid">
${g.items.map(p => `<div class="module-card pg-card" data-id="${esc(p.id)}">
<div class="mod-name">${esc(p.cleanTitle)}</div>
</div>`).join('')}
</div>
</details>`).join('');
}
function renderCaseCards(items, esc, opts) {
opts = opts || {};
return (items || []).map(it => {
const principles = extractPrinciples(it, opts.linkMap, opts.principles);
const badge = principles.length ? `<span class="case-badge">${principles.length} 條可重用原則</span>` : '';
return `<div class="module-card case-card" data-id="${esc(it.id)}">
<div class="mod-name">${esc(deEmmy(it.title))}${badge}</div>
${it.summary ? `<div class="mod-meta">${esc(deEmmy(it.summary))}</div>` : ''}
</div>`;
}).join('');
}
global.LearnUI = {
deEmmy, cleanPrincipleTitle, renderHome, bindHome, renderArticle, bindArticle,
renderPrincipleGroups, renderCaseCards, extractPrinciples, LEARN_PATHS,
};
})(window);