window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], { enter: ["loadSentiment"], }); /* PRESERVATION-SOURCE-BEGIN app.js:1207-1516 */ async function loadSentimentHistory(force = false) { if (!state.dashboard || state.sentimentLoading) return; const key = `${elements.tradeDate.value}:${state.sentimentRange}`; if (!force && state.sentimentHistoryKey === key && state.sentimentHistory) { renderSentimentHistory(); return; } state.sentimentLoading = true; const notice = document.querySelector("#sentimentHistoryNotice"); notice.hidden = true; try { const query = new URLSearchParams({ trade_date: elements.tradeDate.value, limit: String(state.sentimentRange), }); state.sentimentHistory = await apiRequest(`/api/sentiment/history?${query}`); state.sentimentHistoryKey = key; renderSentimentHistory(); } catch (error) { notice.textContent = error.message || "情绪周期数据加载失败"; notice.hidden = false; showToast(notice.textContent); } finally { state.sentimentLoading = false; } } function renderSentimentHistory() { const payload = state.sentimentHistory; if (!payload) return; const rows = payload.rows || []; const latest = rows[rows.length - 1]; const body = document.querySelector("#sentimentHistoryBody"); const empty = document.querySelector("#sentimentHistoryEmpty"); empty.hidden = rows.length > 0; body.innerHTML = [...rows].reverse().map((row) => { return ` ${escapeHtml(displayCompactDate(row.trade_date))} ${number(row.score)} ${escapeHtml(row.phase)} ${escapeHtml(row.direction)} ${number(row.limit_up_count)} ${number(row.first_board_count)} ${number(row.second_board_count)} ${number(row.three_plus_count)} ${number(row.max_height)}板 ${number(row.broken_count)} ${number(row.limit_down_count)} ${number(row.previous_limit_count)} ${number(row.previous_positive_count)} ${formatNumber(row.previous_positive_rate, 1)}% `; }).join(""); if (!latest) { setText("sentimentHistoryDateRange", "暂无历史数据"); return; } setText( "sentimentHistoryDateRange", `${displayCompactDate(rows[0].trade_date)} 至 ${displayCompactDate(latest.trade_date)}`, ); setText("sentimentCycleScore", number(latest.score)); setText("sentimentCycleLabel", latest.label); setText("sentimentCycleDate", displayCompactDate(latest.trade_date)); setText("sentimentCyclePhase", latest.phase); setText("sentimentCycleDirection", latest.direction); const dayChange = number(latest.day_change); const confidence = sentimentPhaseConfidence(latest); setText("sentimentPhaseConfidence", `置信度 ${confidence}%`); setText("sentimentDayChange", `${dayChange > 0 ? "+" : ""}${formatNumber(dayChange, 1)}`); setText("sentimentSealRate", `${formatNumber(latest.seal_rate, 1)}%`); setText("sentimentLimitUp", number(latest.limit_up_count)); setText("sentimentBroken", number(latest.broken_count)); setText("sentimentPhaseAdvice", sentimentPhaseAdvice(latest.phase)); setText("sentimentCurrentTag", `当前 ${number(latest.score)} · ${latest.phase}`); setText("sentimentComponentSummary", `五维加权 → 温度 ${number(latest.score)}`); setText("sentimentPeriodNote", `近 ${state.sentimentRange} 个交易日,当前展示 ${rows.length} 日`); const changeElement = document.querySelector("#sentimentDayChange"); changeElement.className = changeClass(dayChange); setText("sentimentPreviousPositive", `${number(latest.previous_positive_count)} / ${number(latest.previous_limit_count)} 只`); setText("sentimentPreviousAverage", `红盘率 ${formatNumber(latest.previous_positive_rate, 1)}% · 平均 ${signed(latest.average_previous_change)}%`); setText("sentimentHistoryDays", `${number(payload.available_days)} 个交易日`); setText("sentimentNormalization", `${latest.normalization} · 当前展示 ${rows.length} 日`); const marker = document.querySelector("#sentimentCycleScoreMarker"); marker.className = `sentiment-current-phase-badge ${sentimentPhaseClass(latest.phase)}`; document.querySelector("#sentimentComponentList").innerHTML = Object.values(latest.components || {}).map((item) => `
${escapeHtml(item.label)} ${formatNumber(item.score, 1)} × ${number(item.weight)}%
${escapeHtml(item.summary)}
`).join(""); requestAnimationFrame(() => { animateSentimentComponents(); animateSentimentTrendChart(rows); bindSentimentChartTooltip(rows); }); animateRows(body); } function animateSentimentComponents() { document.querySelectorAll("#sentimentComponentList [data-component-score]").forEach((bar, index) => { const width = `${number(bar.dataset.componentScore)}%`; if (!motionEnabled()) { bar.style.width = width; return; } setTimeout(() => { bar.style.width = width; }, index * 70); }); } function animateSentimentTrendChart(rows) { if (sentimentChartAnimationFrame) cancelAnimationFrame(sentimentChartAnimationFrame); if (!motionEnabled()) { drawSentimentTrendChart(rows, 1); return; } const startedAt = performance.now(); const duration = 780; const frame = (now) => { const rawProgress = Math.min(1, (now - startedAt) / duration); const progress = 1 - (1 - rawProgress) ** 3; drawSentimentTrendChart(rows, progress); if (rawProgress < 1) sentimentChartAnimationFrame = requestAnimationFrame(frame); else sentimentChartAnimationFrame = null; }; sentimentChartAnimationFrame = requestAnimationFrame(frame); } function drawSentimentTrendChart(rows, progress = 1) { const canvas = document.querySelector("#sentimentTrendChart"); if (!canvas || !rows.length || state.activeView !== "sentimentCycleView") return; const rect = canvas.getBoundingClientRect(); if (!rect.width) return; const width = Math.max(320, rect.width); const height = Math.max(220, rect.height); const ratio = window.devicePixelRatio || 1; canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); const context = canvas.getContext("2d"); const palette = currentChartPalette(); context.setTransform(ratio, 0, 0, ratio, 0, 0); context.clearRect(0, 0, width, height); context.fillStyle = palette.background; context.fillRect(0, 0, width, height); const padding = { top: 18, right: 18, bottom: 34, left: 42 }; const chartWidth = width - padding.left - padding.right; const chartHeight = height - padding.top - padding.bottom; const x = (index) => padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth); const y = (score) => padding.top + (100 - clamp(score, 0, 100)) / 100 * chartHeight; context.font = '10px "Microsoft YaHei UI", sans-serif'; context.textAlign = "right"; context.textBaseline = "middle"; for (let score = 0; score <= 100; score += 20) { const lineY = y(score); context.strokeStyle = score === 40 || score === 80 ? palette.zero : palette.grid; context.lineWidth = 1; context.beginPath(); context.moveTo(padding.left, lineY); context.lineTo(width - padding.right, lineY); context.stroke(); context.fillStyle = palette.axis; context.fillText(String(score), padding.left - 8, lineY); } context.save(); context.beginPath(); context.rect(padding.left - 6, padding.top - 8, (chartWidth + 12) * clamp(progress, 0, 1), chartHeight + 18); context.clip(); const finalPhase = rows[rows.length - 1]?.phase; let phaseStart = rows.length - 1; while (phaseStart > 0 && rows[phaseStart - 1]?.phase === finalPhase) phaseStart -= 1; if (["退潮", "冰点"].includes(finalPhase)) { const startX = phaseStart === 0 ? padding.left : (x(phaseStart - 1) + x(phaseStart)) / 2; context.fillStyle = palette.alertArea; context.fillRect(startX, padding.top, width - padding.right - startX, chartHeight); context.fillStyle = palette.up; context.font = '10px "Microsoft YaHei UI", sans-serif'; context.textAlign = "center"; context.textBaseline = "top"; context.fillText(finalPhase, (startX + width - padding.right) / 2, padding.top + 4); } const movingAverage = rows.map((_row, index) => { const start = Math.max(0, index - 4); const sample = rows.slice(start, index + 1); return sample.reduce((sum, item) => sum + number(item.score), 0) / sample.length; }); context.beginPath(); movingAverage.forEach((score, index) => { if (index === 0) context.moveTo(x(index), y(score)); else context.lineTo(x(index), y(score)); }); context.strokeStyle = palette.movingAverage; context.lineWidth = 1.5; context.setLineDash([5, 4]); context.stroke(); context.setLineDash([]); context.beginPath(); rows.forEach((row, index) => { const pointX = x(index); const pointY = y(row.score); if (index === 0) context.moveTo(pointX, pointY); else context.lineTo(pointX, pointY); }); context.lineTo(x(rows.length - 1), padding.top + chartHeight); context.lineTo(x(0), padding.top + chartHeight); context.closePath(); context.fillStyle = palette.area; context.fill(); context.beginPath(); rows.forEach((row, index) => { const pointX = x(index); const pointY = y(row.score); if (index === 0) context.moveTo(pointX, pointY); else context.lineTo(pointX, pointY); }); context.strokeStyle = palette.line; context.lineWidth = 2.5; context.lineJoin = "round"; context.lineCap = "round"; context.stroke(); rows.forEach((row, index) => { context.beginPath(); context.arc(x(index), y(row.score), index === rows.length - 1 ? 4.5 : 3, 0, Math.PI * 2); context.fillStyle = ["退潮", "冰点"].includes(row.phase) ? palette.up : row.phase === "修复" ? palette.repair : palette.line; context.fill(); context.strokeStyle = palette.background; context.lineWidth = 1.5; context.stroke(); }); context.restore(); const labelStep = Math.max(1, Math.ceil(rows.length / 6)); context.textAlign = "center"; context.textBaseline = "top"; context.fillStyle = palette.axis; rows.forEach((row, index) => { if (index % labelStep !== 0 && index !== rows.length - 1) return; const dateText = displayCompactDate(row.trade_date).slice(5); context.fillText(dateText, x(index), height - padding.bottom + 10); }); } function bindSentimentChartTooltip(rows) { const canvas = document.querySelector("#sentimentTrendChart"); const tooltip = document.querySelector("#sentimentChartTooltip"); if (!canvas || !tooltip || !rows.length) return; canvas.onmousemove = (event) => { const rect = canvas.getBoundingClientRect(); const padding = { left: 42, right: 18 }; const chartWidth = Math.max(1, rect.width - padding.left - padding.right); const relativeX = clamp(event.clientX - rect.left - padding.left, 0, chartWidth); const index = rows.length === 1 ? 0 : Math.round(relativeX / chartWidth * (rows.length - 1)); const row = rows[index]; tooltip.innerHTML = `${escapeHtml(displayCompactDate(row.trade_date))} · 温度 ${number(row.score)} · ${escapeHtml(row.phase)}`; tooltip.hidden = false; const targetLeft = padding.left + (rows.length === 1 ? chartWidth / 2 : index / (rows.length - 1) * chartWidth); tooltip.style.left = `${clamp(targetLeft + 10, 8, rect.width - tooltip.offsetWidth - 8)}px`; tooltip.style.top = `${clamp(event.clientY - rect.top - 34, 8, rect.height - 34)}px`; }; canvas.onmouseleave = () => { tooltip.hidden = true; }; } function sentimentScoreClass(score) { const value = number(score); return value >= 60 ? "score-strong" : value < 40 ? "score-weak" : "score-neutral"; } function sentimentPhaseClass(phase) { return { "冰点": "phase-ice", "修复": "phase-repair", "发酵": "phase-fermentation", "高潮": "phase-climax", "分化": "phase-divergence", "退潮": "phase-retreat", }[phase] || "phase-divergence"; } function sentimentPhaseConfidence(row) { const explicit = number(row?.confidence || row?.phase_confidence); if (explicit > 0) return Math.round(clamp(explicit, 0, 100)); const historyEvidence = Math.min(12, number(row?.history_days) * 0.6); const movementEvidence = Math.min(18, Math.abs(number(row?.day_change)) * 0.8); return Math.round(clamp(62 + historyEvidence + movementEvidence, 60, 92)); } function sentimentPhaseAdvice(phase) { return { "冰点": "情绪处于极弱区,先观察风险释放,允许没有候选结果。", "修复": "风险开始收敛,关注率先转强的核心,小仓验证修复强度。", "发酵": "主线与梯队正在形成,优先跟随核心,避免偏离主线。", "高潮": "情绪与一致性已处高位,聚焦核心并主动降低后排暴露。", "分化": "强弱开始分层,关注承接与回流,淘汰失去辨识度的方向。", "退潮": "情绪指标继续走弱。", }[phase] || "市场结构尚未形成清晰阶段,保持观察并等待确认。"; } /* PRESERVATION-SOURCE-END app.js:1207-1516 */