// 學習教材 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 stripMd(text) { return deEmmy(String(text || '')) .replace(/```[\s\S]*?```/g, ' ') .replace(/\[\[([^\]|]+)(\|([^\]]+))?\]\]/g, (_, a, _b, c) => c || a) .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/[#>*_`|]/g, '') .replace(/\s+/g, ' ') .trim(); } function splitSections(md) { const sections = []; let cur = null; for (const line of String(md || '').split('\n')) { const h = line.match(/^(#{2,3})\s+(.+)$/); if (h) { if (cur && cur.lines.length) sections.push(cur); cur = { level: h[1].length, title: stripMd(h[2]), lines: [] }; } else if (cur) { cur.lines.push(line); } } if (cur && cur.lines.length) sections.push(cur); return sections .map(s => ({ ...s, text: stripMd(s.lines.join('\n')) })) .filter(s => s.title && s.text.length > 20) .slice(0, 8); } function extractInsights(note) { const body = deEmmy(note.body || ''); const lines = body.split('\n'); const out = []; const seen = new Set(); const push = (raw) => { let text = stripMd(raw) .replace(/^[-\d.)、\s]+/, '') .replace(/^一句話精華[::]\s*/, ''); if (text.length < 14 || seen.has(text)) return; seen.add(text); out.push(text.slice(0, 118)); }; for (const line of lines) { const t = line.trim(); if (/^>\s*\S/.test(t) || /^[-*]\s+\S/.test(t) || /一句話精華/.test(t) || /^\*\*.+\*\*/.test(t)) push(t); if (out.length >= 5) break; } if (out.length < 3) { for (const s of splitSections(body)) { push(s.text); if (out.length >= 5) break; } } return out; } function buildRecallCards(note, sections, principles) { const title = cleanPrincipleTitle(note.title || note.id || '這篇筆記'); const cards = [ { q: `先不看內容,你會怎麼用一句話說明「${title}」?`, a: leadFromNote(note) || '抓出核心判斷,再用自己的話重說一次。', }, ]; sections.slice(0, 3).forEach(s => cards.push({ q: `「${s.title}」真正要判斷的是什麼?`, a: s.text.slice(0, 150), })); principles.slice(0, 2).forEach(p => cards.push({ q: `這個案例可以連到哪一條可重複使用的原則?`, a: cleanPrincipleTitle(p.title), })); return cards.slice(0, 5); } function addHeadingAnchors(bodyHtml, toc) { let idx = 0; return bodyHtml.replace(/([\s\S]*?)<\/h\1>/g, (m, level, inner) => { const t = toc[idx]; idx += 1; if (!t || String(t.level) !== String(level)) return m; return `${inner}`; }); } function renderPathSteps(pathId, path) { return path.steps.map((step, i) => { const links = [ step.read ? `` : '', step.read2 ? `` : '', ].filter(Boolean).join(''); const practice = step.practice ? `` : ''; return `
${i + 1}${deEmmy(step.title)}

${deEmmy(step.body)}

${links}${practice}
`; }).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 `
Learning UX

先回想,再展開,最後拿去判斷

這裡把長筆記拆成短任務:主動回想、章節重點、案例原則、工具應用。你不用把全部內容一次吞完,而是每次完成一個判斷。

回想重點連結應用
從問題開始

選一個你現在真的想回答的問題

不用從頭讀完。展開下面步驟,依序「讀一篇 → 連到工具 → 做一次判斷」。同一套問題可以反覆用在不同股票與不同月份。

${cards.map(c => ``).join('')}
先懂概念,不用背完。
紫色按鈕跳到相關原則、名詞、案例。
到總經、個股或復盤頁實際操作一次。
原則地圖
110+ 條原則的分群索引,可當查詢表。
案例講解
從單一公司/events 抽出可重複用的判斷。
練習題庫
用問題檢查自己是不是真的懂。
`; } 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 sections = splitSections(note.body); const insights = extractInsights(note); const principles = (kind === 'case') ? extractPrinciples(note, opts.linkMap, opts.principles) : []; const recallCards = buildRecallCards(note, sections, principles); const fm = note.frontmatter || {}; const noteKey = (kind && note.id) ? `learn_note:${kind}:${note.id}` : ''; let savedNote = ''; if (noteKey) { try { savedNote = JSON.parse(localStorage.getItem(noteKey) || '{}').text || ''; } catch (_) {} } let tags = ''; if (fm.ticker) tags += `代號 ${esc([].concat(fm.ticker).join(' / '))}`; if (fm.sector) tags += `${esc(fm.sector)}`; if (fm.category) tags += `${esc(fm.category)}`; const principlePanel = principles.length ? `

