Keep 1024-1439 broken/seal metrics in the detail panel, fold desktop admin actions into the ellipsis menu, and fit the 13-column limit pool at 1600 without hiding the data date. Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
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 `
|
||
<tr>
|
||
<td class="sentiment-date-cell">${escapeHtml(displayCompactDate(row.trade_date))}</td>
|
||
<td class="number sentiment-score-cell ${sentimentScoreClass(row.score)}">${number(row.score)}</td>
|
||
<td><span class="sentiment-phase-badge ${sentimentPhaseClass(row.phase)}">${escapeHtml(row.phase)}</span></td>
|
||
<td><span class="sentiment-direction ${trendClass(row.direction)}">${escapeHtml(row.direction)}</span></td>
|
||
<td class="number">${number(row.limit_up_count)}</td>
|
||
<td class="number">${number(row.first_board_count)}</td>
|
||
<td class="number">${number(row.second_board_count)}</td>
|
||
<td class="number">${number(row.three_plus_count)}</td>
|
||
<td class="number">${number(row.max_height)}板</td>
|
||
<td class="number">${number(row.broken_count)}</td>
|
||
<td class="number">${number(row.limit_down_count)}</td>
|
||
<td class="number">${number(row.previous_limit_count)}</td>
|
||
<td class="number">${number(row.previous_positive_count)}</td>
|
||
<td class="number">${formatNumber(row.previous_positive_rate, 1)}%</td>
|
||
</tr>
|
||
`;
|
||
}).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) => `
|
||
<article class="sentiment-component-item">
|
||
<div class="sentiment-component-main">
|
||
<strong>${escapeHtml(item.label)}</strong>
|
||
<div class="sentiment-component-track" aria-hidden="true"><i data-component-score="${clamp(item.score, 0, 100)}" style="width:0%"></i></div>
|
||
<b>${formatNumber(item.score, 1)} <em>× ${number(item.weight)}%</em></b>
|
||
</div>
|
||
<small>${escapeHtml(item.summary)}</small>
|
||
</article>
|
||
`).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))} · 温度 <b>${number(row.score)}</b> · ${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);
|
||
});
|
||
});
|
||
}
|