// ═══════════════════════════════════════════════════════════ // MacroScope — 學習教材 / 財報健檢 / 交易復盤 // 本檔在 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 = deEmmyText(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 = deEmmyText(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; } function deEmmyText(s) { return (window.LearnUI && LearnUI.deEmmy) ? LearnUI.deEmmy(s) : String(s || ''); } // 把容器內所有 [[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', 'calendar', 'learn', 'stock', 'journal', 'settings']; const inited = {}; function parseHash() { const m = location.hash.match(/^#\/(\w+)/); const v = m ? m[1] : 'macro'; return VIEW_IDS.includes(v) ? v : 'macro'; } function setAIFocus(focus) { const view = focus.view || document.body.dataset.view || parseHash(); window.__AI_FOCUS = { ...(window.__AI_FOCUS || {}), ...focus, view, updatedAt: new Date().toISOString() }; updateAIContextLabel(); return window.__AI_FOCUS; } window.setAIFocus = setAIFocus; function setView(view) { document.body.dataset.view = view; if ((window.__AI_FOCUS || {}).view !== view) setAIFocus({ view, type: '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 === 'calendar' && !inited.calendar) { inited.calendar = true; initCalendar(); } 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 === 'settings' && !inited.settings) { inited.settings = true; initSettings(); } updateAIContextLabel(); 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())); // ═══════════════════════════════════════════════════════════ // AI Provider 設定與頁面上下文問答 // ═══════════════════════════════════════════════════════════ const AI_PROVIDER_META = { 'opencode-go': { label: 'OpenCode Go', hint: 'OpenAI-compatible chat completions。官方 Go 端點使用 /zen/go/v1/chat/completions。', }, grok: { label: 'Grok', hint: 'xAI/Grok。後端會使用 xAI Responses API,且 store=false。', }, }; function readAISettings() { const fields = window.__ENV_SETTINGS?.fields || []; const get = (k) => fields.find(f => f.key === k) || {}; return { active: get('AI_ACTIVE_PROVIDER').value || 'grok', providers: { 'opencode-go': { model: get('OPENCODE_GO_MODEL').value || '', hasKey: !!get('OPENCODE_GO_API_KEY').hasValue, }, grok: { model: get('GROK_MODEL').value || '', hasKey: !!get('GROK_API_KEY').hasValue, }, }, }; } async function loadEnvSettings() { window.__ENV_SETTINGS = await api('/api/settings/env'); return window.__ENV_SETTINGS; } function envField(settings, key) { return (settings.fields || []).find(f => f.key === key) || { key, value: '', hasValue: false, masked: '' }; } async function saveEnvSettings(view) { const values = { AI_ACTIVE_PROVIDER: $('input[name="aiActiveProvider"]:checked')?.value || 'grok' }; $$('[data-env-key]', view).forEach(input => values[input.dataset.envKey] = input.value.trim()); window.__ENV_SETTINGS = await api('/api/settings/env', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ values }), }); updateAIContextLabel(); return window.__ENV_SETTINGS; } async function loadProviderModels(provider) { return api('/api/ai/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider }), }); } async function getProviderModels(provider) { window.__AI_MODEL_CACHE = window.__AI_MODEL_CACHE || {}; if (window.__AI_MODEL_CACHE[provider]) return window.__AI_MODEL_CACHE[provider]; const d = await loadProviderModels(provider); window.__AI_MODEL_CACHE[provider] = d.models || []; return window.__AI_MODEL_CACHE[provider]; } async function initSettings() { const view = $('#view-settings'); view.innerHTML = '
    載入設定中…
    '; const envSettings = await loadEnvSettings(); const settings = readAISettings(); view.innerHTML = `
    API Key 與 AI Provider 設定
    所有金鑰會寫入本機專案的 .env:${escapeHtml(envSettings.envPath || '.env')}。金鑰欄位留空代表保留原值;模型與預設 provider 會直接更新。
    市場資料目前總經與日曆使用 FRED API key。儲存後本次伺服器程序會立即使用新值;若你改了 PORT 或 TTL 類設定,仍建議重啟。
    狀態:${escapeHtml(envField(envSettings, 'FRED_API_KEY').masked || '未設定')}
    ${Object.entries(AI_PROVIDER_META).map(([id, meta]) => { const p = settings.providers?.[id] || {}; const keyName = id === 'opencode-go' ? 'OPENCODE_GO_API_KEY' : 'GROK_API_KEY'; const modelName = id === 'opencode-go' ? 'OPENCODE_GO_MODEL' : 'GROK_MODEL'; const keyField = envField(envSettings, keyName); const listId = `models-${id}`; return `
    ${escapeHtml(meta.label)}${escapeHtml(meta.hint)}
    狀態:${escapeHtml(keyField.masked || '未設定')}
    `; }).join('')}
    `; $('#saveAISettings').addEventListener('click', async () => { try { await saveEnvSettings(view); $('#aiSettingsMsg').textContent = '已寫入 .env。金鑰留空的欄位已保留原值。'; } catch (e) { $('#aiSettingsMsg').textContent = '儲存失敗:' + ((e.data && e.data.message) || e.message || ''); } }); $$('[data-ai-test]').forEach(btn => btn.addEventListener('click', async () => { try { $('#aiSettingsMsg').textContent = '先寫入 .env,接著測試連線…'; await saveEnvSettings(view); await askAI({ provider: btn.dataset.aiTest, question: '請用一句話確認你已收到連線測試。', context: { page: 'settings', purpose: 'provider connection test' }, target: '#aiSettingsMsg' }); } catch (e) { $('#aiSettingsMsg').textContent = '測試失敗:' + ((e.data && e.data.message) || e.message || ''); } })); $$('[data-ai-models]').forEach(btn => btn.addEventListener('click', async () => { const provider = btn.dataset.aiModels; const card = btn.closest('.ai-provider-card'); const input = card?.querySelector(`[data-ai-model-input="${provider}"]`); const list = card?.querySelector('datalist'); try { $('#aiSettingsMsg').textContent = '先寫入 .env,接著向 provider 抓取可用模型…'; await saveEnvSettings(view); const d = await loadProviderModels(provider); const models = d.models || []; if (list) list.innerHTML = models.map(m => ``).join(''); if (input && !input.value && models[0]) input.value = models[0].id; $('#aiSettingsMsg').textContent = models.length ? `已抓到 ${models.length} 個模型,請從 Model 欄位選擇後儲存。` : 'Provider 沒有回傳可用模型。'; } catch (e) { $('#aiSettingsMsg').textContent = '抓取模型失敗:' + ((e.data && e.data.message) || e.message || ''); } })); } function initAIWidget() { const dock = $('#aiDock'); if (!dock) return; dock.innerHTML = ` `; const refreshProviders = async () => { try { await loadEnvSettings(); } catch (_) {} const s = readAISettings(); $('#aiProviderSelect').innerHTML = Object.entries(AI_PROVIDER_META).map(([id, meta]) => { const p = s.providers?.[id] || {}; return ``; }).join(''); await refreshWidgetModels($('#aiProviderSelect').value); }; refreshProviders(); $('#aiFab').addEventListener('click', async () => { await refreshProviders(); $('#aiPanel').hidden = !$('#aiPanel').hidden; updateAIContextLabel(); }); $('#aiClose').addEventListener('click', () => { $('#aiPanel').hidden = true; }); $('#aiProviderSelect').addEventListener('change', async () => { await refreshWidgetModels($('#aiProviderSelect').value); updateAIContextLabel(); }); $('#aiOpenSettings').addEventListener('click', () => { location.hash = '#/settings'; $('#aiPanel').hidden = true; }); $('#aiAskBtn').addEventListener('click', () => askAIFromWidget()); $('#aiQuestion').addEventListener('input', () => autosizeAIInput()); $('#aiQuestion').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); askAIFromWidget(); } }); } function autosizeAIInput() { const input = $('#aiQuestion'); if (!input) return; input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 112) + 'px'; } function appendAIMessage(role, html, meta = '') { const log = $('#aiChatLog'); if (!log) return null; const msg = document.createElement('div'); msg.className = `ai-msg ${role === 'user' ? 'ai-msg-user' : 'ai-msg-bot'}`; msg.innerHTML = `
    ${html}
    ${meta ? `
    ${escapeHtml(meta)}
    ` : ''}`; log.appendChild(msg); log.scrollTop = log.scrollHeight; return msg; } async function refreshWidgetModels(provider) { const select = $('#aiModelSelect'); if (!select) return; const settings = readAISettings(); const current = settings.providers?.[provider]?.model || ''; select.innerHTML = ``; if (!settings.providers?.[provider]?.hasKey) { select.innerHTML = ''; return; } try { const models = await getProviderModels(provider); const opts = models.map(m => ``).join(''); select.innerHTML = `${opts}`; if (current) select.value = current; } catch (e) { select.innerHTML = ``; } } function updateAIContextLabel() { const el = $('#aiContextLabel'); if (!el) return; const labels = { macro: '總經', calendar: '日曆', learn: '學習', stock: '個股', journal: '復盤', settings: '設定' }; const view = document.body.dataset.view || parseHash(); const dataViews = new Set(['macro', 'calendar', 'learn', 'stock', 'journal']); const focus = window.__AI_FOCUS || {}; const focusLabel = focus.label || focus.title || focus.symbol || focus.date || focus.key || ''; el.textContent = dataViews.has(view) ? `會附上「${labels[view] || '頁面'}」${focusLabel ? `目前焦點:${focusLabel}` : '目前焦點'}` : '一般聊天,不附頁面資料'; } async function collectAIContext() { const view = document.body.dataset.view || parseHash(); const focus = { ...(window.__AI_FOCUS || {}), view }; const client = { urlHash: location.hash, visibleText: '', currentNote: null, personalNotes: [], symbol: '', subPage: '' }; if (view === 'learn') { client.currentNote = LEARN.currentNote ? { kind: LEARN.currentNote.kind, id: LEARN.currentNote.id, title: LEARN.currentNote.title, summary: LEARN.currentNote.summary, } : null; client.visibleText = $('#learnContent')?.innerText?.slice(0, 6000) || ''; client.personalNotes = readLearnNotes().slice(0, 8); } else if (view === 'stock') { client.symbol = STOCK.symbol || ''; client.subPage = STOCK.sub; client.mapAnswers = STOCK.mapAnswers; if (STOCK.sub === 'map') client.investMap = STOCK.mapCfg; client.visibleText = $('#view-stock')?.innerText?.slice(0, 5000) || ''; } else if (view === 'journal') { client.visibleText = $('#view-journal')?.innerText?.slice(0, 5000) || ''; } else if (view === 'calendar') { client.visibleText = $('#view-calendar')?.innerText?.slice(0, 8000) || ''; } else if (view === 'macro') { client.visibleText = $('#view-macro')?.innerText?.slice(0, 8000) || ''; } return api('/api/ai/context', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ view, focus, client, allowFetch: true }), }).catch(e => ({ mode: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view) ? 'page' : 'chat', hasPageData: ['macro', 'calendar', 'learn', 'stock', 'journal'].includes(view), view, focus, client, contextError: (e.data && e.data.message) || e.message, })); } async function askAIFromWidget() { const question = $('#aiQuestion').value.trim(); if (!question) return; const input = $('#aiQuestion'); const send = $('#aiAskBtn'); appendAIMessage('user', escapeHtml(question), '你'); input.value = ''; autosizeAIInput(); if (send) send.disabled = true; const typing = appendAIMessage('bot', '', '正在回覆'); try { const context = await collectAIContext(); const provider = $('#aiProviderSelect').value; const model = $('#aiModelSelect')?.value || ''; const d = await askAI({ provider, model, question, context }); if (typing) { typing.querySelector('.ai-bubble').innerHTML = renderMarkdown(d?.text || '(AI 沒有回傳文字)'); const meta = typing.querySelector('.ai-msg-meta'); if (meta) meta.textContent = `${AI_PROVIDER_META[provider]?.label || provider}${d?.model ? ' · ' + d.model : ''}`; } } catch (e) { if (typing) { typing.querySelector('.ai-bubble').innerHTML = `
    ${escapeHtml((e.data && e.data.message) || e.message || 'AI 呼叫失敗')}
    `; const meta = typing.querySelector('.ai-msg-meta'); if (meta) meta.textContent = '傳送失敗'; } } finally { if (send) send.disabled = false; $('#aiChatLog').scrollTop = $('#aiChatLog').scrollHeight; } } async function askAI({ provider, model, question, context, target }) { const out = target ? $(target) : null; const settings = readAISettings(); const p = settings.providers?.[provider] || {}; try { const d = await api('/api/ai/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, model: model || p.model || '', question, context }), }); if (out) out.innerHTML = renderMarkdown(d.text || '(AI 沒有回傳文字)'); return d; } catch (e) { if (out) out.innerHTML = `
    ${escapeHtml((e.data && e.data.message) || e.message || 'AI 呼叫失敗')}
    `; if (!out) throw e; return null; } } // ═══════════════════════════════════════════════════════════ // 知識庫資料 // ═══════════════════════════════════════════════════════════ 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'); LEARN.currentNote = note; const kind = note.kind || LEARN.noteKind; setAIFocus({ view: 'learn', type: 'learning-note', kind, id: note.id || '', title: note.title || note.id || '' }); const center = (kind && note.id) ? `${kind}:${note.id}` : ''; content.innerHTML = LearnUI.renderArticle(note, { escapeHtml, renderMarkdown, linkMap: KB.linkMap, principles: KB.principles, }); bindWlinks(content); LearnUI.bindArticle(content, { onBack: () => showSection(LEARN.lastSection || 'overview'), onGraph: () => showGraph({ center, depth: 2 }), openNote, goView(v) { location.hash = v === 'macro' ? '#/' : '#/' + v; }, }); renderMermaid(content); bindTermTips(content); window.scrollTo({ top: 0 }); } // ═══════════════════════════════════════════════════════════ // 重大事件日曆(網格 · 可增減追蹤 · 今天起兩個月) // ═══════════════════════════════════════════════════════════ const CAL = { events: [], selectedDate: '' }; const CAL_WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六']; function loadCalendarSymbols() { try { const raw = localStorage.getItem('calendarSymbols'); if (raw) { const arr = JSON.parse(raw); if (Array.isArray(arr)) return [...new Set(arr.map(s => String(s).trim().toUpperCase()).filter(Boolean))]; } } catch (_) {} const legacy = localStorage.getItem('eventSymbols'); if (legacy && legacy.trim()) { const arr = legacy.split(',').map(s => s.trim().toUpperCase()).filter(Boolean); saveCalendarSymbols(arr); localStorage.removeItem('eventSymbols'); return arr; } return []; } async function syncCalendarWatchlistFromServer() { try { const d = await api('/api/calendar/watchlist'); const remote = (d.symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean); if (remote.length) { saveCalendarSymbols(remote); renderWatchlistChips(); } else { const local = loadCalendarSymbols(); if (local.length) await pushCalendarWatchlistToServer(local); } } catch (_) {} } async function pushCalendarWatchlistToServer(symbols) { try { const d = await api('/api/calendar/watchlist', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ symbols: symbols || loadCalendarSymbols() }), }); if (Array.isArray(d.symbols)) saveCalendarSymbols(d.symbols); } catch (_) {} } function saveCalendarSymbols(symbols) { const clean = [...new Set((symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean))].slice(0, 30); localStorage.setItem('calendarSymbols', JSON.stringify(clean)); return clean; } function calendarRangeISO() { const today = new Date(); today.setHours(0, 0, 0, 0); const end = new Date(today); end.setMonth(end.getMonth() + 2); const iso = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; return { start: iso(today), end: iso(end), today: iso(today) }; } function showCalendarMsg(text, tone) { const el = $('#calendarMsg'); if (!el) return; el.textContent = text || ''; el.className = 'calendar-msg' + (tone ? ' ' + tone : ''); el.hidden = !text; } function renderWatchlistChips() { const box = $('#calendarWatchlist'); if (!box) return; const symbols = loadCalendarSymbols(); box.innerHTML = symbols.length ? symbols.map(sym => `${escapeHtml(sym)}`).join('') : '還沒有追蹤。在上方輸入代號,按 Enter 或「加入」。'; } function tryAddCalendarSymbol() { const input = $('#calendarSymAdd'); if (!input) return; const sym = input.value.trim().toUpperCase(); if (!sym) { showCalendarMsg('請先輸入股票代號', 'warn'); return; } if (!/^[A-Z0-9.\-]{1,12}$/.test(sym)) { showCalendarMsg('代號格式不正確(1–12 字,可用英數與 . -)', 'bad'); input.focus(); return; } const cur = loadCalendarSymbols(); if (cur.includes(sym)) { showCalendarMsg(`${sym} 已在追蹤清單`, 'warn'); input.select(); return; } saveCalendarSymbols([...cur, sym]); input.value = ''; renderWatchlistChips(); pushCalendarWatchlistToServer(); showCalendarMsg(`已加入 ${sym},正在更新財報日…`, 'good'); refreshCalendarData(true); } function removeCalendarSymbol(sym) { sym = String(sym || '').trim().toUpperCase(); if (!sym) return; saveCalendarSymbols(loadCalendarSymbols().filter(s => s !== sym)); renderWatchlistChips(); pushCalendarWatchlistToServer(); showCalendarMsg(`已移除 ${sym}`, 'good'); refreshCalendarData(true); } function bindCalendarViewEvents(view) { if (!view || view.dataset.calBound) return; view.dataset.calBound = '1'; view.addEventListener('click', e => { const rm = e.target.closest('.watch-chip-x'); if (rm) { e.preventDefault(); e.stopPropagation(); removeCalendarSymbol(rm.closest('.watch-chip')?.dataset.sym); return; } if (e.target.closest('#calendarSymGo')) { e.preventDefault(); tryAddCalendarSymbol(); return; } if (e.target.closest('#calendarRefresh')) return; if (e.target.closest('.cal-modal-backdrop') || e.target.closest('.cal-day-close')) { closeCalendarDay(); return; } const cell = e.target.closest('.cal-cell[data-date]'); if (cell) { openCalendarDay(cell.dataset.date); return; } }); view.addEventListener('keydown', e => { if (e.key === 'Escape') { closeCalendarDay(); return; } if (e.target.id === 'calendarSymAdd' && e.key === 'Enter') { e.preventDefault(); tryAddCalendarSymbol(); return; } const cell = e.target.closest('.cal-cell[data-date]'); if (cell && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openCalendarDay(cell.dataset.date); } }); } function initCalendar() { const view = $('#view-calendar'); view.innerHTML = `
    市場日曆
    市場日曆:兩個月內會動到股市的大事
    不只財報——還有美國通膨、就業、Fed 開會、選擇權結算、各國央行、美股休市。點日期看詳情,標題旁的 ? 有白話說明。
    追蹤財報自行新增或刪除,沒有預設清單
    怎麼看?

    格子裡是當天大事的簡稱;若看到 +3 代表還有更多,點日期可一次看完。每項事件標題旁有 ?,滑鼠移上去有白話解釋(不用懂 ADP、ECB 是什麼也能看)。

    `; $('#calendarWatchForm').addEventListener('submit', e => { e.preventDefault(); tryAddCalendarSymbol(); }); $('#calendarRefresh').addEventListener('click', () => refreshCalendarData(true)); bindCalendarViewEvents(view); renderWatchlistChips(); bindTermTips(view); syncCalendarWatchlistFromServer().finally(() => refreshCalendarData(false)); } function calendarEventLabel(ev) { if (ev.symbol) return ev.symbol; const t = ev.title || ''; const rules = [ [/FOMC.*點陣|SEP/i, 'FOMC+點陣'], [/FOMC|聯準會.*利率/i, 'FOMC決議'], [/CPI|消費者物價/i, 'CPI通膨'], [/非農|Employment Situation/i, '非農就業'], [/PCE|個人收入/i, 'PCE通膨'], [/GDP|國內生產/i, 'GDP'], [/PPI|生產者物價/i, 'PPI'], [/JOLTS|職缺/i, 'JOLTS職缺'], [/四巫/i, '四巫日'], [/月選擇權/i, '選擇權到期'], [/美股休市/i, '美股休市'], [/歐洲央行|ECB/i, '歐央行'], [/日本央行/i, '日央行'], [/英央行|MPC/i, '英央行'], [/Jackson Hole/i, '央行年會'], [/ADP/i, 'ADP就業'], [/初領失業/i, '失業救濟'], [/密西根/i, '消費信心'], [/零售銷售/i, '零售銷售'], [/工業生產/i, '工業生產'], [/新屋開工|成屋|營建許可/i, '房市數據'], [/耐久財/i, '耐久財'], [/消費信貸/i, '消費信貸'], [/費城 Fed|製造業指數/i, '製造業調查'], [/非製造業/i, '服務業調查'], [/就業成本|ECI/i, '就業成本'], [/生產力/i, '生產力'], [/進出口物價/i, '進出口價'], [/實質薪資/i, '實質薪資'], [/國際貿易/i, '貿易數據'], [/財報/i, '財報'], ]; for (const [re, label] of rules) if (re.test(t)) return label; return t.length > 8 ? t.slice(0, 7) + '…' : t; } function calendarEventChip(ev) { const cat = ev.category || 'macro'; const title = `${ev.title || ''}${ev.time ? ' · ' + ev.time : ''}${ev.note ? '\n' + ev.note : ''}`; return ``; } function calendarDayDetailHTML(date, events) { if (!date) return ''; const d = new Date(date + 'T00:00:00'); const label = isNaN(d) ? date : d.toLocaleDateString('zh-TW', { month: 'long', day: 'numeric', weekday: 'long' }); if (!events.length) { return `
    ${escapeHtml(label)}
    這天沒有事件。
    `; } const rows = events.map(ev => { const tipKey = eventTipKey(ev.title, ev.note); const tip = tipKey ? termTipBtn(tipKey, ev.title) : ''; const cat = { fed: '聯準會', macro: '總經', earnings: '財報', derivatives: '衍生品', market: '市場', central_bank: '央行' }[ev.category] || '事件'; const impact = { high: '高', medium: '中', low: '低' }[ev.impact] || '低'; return `
    ${impact}${escapeHtml(ev.title)}${tip}${ev.symbol ? `${escapeHtml(ev.symbol)}` : ''}
    ${escapeHtml(ev.note || '—')}${ev.time ? ' · ' + escapeHtml(ev.time) : ''}
    ${escapeHtml(cat)}${escapeHtml(ev.source || '')}
    `; }).join(''); return `
    ${escapeHtml(label)}${events.length} 項事件
    ${rows}
    `; } function closeCalendarDay() { CAL.selectedDate = ''; $$('.cal-cell.selected').forEach(el => el.classList.remove('selected')); const modal = $('#calendarModal'); if (modal) modal.hidden = true; document.body.classList.remove('cal-modal-open'); } function openCalendarDay(date) { CAL.selectedDate = date || ''; $$('.cal-cell.selected').forEach(el => el.classList.remove('selected')); const cell = $(`.cal-cell[data-date="${date}"]`); if (cell) cell.classList.add('selected'); const events = CAL.events.filter(ev => ev.date === date); setAIFocus({ type: 'calendar-day', date, label: `${date} · ${events.length} 項事件`, eventCount: events.length }); const modal = $('#calendarModal'); const panel = $('#calendarModalPanel'); if (!modal || !panel) return; panel.innerHTML = calendarDayDetailHTML(date, events); bindTermTips(panel); modal.hidden = false; document.body.classList.add('cal-modal-open'); $('.cal-day-close', panel)?.focus(); } function buildCalendarGrid(events, range) { const byDate = new Map(); for (const ev of events) { if (ev.date < range.start || ev.date > range.end) continue; if (!byDate.has(ev.date)) byDate.set(ev.date, []); byDate.get(ev.date).push(ev); } for (const [, list] of byDate) { list.sort((a, b) => { const rank = { high: 0, medium: 1, low: 2 }; const ra = rank[a.impact] ?? 2, rb = rank[b.impact] ?? 2; if (ra !== rb) return ra - rb; return String(a.title).localeCompare(String(b.title)); }); } const start = new Date(range.start + 'T00:00:00'); const end = new Date(range.end + 'T00:00:00'); const months = []; let cursor = new Date(start.getFullYear(), start.getMonth(), 1); while (cursor <= end) { months.push(new Date(cursor)); cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1); } const monthHTML = months.map((m, idx) => { const y = m.getFullYear(), mo = m.getMonth(); const firstDow = new Date(y, mo, 1).getDay(); const daysInMonth = new Date(y, mo + 1, 0).getDate(); let cells = ''; for (let i = 0; i < firstDow; i++) cells += '
    '; for (let day = 1; day <= daysInMonth; day++) { const iso = `${y}-${String(mo + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; if (iso < range.start || iso > range.end) { cells += `
    ${day}
    `; continue; } const dayEvents = byDate.get(iso) || []; const cls = [ 'cal-cell', iso === range.today ? 'today' : '', dayEvents.length ? 'has-events' : '', dayEvents.some(e => e.impact === 'high') ? 'has-hot' : '', ].filter(Boolean).join(' '); const evHtml = dayEvents.slice(0, 6).map(calendarEventChip).join('') + (dayEvents.length > 6 ? `+${dayEvents.length - 6} 更多` : ''); cells += `
    ${day}${dayEvents.length ? `${dayEvents.length}` : ''}
    ${evHtml || ''}
    `; } const title = m.toLocaleDateString('zh-TW', { year: 'numeric', month: 'long' }); return `

    ${escapeHtml(title)}

    ${idx === 0 ? '從今天起' : ''}
    ${CAL_WEEKDAYS.map(w => `${w}`).join('')}
    ${cells}
    `; }).join(''); return `
    ${monthHTML}
    `; } function formatCalendarCachedAt(iso) { if (!iso) return ''; const d = new Date(iso); if (isNaN(d)) return ''; return d.toLocaleString('zh-TW', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } async function refreshCalendarData(force) { const body = $('#calendarBody'); if (!body) return; const range = calendarRangeISO(); const symbols = loadCalendarSymbols(); const hadEvents = CAL.events.length > 0; if (!hadEvents || force) { body.innerHTML = `
    區間內事件(自動)
    ${escapeHtml(range.start)}起算日(今天)
    ${escapeHtml(range.end)}結束日(約兩個月)
    ${symbols.length}你追蹤的財報
    高衝擊 聯準會 四巫 / 選擇權 全球央行 財報(自訂) 點日期 → 彈窗看完整列表與 ? 說明
    正在載入日曆…
    `; } else { const note = $('#calendarSourceNote'); if (note) note.textContent = '正在背景更新日曆…'; } try { closeCalendarDay(); const qs = new URLSearchParams({ symbols: symbols.join(','), start: range.start, end: range.end }); if (force) qs.set('fresh', '1'); const d = await api('/api/calendar?' + qs.toString()); CAL.events = (d.events || []).filter(ev => ev.date >= range.start && ev.date <= range.end); const autoCount = CAL.events.filter(ev => ev.category !== 'earnings').length; const countEl = $('#calendarEventCount'); if (countEl) countEl.textContent = String(autoCount); const sourceNote = (d.sources || []).map(s => `${s.ok ? '已更新' : '待補'} ${s.name}`).join(' · '); const loading = $('.cal-loading', body); if (loading) loading.remove(); $('#calendarGridHost').innerHTML = buildCalendarGrid(CAL.events, range); const staleHint = d.stale ? '(更新失敗,顯示資料庫舊資料)' : (d.cached ? '(資料庫快取,每日更新)' : '(剛更新)'); const timeHint = formatCalendarCachedAt(d.cachedAt) ? ` · 更新 ${formatCalendarCachedAt(d.cachedAt)}` : ''; $('#calendarSourceNote').textContent = `共 ${CAL.events.length} 項${staleHint}${timeHint}。來源:${sourceNote}。`; if (CAL.selectedDate) openCalendarDay(CAL.selectedDate); showCalendarMsg('', ''); } catch (e) { body.innerHTML = `
    無法更新日曆:${escapeHtml((e.data && e.data.message) || e.message || '')}
    `; } } // ═══════════════════════════════════════════════════════════ // 學習教材視圖 // ═══════════════════════════════════════════════════════════ const LEARN = { lastSection: 'overview', graphFilter: 'curriculum', graphView: 'map', 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 = `
    學習路徑
    照問題學,不要硬背名詞
    從「現在大環境如何」「這家公司值不值得研究」「這筆交易哪裡做錯」三條路開始。每步都有連結與可點的工具,案例會標出可重複使用的原則。
    ${(KB.cases || []).length}案例講解
    ${(KB.principles || []).length}投資原則
    ${c.terms || 0}名詞速查
    `; $$('#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 renderLearnHome(); if (section === 'lab') return renderLearnLab(); 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 renderLearnHome() { const content = $('#learnContent'); content.innerHTML = LearnUI.renderHome({ escapeHtml }); LearnUI.bindHome(content, { openNote, showSection, goView(v) { location.hash = v === 'macro' ? '#/' : '#/' + v; }, }); window.scrollTo({ top: 0 }); } function learnNoteKey(note) { if (!note || !note.kind || !note.id) return ''; return 'learn_note:' + note.kind + ':' + note.id; } function readLearnNotes() { try { const out = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key || !key.startsWith('learn_note:')) continue; const raw = localStorage.getItem(key); if (!raw) continue; out.push(JSON.parse(raw)); } return out.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))); } catch (_) { return []; } } function saveLearnNote(note, text) { const key = learnNoteKey(note); if (!key) return; const payload = { key, kind: note.kind, id: note.id, title: deEmmyText(note.title || note.id || ''), text: String(text || '').trim(), updatedAt: new Date().toISOString(), }; if (!payload.text) localStorage.removeItem(key); else localStorage.setItem(key, JSON.stringify(payload)); } function renderLearnLab() { const content = $('#learnContent'); const pairs = [ ['財報基本功', '用營收、毛利率、EPS 判斷公司賺錢品質', 'category', '財報基本功'], ['總經與利率', '用通膨、利率、就業判斷市場順逆風', 'category', '總經與利率'], ['交易與資金管理', '用倉位、停損、復盤控制犯錯成本', 'category', '交易與資金管理'], ['護城河與商業模式', '用定價權、平台、生態位判斷長期競爭力', 'category', '護城河與商業模式'], ]; const notes = readLearnNotes(); content.innerHTML = `
    Practice Lab

    把學到的東西連起來

    先做配對,再到個股工具套用,最後把自己的判斷寫成筆記。這裡不是要背答案,是訓練你看到資料時知道該問哪一組問題。

    0/${pairs.length}已連對

    概念配對

    左邊選概念,右邊選它真正要解決的判斷問題
    ${pairs.map((p, i) => ``).join('')}
    ${pairs.map((p, i) => ``).join('')}

    我的學習筆記

    文章頁的筆記會收在這裡,方便回來複習
    ${notes.length ? notes.map(n => ` `).join('') : '
    還沒有筆記。打開任一篇學習文章,在「我的筆記」裡寫下你的判斷。
    '}
    `; bindLearnLab(content); window.scrollTo({ top: 0 }); } function bindLearnLab(content) { const picked = { concept: null, answer: null }; const matched = new Set(); const total = $$('.match-card.concept', content).length || 1; const update = () => { const s = $('#labScore'); if (s) s.textContent = `${matched.size}/${total}`; }; const check = () => { if (!picked.concept || !picked.answer) return; const ok = picked.concept.dataset.pair === picked.answer.dataset.pair; if (ok) { matched.add(picked.concept.dataset.pair); picked.concept.classList.add('matched'); picked.answer.classList.add('matched'); } else { picked.concept.classList.add('wrong'); picked.answer.classList.add('wrong'); setTimeout(() => { picked.concept?.classList.remove('wrong'); picked.answer?.classList.remove('wrong'); }, 520); } picked.concept?.classList.remove('picked'); picked.answer?.classList.remove('picked'); picked.concept = null; picked.answer = null; update(); }; $$('.match-card.concept', content).forEach(btn => btn.addEventListener('click', () => { if (btn.classList.contains('matched')) return; $$('.match-card.concept', content).forEach(x => x.classList.remove('picked')); picked.concept = btn; btn.classList.add('picked'); check(); })); $$('.match-card.answer', content).forEach(btn => btn.addEventListener('click', () => { if (btn.classList.contains('matched')) return; $$('.match-card.answer', content).forEach(x => x.classList.remove('picked')); picked.answer = btn; btn.classList.add('picked'); check(); })); $('#labReset')?.addEventListener('click', renderLearnLab); $('#labApply')?.addEventListener('click', () => { location.hash = '#/stock'; }); $$('.note-card', content).forEach(btn => btn.addEventListener('click', () => openNote(btn.dataset.kind, btn.dataset.id))); $$('.match-card.concept', content).forEach(btn => btn.addEventListener('dblclick', () => openNote(btn.dataset.kind, btn.dataset.id))); } function renderCardList(title, items, kind) { const content = $('#learnContent'); let cards; if (kind === 'case') { cards = LearnUI.renderCaseCards(items, escapeHtml, { linkMap: KB.linkMap, principles: KB.principles }); } else { cards = (items || []).map(it => `
    ${escapeHtml(deEmmyText(it.title))}
    ${it.summary ? `
    ${escapeHtml(deEmmyText(it.summary))}
    ` : ''}
    `).join(''); } const hint = kind === 'case' ? '

    每個案例都會標「可重用原則」——重點不是記住一家公司,而是記住判斷方法。

    ' : ''; content.innerHTML = `
    ${escapeHtml(title)}
    ${hint}
    ${cards || '
    尚無內容。
    '}
    `; $$('.module-card', content).forEach(el => el.addEventListener('click', () => openNote(kind, el.dataset.id))); window.scrollTo({ top: 0 }); } function renderPrincipleList() { const content = $('#learnContent'); content.innerHTML = `
    投資原則庫
    共 ${(KB.principles || []).length} 條。已依主題分群;點開可讀白話說明。完整索引見「原則地圖」。
    ${LearnUI.renderPrincipleGroups(KB.principles, escapeHtml)}
    `; $$('.pg-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; LEARN.graphView = opts.view || LEARN.graphView || 'map'; content.innerHTML = `
    知識地圖
    不再把所有連線擠成一團。先按類型分群,再點節點看它真正連到哪些案例、原則、名詞與公司。
    載入知識地圖中…
    `; mountChips($('#graphFilterChips'), GRAPH_KINDS.map(g => ({ id: g.id, label: g.label })), filter, v => showGraph({ filter: v })); mountChips($('#graphViewChips'), [ { id: 'map', label: '分群地圖' }, { id: 'list', label: '關係清單' }, ], LEARN.graphView, v => showGraph({ filter, center, depth, view: v }), { sm: true }); $('#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 (graphNetwork) { graphNetwork.destroy(); graphNetwork = null; } renderKnowledgeMap(el, data, { center, view: LEARN.graphView }); $('#graphStat').textContent = `${data.nodes.length} 個節點 · ${data.edges.length} 條連線${center ? '(聚焦模式)' : ''}`; } catch (e) { $('#graphCanvas').innerHTML = `
    圖譜載入失敗:${escapeHtml(e.message || '')}
    `; } window.scrollTo({ top: 0 }); } function graphKind(id) { return String(id || '').split(':')[0] || 'note'; } function graphKindLabel(kind) { const extra = { overview: '課綱', principleMap: '原則地圖', quiz: '練習' }; return extra[kind] || (GRAPH_LEGEND.find(g => g[0] === kind) || [kind, kind])[1]; } function renderKnowledgeMap(el, data, opts = {}) { const nodes = data.nodes || []; const edges = data.edges || []; const byId = new Map(nodes.map(n => [n.id, n])); const degree = new Map(); edges.forEach(e => { degree.set(e.from, (degree.get(e.from) || 0) + 1); degree.set(e.to, (degree.get(e.to) || 0) + 1); }); const groups = {}; nodes.forEach(n => { const k = n.kind || graphKind(n.id); if (!groups[k]) groups[k] = []; groups[k].push({ ...n, degree: degree.get(n.id) || 0 }); }); Object.values(groups).forEach(arr => arr.sort((a, b) => b.degree - a.degree || String(a.label).localeCompare(String(b.label)))); if (opts.view === 'list') { el.innerHTML = `
    ${edges.slice(0, 160).map(e => { const a = byId.get(e.from), b = byId.get(e.to); if (!a || !b) return ''; return ``; }).join('')}
    `; } else { const order = ['overview', 'category', 'case', 'principle', 'term', 'company', 'episode', 'principleMap']; const keys = Object.keys(groups).sort((a, b) => (order.indexOf(a) < 0 ? 99 : order.indexOf(a)) - (order.indexOf(b) < 0 ? 99 : order.indexOf(b))); el.innerHTML = `
    ${keys.map(k => `
    ${escapeHtml(graphKindLabel(k))}${groups[k].length}
    ${groups[k].slice(0, 42).map(n => ``).join('')}
    `).join('')}
    `; } const focus = (nid) => { const n = byId.get(nid); if (!n) return; const near = edges.filter(e => e.from === nid || e.to === nid).slice(0, 24).map(e => { const otherId = e.from === nid ? e.to : e.from; return byId.get(otherId); }).filter(Boolean); const box = $('#kgFocus'); if (box) box.innerHTML = `
    ${escapeHtml(n.label || n.title || n.id)}${escapeHtml(graphKindLabel(n.kind || graphKind(n.id)))}
    ${near.length ? near.map(x => ``).join('') : '
    這個節點暫時沒有鄰近連結。
    '}
    `; $$('.kg-node', el).forEach(x => x.classList.toggle('active', x.dataset.id === nid)); }; el.addEventListener('click', e => { const openBtn = e.target.closest('[data-open-node]'); if (openBtn) { const colon = openBtn.dataset.openNode.indexOf(':'); if (colon >= 0) openNote(openBtn.dataset.openNode.slice(0, colon), openBtn.dataset.openNode.slice(colon + 1)); return; } const nodeBtn = e.target.closest('[data-id]'); if (!nodeBtn) return; focus(nodeBtn.dataset.id); }); if (opts.center && byId.has(opts.center)) focus(opts.center); } // ═══════════════════════════════════════════════════════════ // 共用 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 += `