可抽出的通用原則

這些原則不只適用這一檔公司——下次遇到類似情境,可以直接拿來問自己。

${principles.map(p => ``).join('')}
` : ''; const tocHtml = toc.length > 2 ? ` ` : ''; let bodyHtml = opts.renderMarkdown(deEmmy(note.body || '')); bodyHtml = addHeadingAnchors(bodyHtml, toc); const insightPanel = insights.length ? `
01

先抓重點

${insights.map((x, i) => `
${String(i + 1).padStart(2, '0')}

${esc(x)}

`).join('')}
` : ''; const recallPanel = recallCards.length ? `
02

主動回想

${recallCards.map((c, i) => `
${esc(c.q)}
`).join('')}
` : ''; const sectionPanel = sections.length ? `
03

章節速讀

${sections.slice(0, 6).map(s => `
${esc(s.title)}

${esc(s.text.slice(0, 180))}

`).join('')}
` : ''; const reviewPanel = `
04

間隔複習

`; const notePanel = noteKey ? `

我的筆記

尚未儲存
` : ''; const toolActions = [ { view: 'macro', label: '總經儀表板', sub: '對照利率、通膨' }, { view: 'stock', label: '個股工具', sub: '查財報、投資地圖' }, { view: 'journal', label: '交易復盤', sub: '記錄判斷與檢討' }, ]; return `
← 返回 ${note.kind && note.id ? '' : ''}
${tags ? `
${tags}
` : ''}
${esc(kindLabel)}

${esc(title)}

${lead ? `

${esc(lead)}

` : ''}
${insightPanel} ${recallPanel} ${sectionPanel} ${reviewPanel} ${notePanel} ${principlePanel}
${toolActions.map(a => ``).join('')}
`; } 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('.learn-tab[data-tab]').forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.tab; container.querySelectorAll('.learn-tab').forEach(x => x.classList.toggle('on', x === btn)); container.querySelectorAll('.learn-tab-panel').forEach(p => { p.hidden = p.dataset.panel !== tab; }); }); }); container.querySelectorAll('.recall-toggle').forEach(btn => { btn.addEventListener('click', () => { const ans = btn.parentElement.querySelector('.recall-a'); const open = ans.hidden; ans.hidden = !open; btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.textContent = open ? '收起答案' : '看參考答案'; }); }); container.querySelectorAll('.review-step').forEach(btn => { btn.addEventListener('click', () => { btn.parentElement.querySelectorAll('.review-step').forEach(x => x.classList.toggle('on', x === btn)); }); }); container.querySelectorAll('.save-personal-note').forEach(btn => { btn.addEventListener('click', () => { const panel = btn.closest('.personal-note-panel'); const input = panel?.querySelector('.personal-note-input'); const key = input?.dataset.noteKey; if (!key) return; const title = container.querySelector('.la-title')?.textContent?.trim() || ''; const parts = key.split(':'); const payload = { key, title, kind: parts[1] || '', id: parts.slice(2).join(':'), text: input.value.trim(), updatedAt: new Date().toISOString(), }; if (payload.text) localStorage.setItem(key, JSON.stringify(payload)); else localStorage.removeItem(key); const status = panel.querySelector('.personal-note-status'); if (status) status.textContent = payload.text ? '已儲存' : '已清空'; }); }); 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 => `
${esc(g.label)} ${g.items.length}
${g.items.map(p => `
${esc(p.cleanTitle)}
`).join('')}
`).join(''); } function renderCaseCards(items, esc, opts) { opts = opts || {}; return (items || []).map(it => { const principles = extractPrinciples(it, opts.linkMap, opts.principles); const badge = principles.length ? `${principles.length} 條可重用原則` : ''; return `
${esc(deEmmy(it.title))}${badge}
${it.summary ? `
${esc(deEmmy(it.summary))}
` : ''}
`; }).join(''); } global.LearnUI = { deEmmy, cleanPrincipleTitle, renderHome, bindHome, renderArticle, bindArticle, renderPrincipleGroups, renderCaseCards, extractPrinciples, LEARN_PATHS, }; })(window);