let sentimentChartAnimationFrame = null;
window.XiaobaiPageModules.register("sentiment", ["sentimentCycleView"], {
bind: bindSentimentEvents,
enter: ["loadSentiment"],
});
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] || "市场结构尚未形成清晰阶段,保持观察并等待确认。";
}
function trendClass(trend) {
return { "升温": "trend-hot", "降温": "trend-cool", "新进": "trend-new", "持平": "trend-flat" }[trend] || "trend-flat";
}
function bindSentimentEvents() {
document.querySelector("#sentimentExportButton").addEventListener("click", exportSentimentHistory);
document.querySelectorAll("[data-sentiment-range]").forEach((button) => {
button.addEventListener("click", () => {
state.sentimentRange = number(button.dataset.sentimentRange) || 20;
document.querySelectorAll("[data-sentiment-range]").forEach((item) => {
item.classList.toggle("active", item === button);
});
loadSentimentHistory(true);
});
});
}