// ═══════════════════════════════════════════════════════════ // Emmy 投資台 — 學習教材 / 財報健檢 / 交易復盤 // 本檔在 index.html 的內聯 script 之後載入,可使用其全域函式 // (lineChart、HEX、cssVar…),並負責主視圖切換與三個新分頁。 // ═══════════════════════════════════════════════════════════ const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => [...r.querySelectorAll(s)]; function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } async function api(path, opts) { const res = await fetch(path, opts); const data = await res.json().catch(() => ({})); if (!res.ok) throw Object.assign(new Error(data.message || res.statusText), { data }); return data; } function fmtNum(v, d = 0) { if (v == null || isNaN(v)) return '—'; return Number(v).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }); } function fmtPct(v, d = 1) { return v == null || isNaN(v) ? '—' : (v >= 0 ? '' : '') + Number(v).toFixed(d) + '%'; } function fmtMoney(v) { if (v == null || isNaN(v)) return '—'; const a = Math.abs(v), s = v < 0 ? '-' : ''; if (a >= 1e12) return s + '$' + (a / 1e12).toFixed(2) + 'T'; if (a >= 1e9) return s + '$' + (a / 1e9).toFixed(2) + 'B'; if (a >= 1e6) return s + '$' + (a / 1e6).toFixed(2) + 'M'; if (a >= 1e3) return s + '$' + (a / 1e3).toFixed(1) + 'K'; return s + '$' + a.toFixed(2); } // ═══════════════════════════════════════════════════════════ // UI 元件:色塊分段(取代傳統下拉) // ═══════════════════════════════════════════════════════════ function mountChips(container, items, value, onChange, opts = {}) { const cls = opts.sm ? 'chip sm' : 'chip'; container.innerHTML = items.map(it => { const tint = it.tint ? ` tint-${it.tint}` : ''; const on = it.id === value ? ' on' : ''; return ``; }).join(''); $$('button', container).forEach(btn => btn.addEventListener('click', () => { const v = btn.dataset.v; onChange(v); $$('button', container).forEach(b => b.classList.toggle('on', b.dataset.v === v)); })); } function mountTiles(container, items, value, onChange) { container.innerHTML = items.map(it => { const on = it.id === value ? ' on' : ''; const tint = it.tint ? ` tint-${it.tint}` : ''; return `
${escapeHtml(it.label)}
${it.sub ? `
${escapeHtml(it.sub)}
` : ''}
`; }).join(''); $$('.tile', container).forEach(el => { const pick = () => { onChange(el.dataset.v); $$('.tile', container).forEach(t => t.classList.toggle('on', t.dataset.v === el.dataset.v)); }; el.addEventListener('click', pick); el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } }); }); } // Mermaid 初始化(Apple 中性淺色主題) function initMermaid() { if (!window.mermaid || window._mermaidReady) return; window._mermaidReady = true; mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'loose', fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang TC", sans-serif', }); } async function renderMermaid(container) { initMermaid(); const els = $$('.mermaid', container); if (!els.length || !window.mermaid) return; try { await mermaid.run({ nodes: els, suppressErrors: true }); } catch (_) {} } // ═══════════════════════════════════════════════════════════ // 輕量 Markdown 渲染(支援標題/清單/表格/引用/粗體/行內碼/[[wikilink]]) // ═══════════════════════════════════════════════════════════ function mdInline(t) { t = escapeHtml(t); t = t.replace(/`([^`]+)`/g, (m, c) => '' + c + ''); t = t.replace(/\[\[([^\]]+)\]\]/g, (m, inner) => wlinkHTML(inner)); t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); t = t.replace(/\*\*([^*]+)\*\*/g, '$1'); t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); return t; } function wlinkHTML(inner) { let [target, display] = inner.split('|'); target = (target || '').trim(); display = (display || '').trim(); if (!display) display = target.includes('#') ? target.split('#').pop() : target.split('/').pop(); return '' + escapeHtml(display) + ''; } function splitRow(line) { return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim()); } function renderTable(header, rows) { let h = '' + header.map(c => '').join('') + ''; for (const r of rows) h += '' + header.map((_, j) => '').join('') + ''; return h + '
' + mdInline(c) + '
' + mdInline(r[j] || '') + '
'; } function renderListBlock(lines) { const root = { children: [] }; const stack = [{ indent: -1, node: root }]; for (const raw of lines) { const m = raw.match(/^(\s*)([-*]|\d+\.)\s+(.*)$/); if (!m) continue; const indent = m[1].replace(/\t/g, ' ').length; const ordered = /\d/.test(m[2]); const item = { ordered, html: mdInline(m[3]), children: [] }; while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop(); stack[stack.length - 1].node.children.push(item); stack.push({ indent, node: item }); } const emit = (node) => { if (!node.children.length) return ''; const ordered = node.children[0].ordered; let h = '<' + (ordered ? 'ol' : 'ul') + '>'; for (const c of node.children) h += '
  • ' + c.html + emit(c) + '
  • '; return h + ''; }; return emit(root); } function renderMarkdown(md) { md = String(md || '').replace(/\r\n/g, '\n'); const fences = []; const fenceLangs = []; md = md.replace(/```[\s\S]*?```/g, (m) => { const lang = (m.match(/^```(\w+)/) || [])[1] || ''; fenceLangs.push(lang.toLowerCase()); fences.push(m); return '\u0000F' + (fences.length - 1) + '\u0000'; }); const lines = md.split('\n'); const blank = s => !s.trim(); let html = '', i = 0; while (i < lines.length) { const line = lines[i]; if (blank(line)) { i++; continue; } const fm = line.match(/^\u0000F(\d+)\u0000$/); if (fm) { const idx = +fm[1]; const raw = fences[idx]; const lang = fenceLangs[idx]; const code = raw.replace(/^```[^\n]*\n?/, '').replace(/```\s*$/, ''); if (lang === 'mermaid') html += `
    ${escapeHtml(code)}
    `; else html += '
    ' + escapeHtml(code) + '
    '; i++; continue; } const h = line.match(/^(#{1,6})\s+(.*)$/); if (h) { const l = h[1].length; html += `${mdInline(h[2])}`; i++; continue; } if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { html += '
    '; i++; continue; } if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) { const header = splitRow(line); i += 2; const rows = []; while (i < lines.length && lines[i].includes('|') && !blank(lines[i])) { rows.push(splitRow(lines[i])); i++; } html += renderTable(header, rows); continue; } if (/^\s*>/.test(line)) { const buf = []; while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; } html += '
    ' + renderMarkdown(buf.join('\n')) + '
    '; continue; } if (/^\s*([-*]|\d+\.)\s+/.test(line)) { const buf = []; while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) { buf.push(lines[i]); i++; } html += renderListBlock(buf); continue; } const buf = []; while (i < lines.length && !blank(lines[i]) && !/^(#{1,6})\s/.test(lines[i]) && !/^\s*([-*]|\d+\.)\s+/.test(lines[i]) && !/^\s*>/.test(lines[i]) && !/^\u0000F\d+\u0000$/.test(lines[i]) && !/^(-{3,}|\*{3,}|_{3,})$/.test(lines[i].trim()) && !(lines[i].includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]))) { buf.push(lines[i]); i++; } if (buf.length) html += '

    ' + mdInline(buf.join(' ')) + '

    '; } return html; } // 把容器內所有 [[wikilink]] 綁定成站內跳轉;無法解析的標成 dead function bindWlinks(container) { $$('.wlink[data-link]', container).forEach(elx => { const t = elx.dataset.link; const hit = (KB.linkMap && (KB.linkMap[t] || KB.linkMap[t.split('#').pop()] || KB.linkMap[t.split('/').pop()])) || null; if (!hit) { elx.classList.add('dead'); return; } elx.addEventListener('click', () => openNote(hit.kind, hit.id)); }); } // ═══════════════════════════════════════════════════════════ // 主視圖路由 // ═══════════════════════════════════════════════════════════ const VIEW_IDS = ['macro', 'learn', 'stock', 'journal']; const inited = {}; function parseHash() { const m = location.hash.match(/^#\/(\w+)/); const v = m ? m[1] : 'macro'; return VIEW_IDS.includes(v) ? v : 'macro'; } function setView(view) { document.body.dataset.view = view; VIEW_IDS.forEach(v => { const e = $('#view-' + v); if (e) e.hidden = v !== view; }); $$('#viewTabs a').forEach(a => a.classList.toggle('active', a.dataset.view === view)); if (view === 'learn' && !inited.learn) { inited.learn = true; initLearn(); } if (view === 'stock' && !inited.stock) { inited.stock = true; initStock(); } if (view === 'journal' && !inited.journal) { inited.journal = true; initJournal(); } if (view !== 'macro') window.scrollTo({ top: 0 }); } $$('#viewTabs a').forEach(a => a.addEventListener('click', () => { location.hash = a.dataset.view === 'macro' ? '#/' : '#/' + a.dataset.view; })); window.addEventListener('hashchange', () => setView(parseHash())); // ═══════════════════════════════════════════════════════════ // 知識庫資料 // ═══════════════════════════════════════════════════════════ let KB = { loaded: false, linkMap: {} }; async function ensureKnowledge() { if (KB.loaded) return KB; KB = await api('/api/knowledge'); KB.loaded = true; KB.linkMap = KB.linkMap || {}; return KB; } // 從任何視圖點連結要看的、但 initLearn 尚未建好 DOM 時,先暫存於此,由 initLearn 收尾渲染 let pendingNote = null; // 把一篇筆記打開在「學習教材」視圖;macro/個股→切到 learn async function openNote(kind, id) { await ensureKnowledge(); let note = findLocalNote(kind, id); if (!note) { try { note = await api(`/api/note/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`); } catch (e) { note = null; } } const finalNote = note || { body: `# 找不到這篇筆記\n(${kind} / ${id})` }; finalNote.kind = kind; if (!inited.learn) { // 學習教材還沒初始化:暫存,切到 learn 後由 initLearn 渲染(避免被課綱總覽蓋掉) pendingNote = finalNote; location.hash = '#/learn'; return; } if (document.body.dataset.view !== 'learn') location.hash = '#/learn'; renderNote(finalNote); } function findLocalNote(kind, id) { if (kind === 'overview') return KB.overview; if (kind === 'principleMap') return KB.principleMap; if (kind === 'quiz') return KB.quiz; if (kind === 'category') return (KB.categories || []).find(c => c.id === id); if (kind === 'case') return (KB.cases || []).find(c => c.id === id); if (kind === 'principle') return (KB.principles || []).find(p => p.id === id); return null; } function renderNote(note) { const content = $('#learnContent'); const fm = note.frontmatter || {}; LEARN.currentNote = note; let tags = ''; if (fm.ticker) tags += `代號 ${escapeHtml([].concat(fm.ticker).join(' / '))}`; if (fm.sector) tags += `${escapeHtml(fm.sector)}`; if (fm.category) tags += `${escapeHtml(fm.category)}`; if (fm.date) tags += `${escapeHtml(fm.date)}`; if (Array.isArray(fm.aliases) && fm.aliases.length) tags += `別名 ${escapeHtml(fm.aliases.join(' · '))}`; const kind = note.kind || LEARN.noteKind; const center = (kind && note.id) ? `${kind}:${note.id}` : ''; content.innerHTML = `
    ← 返回 ${center ? '' : ''}
    ` + (tags ? `
    ${tags}
    ` : '') + `
    ${renderMarkdown(note.body || '')}
    `; bindWlinks(content); renderMermaid(content); $('#noteBack').addEventListener('click', () => showSection(LEARN.lastSection || 'overview')); const gb = $('#noteGraphBtn'); if (gb) gb.addEventListener('click', () => showGraph({ center, depth: 2 })); window.scrollTo({ top: 0 }); } // ═══════════════════════════════════════════════════════════ // 學習教材視圖 // ═══════════════════════════════════════════════════════════ const LEARN = { lastSection: 'overview', graphFilter: 'curriculum', currentNote: null, noteKind: null }; const GRAPH_KINDS = [ { id: 'curriculum', label: '課程骨架', kinds: 'overview,principleMap,category,case,principle' }, { id: 'terms', label: '名詞', kinds: 'term', includeIndex: '1' }, { id: 'companies', label: '公司', kinds: 'company', includeIndex: '1' }, ]; function setLearnActive(section) { $$('#learnSide a').forEach(a => a.classList.toggle('active', a.dataset.section === section)); } async function initLearn() { const view = $('#view-learn'); view.innerHTML = `
    正在載入知識庫…
    `; try { await ensureKnowledge(); } catch (e) { view.innerHTML = `
    知識庫尚未建立。請先在 web/ 目錄執行 npm run build:knowledge 產生 data/knowledge.json,再重新整理。
    `; return; } const c = KB.counts || {}; view.innerHTML = `
    📚 學習教材
    把 Emmy 的知識整理成從零到能跟著判斷的學習路徑:三階段課綱、心法、案例、名詞與公司速查、練習題庫。點任何 紫色連結 都能跳到對應筆記。
    `; $$('#learnSide a').forEach(a => a.addEventListener('click', () => showSection(a.dataset.section))); if (pendingNote) { const n = pendingNote; pendingNote = null; renderNote(n); } else showSection('overview'); } function showSection(section) { LEARN.lastSection = section; setLearnActive(section); const content = $('#learnContent'); if (!content) return; if (section === 'overview') return renderNote(Object.assign({ kind: 'overview' }, KB.overview || { body: '# 課綱總覽\n(尚無內容)' })); if (section === 'principleMap') return renderNote(Object.assign({ kind: 'principleMap' }, KB.principleMap || { body: '# 心法地圖\n(尚無內容)' })); if (section === 'quiz') return renderQuiz(); if (section === 'graph') return showGraph(); if (section === 'categories') return renderCardList('學習分類', KB.categories, 'category'); if (section === 'cases') return renderCardList('案例講解', KB.cases, 'case'); if (section === 'principles') return renderPrincipleList(); if (['terms', 'companies', 'episodes'].includes(section)) return renderGlossary(section); } function renderCardList(title, items, kind) { const content = $('#learnContent'); const cards = (items || []).map(it => `
    ${escapeHtml(it.title)}
    ${it.summary ? `
    ${escapeHtml(it.summary)}
    ` : ''}
    `).join(''); content.innerHTML = `
    ${escapeHtml(title)}
    ${cards || '
    尚無內容。
    '}
    `; $$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id))); window.scrollTo({ top: 0 }); } function renderPrincipleList() { const content = $('#learnContent'); const cards = (KB.principles || []).map(p => `
    ${escapeHtml(p.title)}
    `).join(''); content.innerHTML = `
    Emmy 投資心法
    共 ${(KB.principles || []).length} 條原則。完整分群與決策流程請看「心法地圖」。
    ${cards}
    `; $$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote('principle', el.dataset.id))); window.scrollTo({ top: 0 }); } function renderGlossary(section) { const content = $('#learnContent'); const kind = { terms: 'term', companies: 'company', episodes: 'episode' }[section]; const all = (KB.index || []).filter(x => x.kind === kind); const title = { terms: '名詞速查', companies: '公司速查', episodes: '單集速查' }[section]; content.innerHTML = `
    ${title}
    `; const grid = $('#glossGrid'), countEl = $('#glossCount'); const draw = (q) => { q = (q || '').trim().toLowerCase(); const list = !q ? all : all.filter(x => x.title.toLowerCase().includes(q) || (x.aliases || []).some(a => a.toLowerCase().includes(q)) || (x.sub || '').toLowerCase().includes(q)); countEl.textContent = `${list.length} 筆${q ? `(搜尋「${q}」)` : ''}`; grid.innerHTML = list.slice(0, 400).map(x => `
    ${escapeHtml(x.title)}
    ${x.sub ? `
    ${escapeHtml(x.sub)}
    ` : ''}
    `).join('') || '
    找不到符合的項目。
    '; $$('.gloss-item', grid).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id))); if (list.length > 400) countEl.textContent += ',只顯示前 400 筆,請用搜尋縮小範圍。'; }; $('#glossSearch').addEventListener('input', e => draw(e.target.value)); draw(''); window.scrollTo({ top: 0 }); } function renderQuiz() { renderNote(Object.assign({ kind: 'quiz' }, KB.quiz || { body: '# 練習題庫\n(尚無內容)' })); } // ── 知識圖譜(vis-network)── let graphNetwork = null; const GRAPH_LEGEND = [ ['category', '分類', '#0071e3'], ['case', '案例', '#34c759'], ['principle', '心法', '#af52de'], ['term', '名詞', '#ff9500'], ['company', '公司', '#5ac8fa'], ['episode', '單集', '#8e8e93'], ]; async function showGraph(opts = {}) { LEARN.lastSection = 'graph'; setLearnActive('graph'); const content = $('#learnContent'); const filter = opts.filter || LEARN.graphFilter || 'curriculum'; const center = opts.center || ''; const depth = opts.depth || 2; LEARN.graphFilter = filter; content.innerHTML = `
    知識圖譜
    節點是筆記與概念,連線來自文內 [[連結]]。點一下節點可開啟該篇;拖曳平移、雙指或滾輪縮放。
    載入圖譜中…
    `; mountChips($('#graphFilterChips'), GRAPH_KINDS.map(g => ({ id: g.id, label: g.label })), filter, v => showGraph({ filter: v })); $('#graphLegend').innerHTML = GRAPH_LEGEND.map(([, lab, col]) => `${lab}`).join(''); const cfg = GRAPH_KINDS.find(g => g.id === filter) || GRAPH_KINDS[0]; const qs = new URLSearchParams({ kinds: cfg.kinds, limit: 500 }); if (cfg.includeIndex) qs.set('includeIndex', '1'); if (center) { qs.set('center', center); qs.set('depth', String(depth)); } try { const data = await api('/api/graph?' + qs); const el = $('#graphCanvas'); el.innerHTML = ''; if (!data.nodes || !data.nodes.length) { el.innerHTML = '
    此範圍沒有足夠的連結可繪製。
    '; return; } if (!window.vis) { el.innerHTML = '
    圖譜元件載入失敗,請重新整理。
    '; return; } const nodes = new vis.DataSet(data.nodes.map(n => ({ id: n.id, label: n.label, title: n.title, color: { background: n.color, border: n.color, highlight: { background: n.color, border: '#1d1d1f' } }, shape: n.shape === 'box' ? 'box' : 'dot', font: { face: '-apple-system, BlinkMacSystemFont, sans-serif', size: 13, color: '#1d1d1f' }, margin: 10, }))); const edges = new vis.DataSet(data.edges.map(e => ({ from: e.from, to: e.to, arrows: { to: { scaleFactor: 0.45 } }, color: { color: 'rgba(0,0,0,.12)', highlight: 'rgba(0,113,227,.45)' }, smooth: { type: 'continuous', roundness: 0.2 }, }))); if (graphNetwork) { graphNetwork.destroy(); graphNetwork = null; } graphNetwork = new vis.Network(el, { nodes, edges }, { physics: { stabilization: { iterations: 100 }, barnesHut: { gravitationalConstant: -12000, springLength: 120 } }, interaction: { hover: true, tooltipDelay: 80, navigationButtons: false }, nodes: { borderWidth: 0, shadow: { enabled: true, size: 6, x: 0, y: 2, color: 'rgba(0,0,0,.08)' } }, }); graphNetwork.on('click', p => { if (!p.nodes.length) return; const nid = p.nodes[0]; const node = data.nodes.find(n => n.id === nid); if (!node) return; const colon = nid.indexOf(':'); if (colon < 0) return; openNote(nid.slice(0, colon), nid.slice(colon + 1)); }); if (center && data.nodes.some(n => n.id === center)) { graphNetwork.focus(center, { scale: 1.2, animation: { duration: 500, easingFunction: 'easeInOutQuad' } }); } $('#graphStat').textContent = `${data.nodes.length} 個節點 · ${data.edges.length} 條連線${center ? '(聚焦模式)' : ''}`; } catch (e) { $('#graphCanvas').innerHTML = `
    圖譜載入失敗:${escapeHtml(e.message || '')}
    `; } window.scrollTo({ top: 0 }); } // ═══════════════════════════════════════════════════════════ // 共用 SVG 折線圖(價格走勢 / 回測權益曲線共用,支援多條線 + hover) // ═══════════════════════════════════════════════════════════ let _chartSeq = 0; function drawLineChart(el, series, opts = {}) { series = (series || []).filter(s => s.points && s.points.length >= 2); if (!series.length) { el.innerHTML = '
    資料不足,無法繪圖。
    '; return; } const uid = 'c' + (++_chartSeq); const w = 760, h = opts.height || 300, padL = 60, padR = 14, padT = 16, padB = 28; const plotW = w - padL - padR, plotH = h - padT - padB; const n = Math.min(...series.map(s => s.points.length)); const dates = series[0].points.map(p => p.date); const allVals = []; series.forEach(s => s.points.forEach(p => allVals.push(p.val))); let yMin = opts.yMin != null ? opts.yMin : Math.min(...allVals); let yMax = opts.yMax != null ? opts.yMax : Math.max(...allVals); 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 fmt = opts.fmt || (v => fmtNum(v, opts.decimals != null ? opts.decimals : 0)); const toX = i => padL + (i / (n - 1)) * plotW; const toY = v => padT + (1 - (v - yMin) / yRange) * plotH; let grid = ''; for (let k = 0; k <= 5; k++) { const v = yMin + yRange * k / 5; const y = toY(v); grid += `${fmt(v)}`; } let xlab = ''; const xt = Math.min(5, n); for (let k = 0; k < xt; k++) { const idx = Math.round(k * (n - 1) / (xt - 1)); xlab += `${(dates[idx] || '').slice(2, 7).replace('-', '/')}`; } let paths = '', dots = ''; series.forEach(s => { const d = s.points.slice(0, n).map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(i).toFixed(1)},${toY(p.val).toFixed(1)}`).join(' '); paths += ``; dots += `