1222 lines
47 KiB
JavaScript
1222 lines
47 KiB
JavaScript
(function (global) {
|
|
"use strict";
|
|
|
|
/* ---------------------------------------------------------------- helpers */
|
|
|
|
function number(value) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value == null ? "" : value).replace(/[&<>"']/g, function (ch) {
|
|
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[ch];
|
|
});
|
|
}
|
|
|
|
function formatNumber(value, digits) {
|
|
return new Intl.NumberFormat("zh-CN", {
|
|
minimumFractionDigits: digits,
|
|
maximumFractionDigits: digits,
|
|
}).format(number(value));
|
|
}
|
|
|
|
function changeClass(value) {
|
|
const n = number(value);
|
|
return n > 0 ? "up" : n < 0 ? "down" : "";
|
|
}
|
|
|
|
function streakLabel(streak) {
|
|
const value = Math.max(1, number(streak));
|
|
return value === 1 ? "首板" : value + "板";
|
|
}
|
|
|
|
function displayCompactDate(value) {
|
|
const text = String(value || "").replaceAll("-", "");
|
|
if (text.length !== 8) return value || "--";
|
|
return text.slice(0, 4) + "-" + text.slice(4, 6) + "-" + text.slice(6, 8);
|
|
}
|
|
|
|
function localDateString(value) {
|
|
const year = value.getFullYear();
|
|
const month = String(value.getMonth() + 1).padStart(2, "0");
|
|
const day = String(value.getDate()).padStart(2, "0");
|
|
return year + "-" + month + "-" + day;
|
|
}
|
|
|
|
function todayString() {
|
|
return localDateString(new Date());
|
|
}
|
|
|
|
function parseLocalDate(value) {
|
|
const parts = String(value || "").split("-").map(Number);
|
|
return new Date(parts[0], (parts[1] || 1) - 1, parts[2] || 1);
|
|
}
|
|
|
|
function addDays(value, delta) {
|
|
const date = parseLocalDate(value);
|
|
date.setDate(date.getDate() + delta);
|
|
return localDateString(date);
|
|
}
|
|
|
|
function previousWeekday(value) {
|
|
let date = parseLocalDate(value);
|
|
date.setDate(date.getDate() - 1);
|
|
while (date.getDay() === 0 || date.getDay() === 6) date.setDate(date.getDate() - 1);
|
|
return localDateString(date);
|
|
}
|
|
|
|
const ICONS = {
|
|
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
|
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
|
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
|
|
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
|
|
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
|
};
|
|
|
|
function icon(name, size) {
|
|
const body = ICONS[name] || "";
|
|
return '<svg width="' + (size || 16) + '" height="' + (size || 16) + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + "</svg>";
|
|
}
|
|
|
|
const TRIANGLE_UP = '<svg width="6" height="6" viewBox="0 0 8 8" aria-hidden="true"><path d="M4 1.6 6.9 6.4H1.1Z" fill="currentColor"/></svg>';
|
|
const TRIANGLE_DOWN = '<svg width="6" height="6" viewBox="0 0 8 8" aria-hidden="true"><path d="M4 6.4 1.1 1.6h5.8Z" fill="currentColor"/></svg>';
|
|
|
|
function sortIndicatorHtml(active) {
|
|
if (active) {
|
|
const up = state.sort.dir === "asc";
|
|
return '<span class="m-sort" aria-hidden="true">' + (up ? TRIANGLE_UP : TRIANGLE_DOWN) + "</span>";
|
|
}
|
|
return '<span class="m-sort m-sort--dual" aria-hidden="true">' + TRIANGLE_UP + TRIANGLE_DOWN + "</span>";
|
|
}
|
|
|
|
function starIcon(filled) {
|
|
const d = "M12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2";
|
|
if (filled) {
|
|
return '<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><path d="' + d + '" fill="currentColor" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
|
|
}
|
|
return '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><path d="' + d + '"/></svg>';
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- state */
|
|
|
|
const state = {
|
|
key: "",
|
|
requestedDate: "",
|
|
dashboard: null,
|
|
popularity: null,
|
|
popularitySource: "combined",
|
|
seq: 0,
|
|
calCursor: null,
|
|
sort: { key: "", dir: null },
|
|
detail: null,
|
|
};
|
|
|
|
let sheetToken = 0;
|
|
|
|
function pageConfig(key) {
|
|
const cfg = global.MobileNav && global.MobileNav.tableColumns ? global.MobileNav.tableColumns[key] : null;
|
|
return cfg || null;
|
|
}
|
|
|
|
function columnsFor(cfg) {
|
|
if (cfg.columns) return cfg.columns[state.popularitySource] || cfg.columns.combined;
|
|
return cfg;
|
|
}
|
|
|
|
function orderedColumns(cols) {
|
|
return (cols.frozenColumns || []).concat(cols.primaryColumns || [], cols.scrollColumns || []);
|
|
}
|
|
|
|
function findColumn(cols, key) {
|
|
const ordered = orderedColumns(cols);
|
|
for (let i = 0; i < ordered.length; i += 1) {
|
|
if (ordered[i].key === key) return ordered[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isNumericType(type) {
|
|
return ["rank", "change", "price", "rate", "money", "gap", "int", "streak", "advance", "height", "move"].indexOf(type) >= 0;
|
|
}
|
|
|
|
function sortableColumn(col) {
|
|
return col.type !== "stock";
|
|
}
|
|
|
|
function resetSortIfInvalid(cols) {
|
|
const active = state.sort.key;
|
|
if (active && !findColumn(cols, active)) {
|
|
state.sort = { key: "", dir: null };
|
|
}
|
|
}
|
|
|
|
function sortedRows(rows, cols) {
|
|
resetSortIfInvalid(cols);
|
|
const key = state.sort.key;
|
|
const dir = state.sort.dir;
|
|
if (!key || !dir) return rows;
|
|
const col = findColumn(cols, key);
|
|
if (!col || !sortableColumn(col)) return rows;
|
|
const numeric = isNumericType(col.type);
|
|
const factor = dir === "asc" ? 1 : -1;
|
|
return rows.slice().sort(function (a, b) {
|
|
if (numeric) {
|
|
const av = number(a[key]);
|
|
const bv = number(b[key]);
|
|
if (av === bv) return 0;
|
|
return (av - bv) * factor;
|
|
}
|
|
const av = String(a[key] == null ? "" : a[key]);
|
|
const bv = String(b[key] == null ? "" : b[key]);
|
|
return av.localeCompare(bv, "zh-CN") * factor;
|
|
});
|
|
}
|
|
|
|
function toggleSort(key) {
|
|
const cfg = pageConfig(state.key);
|
|
if (!cfg) return;
|
|
const cols = columnsFor(cfg);
|
|
const col = findColumn(cols, key);
|
|
if (!col || !sortableColumn(col)) return;
|
|
const firstDir = isNumericType(col.type) ? "desc" : "asc";
|
|
if (state.sort.key !== key) {
|
|
state.sort = { key: key, dir: firstDir };
|
|
} else if (state.sort.dir === firstDir) {
|
|
state.sort = { key: key, dir: firstDir === "desc" ? "asc" : "desc" };
|
|
} else {
|
|
state.sort = { key: "", dir: null };
|
|
}
|
|
renderTableBody();
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- cells */
|
|
|
|
function columnWidth(col) {
|
|
if (col.width) return col.width;
|
|
if (col.type === "rank") return 40;
|
|
if (col.type === "stock") return 96;
|
|
if (col.wide) return 140;
|
|
if (col.type === "text" || col.type === "concepts" || col.type === "outcome" || col.type === "dual") return 96;
|
|
return 72;
|
|
}
|
|
|
|
function colAlign(col) {
|
|
if (col.type === "rank") return "m-align-center";
|
|
if (["change", "price", "rate", "money", "gap", "int", "streak", "advance", "height", "move"].indexOf(col.type) >= 0) return "m-align-right";
|
|
return "m-align-left";
|
|
}
|
|
|
|
function stockCell(row) {
|
|
return '<span class="m-stock">' +
|
|
'<span class="m-stock-name">' + escapeHtml(row.name || "--") + "</span>" +
|
|
'<span class="m-stock-code">' + escapeHtml(row.code || "") + "</span>" +
|
|
"</span>";
|
|
}
|
|
|
|
function rankCell(row, index) {
|
|
const value = number(row.rank) > 0 ? number(row.rank) : index + 1;
|
|
const hot = index < 3 ? '<span class="m-rank-hot">热</span>' : "";
|
|
return '<span class="m-rank' + (index < 3 ? " is-top" : "") + '"><b>' + value + "</b>" + hot + "</span>";
|
|
}
|
|
|
|
function streakCell(value) {
|
|
const n = number(value);
|
|
if (n <= 0) return "";
|
|
const high = n >= 4 ? " is-high" : "";
|
|
return '<span class="m-pill' + high + '">' + streakLabel(n) + "</span>";
|
|
}
|
|
|
|
function numCell(value, digits, signed) {
|
|
if (value == null || value === "") return '<span class="m-num"></span>';
|
|
const n = number(value);
|
|
const sign = signed && n > 0 ? "+" : "";
|
|
return '<span class="m-num">' + sign + formatNumber(n, digits) + "</span>";
|
|
}
|
|
|
|
function changeCell(value) {
|
|
if (value == null || value === "") return '<span class="m-num"></span>';
|
|
const n = number(value);
|
|
return '<span class="m-num ' + changeClass(n) + '">' + (n > 0 ? "+" : "") + formatNumber(n, 2) + "</span>";
|
|
}
|
|
|
|
function intCell(value, hideZero) {
|
|
const n = Math.round(number(value));
|
|
if (hideZero && n === 0) return '<span class="m-num"></span>';
|
|
if (value == null || value === "") return '<span class="m-num"></span>';
|
|
return '<span class="m-num">' + n.toLocaleString("zh-CN") + "</span>";
|
|
}
|
|
|
|
function advanceCell(value) {
|
|
const n = number(value);
|
|
const cls = n === 0 ? "is-neutral" : n < 20 ? "is-warning" : "is-active";
|
|
return '<span class="m-num ' + cls + '">' + formatNumber(n, 1) + "</span>";
|
|
}
|
|
|
|
function outcomeCell(value) {
|
|
const map = { "晋级": "advance", "断板": "fail", "炸板": "broken", "跌停": "down" };
|
|
const cls = map[value] || "fail";
|
|
return '<span class="m-tag m-tag--' + cls + '">' + escapeHtml(value || "") + "</span>";
|
|
}
|
|
|
|
function heightCell(value) {
|
|
const n = number(value);
|
|
if (n <= 0) return "";
|
|
return '<span class="m-pill m-pill--plain">' + n + "</span>";
|
|
}
|
|
|
|
function moveCell(value) {
|
|
const n = value == null ? null : number(value);
|
|
if (n == null) return '<span class="m-num is-new">新</span>';
|
|
if (n > 0) return '<span class="m-num up">\u2191' + n + "</span>";
|
|
if (n < 0) return '<span class="m-num down">\u2193' + Math.abs(n) + "</span>";
|
|
return '<span class="m-num">持平</span>';
|
|
}
|
|
|
|
function conceptsCell(value) {
|
|
const list = Array.isArray(value) ? value : [];
|
|
const text = list.slice(0, 3).join("、");
|
|
return '<span class="m-cell-text" title="' + escapeHtml(list.join("、")) + '">' + escapeHtml(text) + "</span>";
|
|
}
|
|
|
|
function dualCell(value) {
|
|
const dual = Boolean(value);
|
|
return '<span class="m-tag ' + (dual ? "m-tag--dual" : "") + '">' + (dual ? "双榜共识" : "单榜入选") + "</span>";
|
|
}
|
|
|
|
function textCell(value) {
|
|
const text = String(value == null ? "" : value);
|
|
return '<span class="m-cell-text" title="' + escapeHtml(text) + '">' + escapeHtml(text) + "</span>";
|
|
}
|
|
|
|
function cellHtml(col, row, index) {
|
|
const value = row[col.key];
|
|
switch (col.type) {
|
|
case "stock": return stockCell(row);
|
|
case "rank": return rankCell(row, index);
|
|
case "streak": return streakCell(value);
|
|
case "change": return changeCell(value);
|
|
case "price": return numCell(value, 2);
|
|
case "rate": return numCell(value, 2);
|
|
case "money": return numCell(value, 2);
|
|
case "gap": return numCell(value, 2);
|
|
case "int": return intCell(value, col.hideZero);
|
|
case "advance": return advanceCell(value);
|
|
case "outcome": return outcomeCell(value);
|
|
case "height": return heightCell(value);
|
|
case "move": return moveCell(value);
|
|
case "concepts": return conceptsCell(value);
|
|
case "dual": return dualCell(value);
|
|
case "text":
|
|
default: return textCell(value);
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- table */
|
|
|
|
function buildTable(cols, rows) {
|
|
const frozen = cols.frozenColumns || [];
|
|
const primary = cols.primaryColumns || [];
|
|
const scroll = cols.scrollColumns || [];
|
|
const ordered = frozen.concat(primary, scroll);
|
|
|
|
let left = 0;
|
|
const frozenLeft = frozen.map(function (col) {
|
|
const offset = left;
|
|
left += columnWidth(col);
|
|
return offset;
|
|
});
|
|
|
|
function cellOpen(col, index, isHead, extraCls, extraAttrs) {
|
|
const isFrozen = index < frozen.length;
|
|
const width = columnWidth(col);
|
|
const clamp = col.type === "text" || col.type === "concepts" ? ";max-width:" + width + "px" : "";
|
|
const style = "min-width:" + width + "px" + clamp + (isFrozen ? ";width:" + width + "px;left:" + frozenLeft[index] + "px" : "");
|
|
const cls = (isHead ? "m-th" : "m-td") + " " + colAlign(col) + (isFrozen ? " m-frozen" : "") + (extraCls ? " " + extraCls : "");
|
|
return '<' + (isHead ? "th" : "td") + ' class="' + cls + '" style="' + style + '"' + (extraAttrs || "") + '>';
|
|
}
|
|
|
|
const head = ordered.map(function (col, i) {
|
|
const sortable = sortableColumn(col);
|
|
let extraCls = "";
|
|
let extraAttrs = "";
|
|
let indicator = "";
|
|
if (sortable) {
|
|
const active = state.sort.key === col.key && Boolean(state.sort.dir);
|
|
extraCls = " m-sortable" + (active ? " is-sorted" : "");
|
|
extraAttrs = ' data-sort-key="' + escapeHtml(col.key) + '" aria-sort="' + (active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none") + '"';
|
|
indicator = sortIndicatorHtml(active);
|
|
}
|
|
return cellOpen(col, i, true, extraCls, extraAttrs) + '<span class="m-th-inner">' + escapeHtml(col.label) + indicator + "</span></th>";
|
|
}).join("");
|
|
|
|
const body = rows.map(function (row, rIndex) {
|
|
const cells = ordered.map(function (col, i) {
|
|
return cellOpen(col, i, false) + cellHtml(col, row, rIndex) + "</td>";
|
|
}).join("");
|
|
const code = /^\d{6}$/.test(String(row.code || "")) ? ' data-code="' + escapeHtml(row.code) + '"' : "";
|
|
return "<tr" + code + ">" + cells + "</tr>";
|
|
}).join("");
|
|
|
|
return '<table class="m-table"><thead><tr>' + head + "</tr></thead><tbody>" + body + "</tbody></table>";
|
|
}
|
|
|
|
function skeletonHtml(rowCount) {
|
|
const rows = [];
|
|
for (let i = 0; i < rowCount; i += 1) {
|
|
rows.push('<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>');
|
|
}
|
|
return '<div class="m-skeleton" aria-hidden="true">' + rows.join("") + "</div>";
|
|
}
|
|
|
|
function emptyHtml() {
|
|
return '<div class="m-state m-motion-rise-in">' +
|
|
'<span class="m-state-icon">' + icon("inbox", 26) + "</span>" +
|
|
"<p>该交易日暂无相关数据</p>" +
|
|
'<small>可点右上角日期切换交易日</small>' +
|
|
"</div>";
|
|
}
|
|
|
|
function errorHtml(message) {
|
|
return '<div class="m-state m-state--error m-motion-fade-in">' +
|
|
'<p>' + escapeHtml(message || "数据加载失败") + "</p>" +
|
|
'<button class="m-btn-primary m-btn-retry" type="button" data-action="retry">重试</button>' +
|
|
"</div>";
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- strip */
|
|
|
|
function stripSkeleton() {
|
|
const cells = [];
|
|
for (let i = 0; i < 7; i += 1) {
|
|
cells.push('<div class="m-strip-cell"><span class="m-strip-skeleton-bar"></span></div>');
|
|
}
|
|
return '<div class="m-strip m-strip--skeleton" aria-hidden="true">' + cells.join("") + "</div>";
|
|
}
|
|
|
|
function amountLabel(amount) {
|
|
if (amount == null || amount === "") return { value: "--", unit: "", compact: false };
|
|
const n = number(amount);
|
|
if (n >= 10000) {
|
|
return { value: formatNumber(n / 10000, 2), unit: "万亿", compact: n >= 100000 };
|
|
}
|
|
return { value: Math.round(n).toLocaleString("zh-CN"), unit: "亿", compact: false };
|
|
}
|
|
|
|
function stripCell(label, valueHtml, tone, extraCls) {
|
|
return '<div class="m-strip-cell' + (tone ? " m-strip-" + tone : "") + (extraCls ? " " + extraCls : "") + '">' +
|
|
'<span class="m-strip-label">' + escapeHtml(label) + "</span>" +
|
|
'<span class="m-strip-value">' + valueHtml + "</span></div>";
|
|
}
|
|
|
|
function intOrDash(value) {
|
|
return value == null ? "--" : Math.round(number(value)).toLocaleString("zh-CN");
|
|
}
|
|
|
|
function buildStrip() {
|
|
const dash = state.dashboard || {};
|
|
if (!state.dashboard) return stripSkeleton();
|
|
const overview = dash.overview || {};
|
|
const limits = dash.limits || [];
|
|
const maxStreak = limits.reduce(function (m, r) { return Math.max(m, number(r.streak)); }, 0);
|
|
|
|
const score = overview.sentiment_score != null ? Math.round(number(overview.sentiment_score)) : null;
|
|
const phase = overview.sentiment_phase || "";
|
|
const emotionHtml = (score == null ? "--" : "<b>" + score + "</b>") +
|
|
(phase ? '<i class="m-strip-phase">' + escapeHtml(phase) + "</i>" : "");
|
|
|
|
const amount = amountLabel(overview.amount_billion);
|
|
const amountHtml = "<b>" + escapeHtml(amount.value) + "</b>" +
|
|
(amount.unit ? '<i class="m-strip-unit">' + escapeHtml(amount.unit) + "</i>" : "");
|
|
const seal = overview.seal_rate != null ? formatNumber(overview.seal_rate, 1) + "%" : "--";
|
|
|
|
const cells = [];
|
|
cells.push(stripCell("情绪", emotionHtml, "", ""));
|
|
cells.push(stripCell("涨停", "<b>" + intOrDash(overview.limit_up_count) + "</b>", "up", ""));
|
|
cells.push(stripCell("跌停", "<b>" + intOrDash(overview.limit_down_count) + "</b>", "down", ""));
|
|
cells.push(stripCell("炸板", "<b>" + intOrDash(overview.broken_count) + "</b>", "warn", ""));
|
|
cells.push(stripCell("封板率", "<b>" + seal + "</b>", "", ""));
|
|
cells.push(stripCell("成交额", amountHtml, "", amount.compact ? "m-strip-sm" : ""));
|
|
cells.push(stripCell("最高连板", "<b>" + (maxStreak > 0 ? maxStreak : "--") + "</b>", "up", ""));
|
|
|
|
return '<div class="m-strip">' + cells.join("") + "</div>";
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- rows */
|
|
|
|
function brokenLimitRate(row) {
|
|
const name = String(row.name || "").toUpperCase();
|
|
const code = String(row.code || "").replace(/\D/g, "");
|
|
if (name.indexOf("ST") >= 0) return 10;
|
|
if (/^(300|301|688|689)/.test(code)) return 20;
|
|
if (/^(4|8|92)/.test(code)) return 30;
|
|
return 10;
|
|
}
|
|
|
|
function prepareRows(key) {
|
|
const dash = state.dashboard || {};
|
|
if (key === "market/limit-up") return dash.limits || [];
|
|
if (key === "market/broken") {
|
|
return (dash.broken || []).map(function (row) {
|
|
row.limitGap = Math.max(0, brokenLimitRate(row) - number(row.change));
|
|
return row;
|
|
});
|
|
}
|
|
if (key === "market/limit-down") return dash.down_limits || [];
|
|
if (key === "market/yesterday") return dash.yesterday_limits || [];
|
|
if (key === "market/performance") return normalizePerformanceRows(dash.limit_performance || []);
|
|
if (key === "market/popularity") {
|
|
return state.popularity ? (state.popularity[state.popularitySource] || []) : [];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function normalizePerformanceRows(rows) {
|
|
const groups = {};
|
|
(rows || []).forEach(function (row) {
|
|
const level = Math.max(1, number(row.level));
|
|
const displayLevel = Math.min(level, 5);
|
|
const group = groups[displayLevel] || {
|
|
level: displayLevel,
|
|
label: displayLevel === 1 ? "昨日首板" : displayLevel === 5 ? "昨日5板+" : "昨日" + displayLevel + "板",
|
|
count: 0,
|
|
advanced: 0,
|
|
positive: 0,
|
|
changeTotal: 0,
|
|
};
|
|
const count = number(row.count);
|
|
group.count += count;
|
|
group.advanced += number(row.advanced);
|
|
group.positive += count * number(row.positive_rate) / 100;
|
|
group.changeTotal += count * number(row.average_change);
|
|
groups[displayLevel] = group;
|
|
});
|
|
return Object.keys(groups)
|
|
.map(Number)
|
|
.sort(function (a, b) { return b - a; })
|
|
.map(function (level) {
|
|
const group = groups[level];
|
|
return {
|
|
level: group.level,
|
|
label: group.label,
|
|
count: group.count,
|
|
advanced: group.advanced,
|
|
advance_rate: group.count ? group.advanced / group.count * 100 : 0,
|
|
positive_rate: group.count ? group.positive / group.count * 100 : 0,
|
|
average_change: group.count ? group.changeTotal / group.count : 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
function performanceConclusion() {
|
|
const overview = (state.dashboard && state.dashboard.overview) || {};
|
|
const up = number(overview.up_count);
|
|
const down = number(overview.down_count);
|
|
const breadthRate = up + down > 0 ? up / (up + down) * 100 : 50;
|
|
const stance = breadthRate < 25 ? "宜守不宜攻" : breadthRate < 45 ? "控制仓位,聚焦核心" : "保持精选,跟随强势梯队";
|
|
const phase = overview.sentiment_phase || "观察";
|
|
return '<div class="m-conclusion">结论:<b>' + escapeHtml(stance) + "</b>,当前情绪周期「" + escapeHtml(phase) + "」。</div>";
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- render */
|
|
|
|
function updateHeader(title, dateText) {
|
|
if (global.MobileRouter && global.MobileRouter.updateHeader) {
|
|
global.MobileRouter.updateHeader({ title: title, back: true, actions: dateButtonHtml(dateText) });
|
|
}
|
|
}
|
|
|
|
function dateButtonHtml(dateText) {
|
|
return '<button class="m-date-btn" type="button" data-action="date" aria-label="选择日期">' +
|
|
icon("calendar", 15) +
|
|
'<span class="m-date-btn-text">' + escapeHtml(dateText || state.requestedDate || todayString()) + "</span>" +
|
|
"</button>";
|
|
}
|
|
|
|
function sourceTabsHtml(cfg) {
|
|
if (!cfg.sources || !cfg.sources.length) return "";
|
|
const labels = { combined: "双榜综合", ths: "同花顺", dc: "东方财富" };
|
|
return '<div class="m-source-tabs" role="tablist" aria-label="热榜来源">' +
|
|
cfg.sources.map(function (source) {
|
|
const active = source === state.popularitySource;
|
|
return '<button class="m-source-tab' + (active ? " active" : "") + '" type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" data-action="source" data-source="' + source + '">' + escapeHtml(labels[source] || source) + "</button>";
|
|
}).join("") +
|
|
"</div>";
|
|
}
|
|
|
|
function renderPage(key) {
|
|
const cfg = pageConfig(key);
|
|
if (!cfg) return;
|
|
state.key = key;
|
|
state.requestedDate = todayString();
|
|
state.dashboard = null;
|
|
state.popularity = null;
|
|
state.popularitySource = "combined";
|
|
state.calCursor = null;
|
|
state.sort = { key: "", dir: null };
|
|
state.detail = null;
|
|
|
|
document.getElementById("m-view").classList.add("m-view-feature");
|
|
const title = findLabel(key) || key;
|
|
updateHeader(title, state.requestedDate);
|
|
|
|
const sourceTabs = sourceTabsHtml(cfg);
|
|
const content = '<div class="m-page" data-page="' + escapeHtml(key) + '">' +
|
|
'<div class="m-top">' + buildStrip() + "</div>" +
|
|
sourceTabs +
|
|
'<div class="m-table-scroll" id="m-table-scroll">' + skeletonHtml(8) + "</div>" +
|
|
"</div>";
|
|
document.getElementById("m-view").innerHTML = content;
|
|
|
|
load();
|
|
}
|
|
|
|
function findLabel(key) {
|
|
const hubs = global.MobileNav && global.MobileNav.hubs ? global.MobileNav.hubs : {};
|
|
for (const hubKey in hubs) {
|
|
const items = hubs[hubKey].items || [];
|
|
for (const item of items) {
|
|
if (item.key === key) return item.label;
|
|
}
|
|
}
|
|
return key;
|
|
}
|
|
|
|
function load() {
|
|
const key = state.key;
|
|
const cfg = pageConfig(key);
|
|
if (!cfg) return;
|
|
const seq = ++state.seq;
|
|
const requestedDate = state.requestedDate;
|
|
const popularity = key === "market/popularity";
|
|
|
|
const dashboardUrl = "/api/dashboard?trade_date=" + encodeURIComponent(requestedDate);
|
|
const popUrl = "/api/popularity?trade_date=" + encodeURIComponent(requestedDate);
|
|
|
|
const request = popularity
|
|
? Promise.all([global.MobileAPI.request(popUrl), global.MobileAPI.request(dashboardUrl)])
|
|
: global.MobileAPI.request(dashboardUrl).then(function (payload) { return [payload]; });
|
|
|
|
request.then(function (results) {
|
|
if (seq !== state.seq) return;
|
|
let meta = {};
|
|
if (popularity) {
|
|
state.popularity = results[0];
|
|
state.dashboard = results[1];
|
|
meta = (results[0] && results[0].meta) || {};
|
|
} else {
|
|
state.dashboard = results[0];
|
|
meta = (results[0] && results[0].meta) || {};
|
|
}
|
|
state.requestedDate = displayCompactDate(meta.requested_date || meta.trade_date || requestedDate);
|
|
const title = findLabel(key) || key;
|
|
updateHeader(title, state.requestedDate);
|
|
renderData(key, cfg);
|
|
}).catch(function (error) {
|
|
if (seq !== state.seq) return;
|
|
renderError(error && error.message ? error.message : "数据加载失败");
|
|
});
|
|
}
|
|
|
|
function renderData(key, cfg) {
|
|
renderTableBody();
|
|
renderTopArea(key);
|
|
}
|
|
|
|
function renderTableBody() {
|
|
const key = state.key;
|
|
const cfg = pageConfig(key);
|
|
if (!cfg) return;
|
|
const cols = columnsFor(cfg);
|
|
const rows = sortedRows(prepareRows(key), cols);
|
|
const scroll = document.getElementById("m-table-scroll");
|
|
if (!scroll) return;
|
|
|
|
if (!rows.length) {
|
|
scroll.innerHTML = emptyHtml();
|
|
} else {
|
|
scroll.innerHTML = buildTable(cols, rows);
|
|
}
|
|
|
|
scroll.classList.remove("m-motion-fade-in");
|
|
void scroll.offsetWidth;
|
|
scroll.classList.add("m-motion-fade-in");
|
|
}
|
|
|
|
function renderTopArea(key) {
|
|
const page = document.querySelector(".m-page");
|
|
if (!page) return;
|
|
let top = page.querySelector(".m-top");
|
|
let html = buildStrip();
|
|
if (key === "market/performance") html += performanceConclusion();
|
|
if (!top) {
|
|
top = document.createElement("div");
|
|
top.className = "m-top";
|
|
const tabs = page.querySelector(".m-source-tabs");
|
|
const scroll = document.getElementById("m-table-scroll");
|
|
page.insertBefore(top, tabs || scroll);
|
|
}
|
|
top.innerHTML = html;
|
|
const strip = top.querySelector(".m-strip");
|
|
if (strip) {
|
|
strip.classList.remove("m-motion-fade-in");
|
|
void strip.offsetWidth;
|
|
strip.classList.add("m-motion-fade-in");
|
|
}
|
|
}
|
|
|
|
function renderError(message) {
|
|
const scroll = document.getElementById("m-table-scroll");
|
|
if (scroll) scroll.innerHTML = errorHtml(message);
|
|
const page = document.querySelector(".m-page");
|
|
if (page) {
|
|
const top = page.querySelector(".m-top");
|
|
if (top) top.remove();
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- sheets */
|
|
|
|
function ensureSheetRoot() {
|
|
let root = document.getElementById("m-sheet-root");
|
|
if (!root) {
|
|
root = document.createElement("div");
|
|
root.id = "m-sheet-root";
|
|
root.className = "m-sheet-root";
|
|
document.getElementById("m-app").appendChild(root);
|
|
}
|
|
return root;
|
|
}
|
|
|
|
function openSheet(content, opts) {
|
|
const root = ensureSheetRoot();
|
|
sheetToken += 1;
|
|
root.innerHTML =
|
|
'<div class="m-sheet-backdrop" data-sheet-backdrop></div>' +
|
|
'<div class="m-sheet' + (opts && opts.detail ? " m-sheet--detail" : "") + '" role="dialog" aria-modal="true">' +
|
|
'<div class="m-sheet-handle" aria-hidden="true"></div>' +
|
|
content +
|
|
"</div>";
|
|
global.requestAnimationFrame(function () { root.classList.add("is-open"); });
|
|
bindSheetDrag(root);
|
|
}
|
|
|
|
function closeSheet() {
|
|
const root = document.getElementById("m-sheet-root");
|
|
if (!root) return;
|
|
const token = sheetToken;
|
|
root.classList.remove("is-open");
|
|
global.setTimeout(function () {
|
|
if (sheetToken === token && !root.classList.contains("is-open")) root.innerHTML = "";
|
|
}, 340);
|
|
}
|
|
|
|
function bindSheetDrag(root) {
|
|
const sheet = root.querySelector(".m-sheet");
|
|
const handle = root.querySelector(".m-sheet-handle");
|
|
if (!sheet || !handle) return;
|
|
let startY = 0;
|
|
let dragging = false;
|
|
handle.addEventListener("touchstart", function (event) {
|
|
dragging = true;
|
|
startY = event.touches[0].clientY;
|
|
sheet.style.transition = "none";
|
|
}, { passive: true });
|
|
handle.addEventListener("touchmove", function (event) {
|
|
if (!dragging) return;
|
|
const dy = event.touches[0].clientY - startY;
|
|
if (dy > 0) sheet.style.transform = "translateY(" + dy + "px)";
|
|
}, { passive: true });
|
|
handle.addEventListener("touchend", function (event) {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
sheet.style.transition = "";
|
|
const dy = event.changedTouches[0].clientY - startY;
|
|
if (dy > sheet.offsetHeight / 3) {
|
|
closeSheet();
|
|
} else {
|
|
sheet.style.transform = "";
|
|
}
|
|
});
|
|
}
|
|
|
|
function selectDate(dateStr) {
|
|
if (!dateStr) return;
|
|
state.requestedDate = dateStr;
|
|
closeSheet();
|
|
load();
|
|
}
|
|
|
|
function openDateSheet() {
|
|
state.calCursor = {
|
|
year: parseLocalDate(state.requestedDate).getFullYear(),
|
|
month: parseLocalDate(state.requestedDate).getMonth(),
|
|
};
|
|
openSheet(
|
|
'<div class="m-sheet-head">' +
|
|
'<h2>选择日期</h2>' +
|
|
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button>" +
|
|
"</div>" +
|
|
'<div class="m-sheet-body" id="m-date-sheet-body"></div>',
|
|
{ detail: false }
|
|
);
|
|
renderDateSheetBody();
|
|
}
|
|
|
|
function renderDateSheetBody() {
|
|
const body = document.getElementById("m-date-sheet-body");
|
|
if (!body || !state.calCursor) return;
|
|
const year = state.calCursor.year;
|
|
const month = state.calCursor.month;
|
|
const today = todayString();
|
|
const now = parseLocalDate(today);
|
|
const currentMonth = now.getFullYear() * 12 + now.getMonth();
|
|
const cursorMonth = year * 12 + month;
|
|
const prevDisabled = cursorMonth <= currentMonth - 12;
|
|
const nextDisabled = cursorMonth >= currentMonth;
|
|
|
|
const quick = [
|
|
{ label: "今天", date: todayString() },
|
|
{ label: "昨天", date: addDays(today, -1) },
|
|
{ label: "前一交易日", date: previousTradeDate() },
|
|
];
|
|
|
|
const quickHtml = quick.map(function (item) {
|
|
return '<button class="m-quick" type="button" data-quick-date="' + escapeHtml(item.date) + '">' + escapeHtml(item.label) + "</button>";
|
|
}).join("");
|
|
|
|
body.innerHTML =
|
|
'<div class="m-quick-row">' + quickHtml + "</div>" +
|
|
'<div class="m-cal-head">' +
|
|
'<button class="m-cal-nav" type="button" data-cal-nav="prev"' + (prevDisabled ? " disabled" : "") + " aria-label=\"上个月\">" + icon("chevron-left", 18) + "</button>" +
|
|
'<span class="m-cal-title">' + year + " 年 " + (month + 1) + " 月</span>" +
|
|
'<button class="m-cal-nav" type="button" data-cal-nav="next"' + (nextDisabled ? " disabled" : "") + " aria-label=\"下个月\">" + icon("chevron-right", 18) + "</button>" +
|
|
"</div>" +
|
|
'<div class="m-cal-week">' + ["一", "二", "三", "四", "五", "六", "日"].map(function (d) { return "<span>" + d + "</span>"; }).join("") + "</div>" +
|
|
'<div class="m-cal-grid">' + calendarCells(year, month) + "</div>";
|
|
}
|
|
|
|
function calendarCells(year, month) {
|
|
const first = new Date(year, month, 1);
|
|
const startWeekday = (first.getDay() + 6) % 7;
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
const today = todayString();
|
|
const cells = [];
|
|
for (let i = 0; i < startWeekday; i += 1) cells.push('<span class="m-cal-cell is-empty"></span>');
|
|
for (let d = 1; d <= daysInMonth; d += 1) {
|
|
const date = new Date(year, month, d);
|
|
const dateStr = localDateString(date);
|
|
const dow = date.getDay();
|
|
const disabled = dow === 0 || dow === 6 || dateStr > today;
|
|
const selected = dateStr === state.requestedDate;
|
|
cells.push(
|
|
'<button class="m-cal-cell' + (selected ? " is-selected" : "") + '" type="button" data-date="' + dateStr + '"' + (disabled ? " disabled" : "") + ">" + d + "</button>"
|
|
);
|
|
}
|
|
return cells.join("");
|
|
}
|
|
|
|
function previousTradeDate() {
|
|
const dash = state.dashboard || {};
|
|
if (dash.meta && dash.meta.previous_trade_date) return displayCompactDate(dash.meta.previous_trade_date);
|
|
if (state.popularity && state.popularity.meta && state.popularity.meta.previous_trade_date) {
|
|
return displayCompactDate(state.popularity.meta.previous_trade_date);
|
|
}
|
|
return previousWeekday(todayString());
|
|
}
|
|
|
|
function openDetailSheet(code) {
|
|
openSheet(
|
|
'<div class="m-sheet-head m-detail-head">' +
|
|
'<div class="m-detail-head-left">' +
|
|
'<span class="m-detail-head-name">--</span>' +
|
|
'<span class="m-detail-head-code">' + escapeHtml(code) + "</span>" +
|
|
"</div>" +
|
|
'<div class="m-detail-head-right">' +
|
|
'<button class="m-detail-star" type="button" data-action="watch" aria-label="加入自选" disabled>' + starIcon(false) + "</button>" +
|
|
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button>" +
|
|
"</div></div>" +
|
|
'<div class="m-sheet-body" id="m-detail-sheet-body">' +
|
|
'<div class="m-skeleton">' +
|
|
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
|
|
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
|
|
'<div class="m-skeleton-row"><span class="m-skeleton-bar"></span></div>' +
|
|
"</div>" +
|
|
"</div>",
|
|
{ detail: true }
|
|
);
|
|
loadDetail(code);
|
|
}
|
|
|
|
function loadDetail(code) {
|
|
const token = sheetToken;
|
|
global.MobileAPI.request("/api/stock/" + encodeURIComponent(code) + "/preview").then(function (payload) {
|
|
if (token !== sheetToken) return;
|
|
renderDetail(payload);
|
|
}).catch(function (error) {
|
|
if (token !== sheetToken) return;
|
|
const body = document.getElementById("m-detail-sheet-body");
|
|
if (body) body.innerHTML = errorHtml(error && error.message ? error.message : "行情预览加载失败");
|
|
});
|
|
}
|
|
|
|
function chartCaptionHtml(tab) {
|
|
const detail = state.detail;
|
|
const payload = detail && detail.payload ? detail.payload : {};
|
|
const meta = payload.meta || {};
|
|
if (tab === "daily") {
|
|
const bars = (payload.prices || []).slice(-48);
|
|
const last = bars.length ? bars[bars.length - 1].trade_date : "";
|
|
return "日线 · 近48根 · 至 " + (displayCompactDate(last) || "--");
|
|
}
|
|
const d = displayCompactDate(meta.intraday_trade_date) || displayCompactDate(meta.trade_date);
|
|
return "分时 · " + (d || "--");
|
|
}
|
|
|
|
function switchChartTab(tab) {
|
|
const detail = state.detail;
|
|
if (!detail || !detail.payload) return;
|
|
detail.tab = tab;
|
|
const payload = detail.payload;
|
|
const meta = payload.meta || {};
|
|
const chart = document.getElementById("m-detail-chart");
|
|
if (chart) {
|
|
if (tab === "daily") {
|
|
chart.innerHTML = dailyChart(payload);
|
|
} else if (meta.intraday_status === "available") {
|
|
chart.innerHTML = intradayChart(payload);
|
|
} else {
|
|
chart.innerHTML = emptyChart(meta.intraday_notice || "分时数据暂不可用");
|
|
}
|
|
}
|
|
const caption = document.getElementById("m-detail-caption");
|
|
if (caption) caption.textContent = chartCaptionHtml(tab);
|
|
document.querySelectorAll(".m-detail-tab").forEach(function (btn) {
|
|
const active = btn.dataset.chartTab === tab;
|
|
btn.classList.toggle("active", active);
|
|
btn.setAttribute("aria-selected", String(active));
|
|
});
|
|
}
|
|
|
|
function updateStarButton() {
|
|
const btn = document.querySelector(".m-detail-star");
|
|
if (!btn) return;
|
|
const watched = Boolean(state.detail && state.detail.watchlist);
|
|
btn.classList.toggle("is-added", watched);
|
|
btn.setAttribute("aria-label", watched ? "移出自选" : "加入自选");
|
|
btn.setAttribute("aria-pressed", String(watched));
|
|
btn.innerHTML = starIcon(watched);
|
|
btn.disabled = false;
|
|
}
|
|
|
|
function toggleWatch() {
|
|
const detail = state.detail;
|
|
if (!detail || !detail.code) return;
|
|
const btn = document.querySelector(".m-detail-star");
|
|
const adding = !detail.watchlist;
|
|
const code = detail.code;
|
|
const url = adding ? "/api/watchlist" : "/api/watchlist/" + encodeURIComponent(code);
|
|
const method = adding ? "POST" : "DELETE";
|
|
const body = adding ? { code: code, name: detail.name || code, sector: detail.sector || "" } : null;
|
|
if (btn) btn.disabled = true;
|
|
global.MobileAPI.request(url, method, body).then(function () {
|
|
detail.watchlist = adding;
|
|
updateStarButton();
|
|
showToast(adding ? "已加入自选" : "已移出自选");
|
|
}).catch(function () {
|
|
updateStarButton();
|
|
showToast("操作失败,请重试");
|
|
});
|
|
}
|
|
|
|
function showToast(message) {
|
|
const root = document.getElementById("m-sheet-root");
|
|
if (!root) return;
|
|
let toast = document.getElementById("m-toast");
|
|
if (!toast) {
|
|
toast = document.createElement("div");
|
|
toast.id = "m-toast";
|
|
toast.className = "m-toast";
|
|
root.appendChild(toast);
|
|
}
|
|
toast.textContent = message;
|
|
toast.classList.remove("is-visible");
|
|
void toast.offsetWidth;
|
|
toast.classList.add("is-visible");
|
|
global.clearTimeout(toast._timer);
|
|
toast._timer = global.setTimeout(function () {
|
|
toast.classList.remove("is-visible");
|
|
}, 1500);
|
|
}
|
|
|
|
function renderDetail(payload) {
|
|
const body = document.getElementById("m-detail-sheet-body");
|
|
if (!body) return;
|
|
const stock = payload.stock || {};
|
|
const meta = payload.meta || {};
|
|
const name = stock.name && stock.name !== "--" ? stock.name : "--";
|
|
const price = number(stock.price);
|
|
const change = number(stock.change);
|
|
const industry = stock.industry && stock.industry !== "其他" ? stock.industry : (stock.sector || "其他");
|
|
|
|
state.detail = {
|
|
code: stock.code || "",
|
|
name: name === "--" ? "" : name,
|
|
sector: industry === "其他" ? "" : industry,
|
|
watchlist: Boolean(stock.watchlist),
|
|
tab: "intraday",
|
|
payload: payload,
|
|
};
|
|
|
|
const headName = document.querySelector(".m-detail-head-name");
|
|
const headCode = document.querySelector(".m-detail-head-code");
|
|
if (headName) headName.textContent = name;
|
|
if (headCode) headCode.textContent = stock.code || "";
|
|
updateStarButton();
|
|
|
|
const intradayOk = meta.intraday_status === "available";
|
|
const chartHtml = intradayOk ? intradayChart(payload) : emptyChart(meta.intraday_notice || "分时数据暂不可用");
|
|
|
|
body.innerHTML =
|
|
'<div class="m-detail-top">' +
|
|
'<div class="m-detail-quote">' +
|
|
'<strong class="m-detail-price ' + changeClass(change) + '">' + (price ? formatNumber(price, 2) : "--") + "</strong>" +
|
|
'<span class="m-detail-change ' + changeClass(change) + '">' + (change > 0 ? "+" : "") + formatNumber(change, 2) + "%</span>" +
|
|
"</div>" +
|
|
'<div class="m-detail-meta">' + escapeHtml(industry) + " · " + escapeHtml(displayCompactDate(meta.trade_date) || "--") + " 收盘</div>" +
|
|
"</div>" +
|
|
'<div class="m-detail-tabs" role="tablist" aria-label="行情图类型">' +
|
|
'<button class="m-detail-tab active" type="button" role="tab" aria-selected="true" data-chart-tab="intraday">分时</button>' +
|
|
'<button class="m-detail-tab" type="button" role="tab" aria-selected="false" data-chart-tab="daily">日线</button>' +
|
|
'<span class="m-detail-chart-caption" id="m-detail-caption">' + escapeHtml(chartCaptionHtml("intraday")) + "</span>" +
|
|
"</div>" +
|
|
'<div class="m-detail-chart" id="m-detail-chart">' + chartHtml + "</div>" +
|
|
'<div class="m-detail-note">点按图面可读数值 · 数据来源:本地行情</div>';
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- charts */
|
|
|
|
function emptyChart(message) {
|
|
return '<div class="m-chart-empty">' + escapeHtml(message || "数据暂不可用") + "</div>";
|
|
}
|
|
|
|
function svgAxisText(x, y, anchor, cls, content) {
|
|
return '<text x="' + x + '" y="' + y + '" text-anchor="' + anchor + '" class="m-chart-axis ' + cls + '">' + escapeHtml(content) + "</text>";
|
|
}
|
|
|
|
function dateMMDD(value) {
|
|
const s = String(value || "");
|
|
return s.length >= 10 ? s.slice(5, 10) : s;
|
|
}
|
|
|
|
function movingAverage(values, period) {
|
|
const out = [];
|
|
for (let i = 0; i < values.length; i += 1) {
|
|
if (i < period - 1) { out.push(null); continue; }
|
|
let sum = 0;
|
|
for (let j = i - period + 1; j <= i; j += 1) sum += values[j];
|
|
out.push(sum / period);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function maPath(ma, xf, yf, cls) {
|
|
const pts = [];
|
|
for (let i = 0; i < ma.length; i += 1) {
|
|
if (ma[i] == null) continue;
|
|
pts.push(xf(i).toFixed(1) + " " + yf(ma[i]).toFixed(1));
|
|
}
|
|
return pts.length > 1 ? '<polyline class="m-chart-ma ' + cls + '" points="' + pts.join(" ") + '"/>' : "";
|
|
}
|
|
|
|
function intradayChart(payload) {
|
|
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
|
|
const pw = W - padL - padR;
|
|
const ph = H - padT - padB;
|
|
const points = payload.intraday || [];
|
|
const meta = payload.meta || {};
|
|
const prevClose = number(meta.intraday_previous_close) || (points.length ? number(points[0].close) : 0);
|
|
|
|
if (!points.length || prevClose <= 0) {
|
|
return emptyChart(meta.intraday_notice || "分时数据暂不可用");
|
|
}
|
|
|
|
let maxPct = 2;
|
|
points.forEach(function (p) {
|
|
const cp = number(p.close);
|
|
const ap = number(p.average);
|
|
if (cp > 0) maxPct = Math.max(maxPct, Math.abs((cp - prevClose) / prevClose * 100));
|
|
if (ap > 0) maxPct = Math.max(maxPct, Math.abs((ap - prevClose) / prevClose * 100));
|
|
});
|
|
maxPct = Math.max(0.01, Math.ceil(maxPct * 100) / 100);
|
|
|
|
function x(i) {
|
|
return padL + (points.length <= 1 ? pw / 2 : i / (points.length - 1) * pw);
|
|
}
|
|
function y(close) {
|
|
const pct = (close - prevClose) / prevClose * 100;
|
|
return padT + (maxPct - pct) / (2 * maxPct) * ph;
|
|
}
|
|
|
|
const pricePath = points.map(function (p, i) {
|
|
return (i ? "L" : "M") + x(i).toFixed(1) + " " + y(number(p.close)).toFixed(1);
|
|
}).join(" ");
|
|
|
|
const avgPts = [];
|
|
points.forEach(function (p, i) {
|
|
const a = number(p.average);
|
|
if (a <= 0) return;
|
|
avgPts.push((avgPts.length ? "L" : "M") + x(i).toFixed(1) + " " + y(a).toFixed(1));
|
|
});
|
|
const avgPath = avgPts.join(" ");
|
|
|
|
const y0 = y(prevClose).toFixed(1);
|
|
const right = W - 4;
|
|
|
|
return '<svg viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" aria-hidden="true">' +
|
|
'<line class="m-chart-prev-close" x1="' + padL + '" y1="' + y0 + '" x2="' + (W - padR) + '" y2="' + y0 + '"/>' +
|
|
'<path class="m-chart-line m-chart-avg" d="' + avgPath + '"/>' +
|
|
'<path class="m-chart-line m-chart-price" d="' + pricePath + '"/>' +
|
|
svgAxisText(right, padT + 8, "end", "is-up", "+" + maxPct.toFixed(2) + "%") +
|
|
svgAxisText(right, padT + ph / 2 + 3, "end", "", "0.00%") +
|
|
svgAxisText(right, padT + ph, "end", "is-down", "-" + maxPct.toFixed(2) + "%") +
|
|
svgAxisText(padL, H - 6, "start", "", "09:30") +
|
|
svgAxisText(padL + pw / 2, H - 6, "middle", "", "11:30/13:00") +
|
|
svgAxisText(padL + pw, H - 6, "end", "", "15:00") +
|
|
"</svg>";
|
|
}
|
|
|
|
function dailyChart(payload) {
|
|
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
|
|
const pw = W - padL - padR;
|
|
const ph = H - padT - padB;
|
|
const prices = (payload.prices || []).slice(-48);
|
|
|
|
if (prices.length < 2) return emptyChart("日线数据暂不可用");
|
|
|
|
const closes = prices.map(function (b) { return number(b.close); });
|
|
const highs = prices.map(function (b) { return number(b.high); });
|
|
const lows = prices.map(function (b) { return number(b.low); });
|
|
let max = Math.max.apply(null, highs);
|
|
let min = Math.min.apply(null, lows);
|
|
if (max - min <= 0) { max += 1; min -= 1; }
|
|
const padRange = (max - min) * 0.08;
|
|
max += padRange;
|
|
min -= padRange;
|
|
|
|
function x(i) { return padL + (i + 0.5) / prices.length * pw; }
|
|
function y(v) { return padT + (max - v) / (max - min) * ph; }
|
|
|
|
const candleW = Math.max(2, Math.min(7, pw / prices.length * 0.7));
|
|
const candles = prices.map(function (b, i) {
|
|
const up = number(b.close) >= number(b.open);
|
|
const cls = "m-chart-candle" + (up ? " is-up" : " is-down");
|
|
const cx = x(i).toFixed(1);
|
|
const bodyTop = y(Math.max(number(b.open), number(b.close))).toFixed(1);
|
|
const bodyH = Math.max(1, Math.abs(y(number(b.open)) - y(number(b.close)))).toFixed(1);
|
|
const bx = (x(i) - candleW / 2).toFixed(1);
|
|
return '<line class="' + cls + '" x1="' + cx + '" y1="' + y(number(b.high)).toFixed(1) + '" x2="' + cx + '" y2="' + y(number(b.low)).toFixed(1) + '"/>' +
|
|
'<rect class="' + cls + '" x="' + bx + '" y="' + bodyTop + '" width="' + candleW.toFixed(1) + '" height="' + bodyH + '"/>';
|
|
}).join("");
|
|
|
|
const ma5 = movingAverage(closes, 5);
|
|
const ma10 = movingAverage(closes, 10);
|
|
|
|
const firstDate = prices[0].trade_date;
|
|
const midDate = prices[Math.floor(prices.length / 2)].trade_date;
|
|
const lastDate = prices[prices.length - 1].trade_date;
|
|
const right = W - 4;
|
|
|
|
return '<div class="m-chart-legend" aria-hidden="true">' +
|
|
'<span class="m-chart-legend-ma5">MA5</span>' +
|
|
'<span class="m-chart-legend-ma10">MA10</span>' +
|
|
"</div>" +
|
|
'<svg viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none" aria-hidden="true">' +
|
|
candles +
|
|
maPath(ma5, x, y, "is-ma5") +
|
|
maPath(ma10, x, y, "is-ma10") +
|
|
svgAxisText(right, padT + 8, "end", "is-up", formatNumber(max, 2)) +
|
|
svgAxisText(right, padT + ph, "end", "is-down", formatNumber(min, 2)) +
|
|
svgAxisText(padL, H - 6, "start", "", dateMMDD(firstDate)) +
|
|
svgAxisText(padL + pw / 2, H - 6, "middle", "", dateMMDD(midDate)) +
|
|
svgAxisText(padL + pw, H - 6, "end", "", dateMMDD(lastDate)) +
|
|
"</svg>";
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- events */
|
|
|
|
function bindEvents() {
|
|
window.addEventListener("hashchange", closeSheet);
|
|
|
|
document.addEventListener("click", function (event) {
|
|
const dateBtn = event.target.closest("[data-action=date]");
|
|
if (dateBtn) { openDateSheet(); return; }
|
|
|
|
const retry = event.target.closest("[data-action=retry]");
|
|
if (retry) { load(); return; }
|
|
|
|
const sortHead = event.target.closest("[data-sort-key]");
|
|
if (sortHead) { toggleSort(sortHead.dataset.sortKey); return; }
|
|
|
|
const watchBtn = event.target.closest("[data-action=watch]");
|
|
if (watchBtn) { toggleWatch(); return; }
|
|
|
|
const chartTab = event.target.closest("[data-chart-tab]");
|
|
if (chartTab) { switchChartTab(chartTab.dataset.chartTab); return; }
|
|
|
|
const sourceTab = event.target.closest("[data-action=source]");
|
|
if (sourceTab) {
|
|
state.popularitySource = sourceTab.dataset.source || "combined";
|
|
renderTableBody();
|
|
document.querySelectorAll(".m-source-tab").forEach(function (tab) {
|
|
const active = tab.dataset.source === state.popularitySource;
|
|
tab.classList.toggle("active", active);
|
|
tab.setAttribute("aria-selected", String(active));
|
|
});
|
|
return;
|
|
}
|
|
|
|
const closeBtn = event.target.closest("[data-sheet-close]");
|
|
if (closeBtn) { closeSheet(); return; }
|
|
|
|
const backdrop = event.target.closest("[data-sheet-backdrop]");
|
|
if (backdrop) { closeSheet(); return; }
|
|
|
|
const quick = event.target.closest("[data-quick-date]");
|
|
if (quick) { selectDate(quick.dataset.quickDate); return; }
|
|
|
|
const calNav = event.target.closest("[data-cal-nav]");
|
|
if (calNav) {
|
|
if (calNav.disabled) return;
|
|
const dir = calNav.dataset.calNav === "next" ? 1 : -1;
|
|
const monthIndex = state.calCursor.year * 12 + state.calCursor.month + dir;
|
|
state.calCursor = { year: Math.floor(monthIndex / 12), month: ((monthIndex % 12) + 12) % 12 };
|
|
renderDateSheetBody();
|
|
return;
|
|
}
|
|
|
|
const calCell = event.target.closest("[data-date]");
|
|
if (calCell) {
|
|
if (calCell.disabled) return;
|
|
selectDate(calCell.dataset.date);
|
|
return;
|
|
}
|
|
|
|
const row = event.target.closest("[data-code]");
|
|
if (row) {
|
|
const interactive = event.target.closest("button, a, input, select, textarea");
|
|
if (!interactive) openDetailSheet(row.dataset.code);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- init */
|
|
|
|
bindEvents();
|
|
|
|
global.MobilePages = {
|
|
render: renderPage,
|
|
has: function (key) { return Boolean(pageConfig(key)); },
|
|
};
|
|
})(window);
|