|
17 | 17 | let txRange = "24h"; // 交易时间段快捷范围:24h / 7d / 30d / all / custom(默认最近 24 小时,rant 2026-08-22T10:50:00) |
18 | 18 | let txCustomStart = ""; // 自定义开始(datetime-local 值,本地时区) |
19 | 19 | let txCustomEnd = ""; // 自定义结束 |
| 20 | + let txTrendMetric = "expense"; // 趋势图指标(rant 2026-08-23T16:17:18 需求 2):expense / income / net / tokens,默认消费点数 |
20 | 21 | let isGuest = false; // 游客模式(US-1:未登录可浏览市场) |
21 | 22 | let pendingHashView = null; // URL hash 路由(rant 20:39:30 A):刷新后登录时恢复上次视图 |
22 | 23 | let mkExpanded = null; // 市场行展开(rant 20:39:30 F):当前展开的模型 id,null=全部收起;仅展开当前行 |
|
1240 | 1241 | $("#tx-summary").innerHTML = html; |
1241 | 1242 | } |
1242 | 1243 |
|
1243 | | - // 交易趋势图(rant 2026-08-23T16:01:07 需求 2):手写 SVG 折线图(无外部依赖), |
| 1244 | + // 交易趋势图(rant 2026-08-23T16:01:07 需求 2 + 2026-08-23T16:17:18 优化):手写 SVG(无外部依赖), |
1244 | 1245 | // 数据源 = /api/transactions/trend(跟随 tab + 外部时间段筛选,与 summary 同口径); |
1245 | | - // 横轴 = 时间桶(hour/day/week),纵轴 = 点数;收入(绿) / 支出(红) 两条线,点悬停显示精确值。 |
| 1246 | + // 指标可切换(消费/收入/净变化/Token,默认消费点数);平滑折线 + 渐变面积 + 自适应刻度 + 悬停 tooltip。 |
| 1247 | + let _trendCtx = null; // 悬停 tooltip 上下文(renderTxTrend 写入,事件委托读取) |
1246 | 1248 | function renderTxTrend() { |
1247 | 1249 | const el = $("#tx-trend"); |
1248 | 1250 | const tr = (Live.transactions && Live.transactions.trend) ? Live.transactions.trend : null; |
1249 | 1251 | const buckets = (tr && Array.isArray(tr.buckets)) ? tr.buckets : []; |
1250 | 1252 | if (!buckets.length) { |
1251 | 1253 | el.innerHTML = '<div class="tx-trend-empty">' + esc(T("tx.trend.empty")) + "</div>"; |
| 1254 | + _trendCtx = null; |
1252 | 1255 | return; |
1253 | 1256 | } |
1254 | 1257 | const bucket = tr.bucket || "day"; |
1255 | | - const income = buckets.map((b) => b.income || 0); |
1256 | | - const expense = buckets.map((b) => b.expense || 0); |
1257 | | - const maxV = Math.max(1, ...income, ...expense); |
1258 | | - const W = 640, H = 170, PL = 46, PR = 12, PT = 12, PB = 24; |
| 1258 | + const METRICS = [ |
| 1259 | + { key: "expense", label: T("tx.trend.metric.expense"), pick: (b) => b.expense || 0, cls: "exp" }, |
| 1260 | + { key: "income", label: T("tx.trend.metric.income"), pick: (b) => b.income || 0, cls: "inc" }, |
| 1261 | + { key: "net", label: T("tx.trend.metric.net"), pick: (b) => (b.net === undefined ? (b.income || 0) - (b.expense || 0) : b.net) || 0, cls: "net", signed: true }, |
| 1262 | + { key: "tokens", label: T("tx.trend.metric.tokens"), pick: (b) => b.tokens || 0, cls: "tok" }, |
| 1263 | + ]; |
| 1264 | + const m = METRICS.find((x) => x.key === txTrendMetric) || METRICS[0]; |
| 1265 | + const vals = buckets.map(m.pick); |
| 1266 | + const lbl = (b) => bucketLabel(b.t, bucket); |
| 1267 | + const fmtSigned = (n) => (n > 0 ? "+" : n < 0 ? "-" : "") + D.fmt(Math.abs(n)); |
| 1268 | + const fmtAxis = (n) => { |
| 1269 | + const a = Math.abs(n); |
| 1270 | + const s = a >= 1e6 ? (a / 1e6).toFixed(2).replace(/\.?0+$/, "") + "M" |
| 1271 | + : a >= 1000 ? Math.round(a / 1000) + "K" |
| 1272 | + : (Math.round(a * 10) / 10).toString(); |
| 1273 | + return (n < 0 ? "-" : "") + s; |
| 1274 | + }; |
| 1275 | + // 自适应刻度:nice 上限(1/2/5×10^n);负值指标(净变化)±maxV 对称,零轴居中 |
| 1276 | + const neg = vals.some((v) => v < 0); |
| 1277 | + const absMax = Math.max(0, ...vals.map((v) => Math.abs(v))); |
| 1278 | + const np = Math.pow(10, Math.floor(Math.log10(Math.max(absMax, 1e-9)))); |
| 1279 | + const nm = Math.max(absMax, 1e-9) / np; |
| 1280 | + const maxV = Math.max((nm <= 1 ? 1 : nm <= 2 ? 2 : nm <= 5 ? 5 : 10) * np, 4); |
| 1281 | + const minV = neg ? -maxV : 0; |
| 1282 | + const W = 640, H = 190, PL = 46, PR = 14, PT = 14, PB = 26; |
1259 | 1283 | const iw = W - PL - PR, ih = H - PT - PB; |
1260 | 1284 | const X = (i) => PL + (buckets.length === 1 ? iw / 2 : iw * i / (buckets.length - 1)); |
1261 | | - const Y = (v) => PT + ih * (1 - v / maxV); |
1262 | | - const lbl = (b) => bucketLabel(b.t, bucket); |
1263 | | - const fmt = (n) => (n > 0 ? "+" : "") + D.fmt(n); |
1264 | | - const fmtShort = (n) => (n >= 1e6 ? (n / 1e6).toFixed(2) + "M" : (n >= 1000 ? Math.round(n / 1000) + "K" : String(Math.round(n)))); |
1265 | | - // 网格 + 纵轴刻度(4 档) |
| 1285 | + const Y = (v) => PT + ih * (maxV - v) / (maxV - minV || 1); |
| 1286 | + const pts = vals.map((v, i) => [X(i), Y(v)]); |
| 1287 | + // 平滑折线(Catmull-Rom → 三次贝塞尔,单点退化为点) |
| 1288 | + const smoothPath = (p) => { |
| 1289 | + if (!p.length) return ""; |
| 1290 | + if (p.length === 1) return "M" + p[0][0].toFixed(1) + "," + p[0][1].toFixed(1) + " L" + (p[0][0] + 1).toFixed(1) + "," + p[0][1].toFixed(1); |
| 1291 | + let d = "M" + p[0][0].toFixed(1) + "," + p[0][1].toFixed(1); |
| 1292 | + for (let i = 0; i < p.length - 1; i++) { |
| 1293 | + const p0 = p[Math.max(0, i - 1)], p1 = p[i], p2 = p[i + 1], p3 = p[Math.min(p.length - 1, i + 2)]; |
| 1294 | + const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6; |
| 1295 | + const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6; |
| 1296 | + d += " C" + c1x.toFixed(1) + "," + c1y.toFixed(1) + " " + c2x.toFixed(1) + "," + c2y.toFixed(1) + " " + p2[0].toFixed(1) + "," + p2[1].toFixed(1); |
| 1297 | + } |
| 1298 | + return d; |
| 1299 | + }; |
| 1300 | + // 网格 + 纵轴刻度(4 等分,5 条线,负值指标含负刻度) |
1266 | 1301 | let grid = ""; |
1267 | 1302 | for (let g = 0; g <= 4; g++) { |
1268 | 1303 | const gy = PT + ih * g / 4; |
| 1304 | + const gv = minV + (maxV - minV) * (4 - g) / 4; |
1269 | 1305 | grid += '<line class="tx-trend-grid" x1="' + PL + '" y1="' + gy.toFixed(1) + '" x2="' + (W - PR) + '" y2="' + gy.toFixed(1) + '"/>' + |
1270 | | - '<text class="tx-trend-axis" x="' + (PL - 6) + '" y="' + (gy + 4).toFixed(1) + '" text-anchor="end">' + fmtShort(maxV * (4 - g) / 4) + "</text>"; |
| 1306 | + '<text class="tx-trend-axis" x="' + (PL - 6) + '" y="' + (gy + 4).toFixed(1) + '" text-anchor="end">' + fmtAxis(gv) + "</text>"; |
1271 | 1307 | } |
1272 | 1308 | // 横轴标签:首 / 中 / 尾 |
1273 | 1309 | const idxs = [0, Math.floor((buckets.length - 1) / 2), buckets.length - 1].filter((v, i, a) => a.indexOf(v) === i); |
1274 | 1310 | let xlabels = ""; |
1275 | 1311 | idxs.forEach((i) => { |
1276 | 1312 | xlabels += '<text class="tx-trend-axis" x="' + X(i).toFixed(1) + '" y="' + (H - 8) + '" text-anchor="middle">' + esc(lbl(buckets[i])) + "</text>"; |
1277 | 1313 | }); |
1278 | | - // 折线 + 数据点(<title> 悬停显示精确值) |
1279 | | - const lineHtml = (arr, cls, name) => { |
1280 | | - let pts = "", dots = ""; |
1281 | | - arr.forEach((v, i) => { |
1282 | | - const xi = X(i).toFixed(1), yi = Y(v).toFixed(1); |
1283 | | - pts += (i ? " " : "") + xi + "," + yi; |
1284 | | - dots += '<circle class="' + cls + '" cx="' + xi + '" cy="' + yi + '" r="2.5"><title>' + |
1285 | | - esc(lbl(buckets[i]) + " · " + name + " " + fmt(v)) + "</title></circle>"; |
| 1314 | + // 折线 + 渐变面积 + 数据点(面积基线:负值指标取零轴,正值指标取图表底) |
| 1315 | + const yBase = Y(neg ? 0 : minV); |
| 1316 | + const areaPath = pts.length |
| 1317 | + ? smoothPath(pts) + " L" + pts[pts.length - 1][0].toFixed(1) + "," + yBase.toFixed(1) + " L" + pts[0][0].toFixed(1) + "," + yBase.toFixed(1) + " Z" |
| 1318 | + : ""; |
| 1319 | + const lineHtml = () => { |
| 1320 | + let dots = ""; |
| 1321 | + pts.forEach((p) => { |
| 1322 | + dots += '<circle class="trend-dot" cx="' + p[0].toFixed(1) + '" cy="' + p[1].toFixed(1) + '" r="2.5"/>'; |
1286 | 1323 | }); |
1287 | | - return '<polyline class="' + cls + '" points="' + pts + '"/>' + dots; |
| 1324 | + return '<path class="trend-area" d="' + areaPath + '"/>' + |
| 1325 | + '<path class="trend-line" d="' + smoothPath(pts) + '"/>' + dots; |
1288 | 1326 | }; |
| 1327 | + const switchHtml = |
| 1328 | + '<div class="tx-trend-switch" role="tablist" aria-label="' + esc(T("tx.trend.metricLabel")) + '">' + |
| 1329 | + METRICS.map((x) => |
| 1330 | + '<button type="button" class="tsw-btn' + (x.key === m.key ? " active" : "") + '" data-metric="' + x.key + '" role="tab" aria-selected="' + (x.key === m.key) + '">' + esc(x.label) + "</button>" |
| 1331 | + ).join("") + |
| 1332 | + "</div>"; |
| 1333 | + const total = vals.reduce((a, b) => a + b, 0); |
1289 | 1334 | const html = |
1290 | 1335 | '<div class="tx-trend-title">' + esc(T("tx.trend.title")) + "</div>" + |
1291 | | - '<svg viewBox="0 0 ' + W + " " + H + '" role="img" aria-label="' + esc(T("tx.trend.title")) + '">' + |
1292 | | - grid + xlabels + |
1293 | | - lineHtml(income, "tx-trend-inc", T("tx.summary.income")) + |
1294 | | - lineHtml(expense, "tx-trend-exp", T("tx.summary.expense")) + |
| 1336 | + switchHtml + |
| 1337 | + '<div class="tx-trend-chart">' + |
| 1338 | + '<svg class="m-' + m.cls + '" viewBox="0 0 ' + W + " " + H + '" role="img" aria-label="' + esc(T("tx.trend.title")) + '">' + |
| 1339 | + '<defs><linearGradient id="tx-trend-grad" x1="0" y1="0" x2="0" y2="1"><stop class="tg-0" offset="0%"/><stop class="tg-1" offset="100%"/></linearGradient></defs>' + |
| 1340 | + grid + xlabels + lineHtml() + |
1295 | 1341 | "</svg>" + |
1296 | | - '<div class="tx-trend-legend"><span><i class="tx-trend-dot inc"></i>' + esc(T("tx.summary.income")) + "</span>" + |
1297 | | - '<span><i class="tx-trend-dot exp"></i>' + esc(T("tx.summary.expense")) + "</span></div>"; |
| 1342 | + '<div class="tx-trend-guide" id="tx-trend-guide"></div>' + |
| 1343 | + '<div class="tx-trend-tip" id="tx-trend-tip"></div>' + |
| 1344 | + "</div>" + |
| 1345 | + '<div class="tx-trend-legend"><i class="tx-trend-dot ' + m.cls + '"></i><span>' + esc(m.label) + " · " + (m.signed ? fmtSigned(total) : D.fmt(total)) + "</span></div>"; |
1298 | 1346 | el.innerHTML = html; |
| 1347 | + _trendCtx = { buckets, vals, m, lbl, X, iw, W, PL }; |
1299 | 1348 | } |
1300 | 1349 |
|
1301 | 1350 | // 趋势桶标签:hour → "MM-DD HH:00";day/week → "MM-DD"(bucket 起点均为 UTC,转本地显示) |
|
3118 | 3167 | showCustom(); |
3119 | 3168 | } |
3120 | 3169 |
|
| 3170 | + // 趋势图指标切换 + 悬停 tooltip(rant 2026-08-23T16:17:18 需求 1/2:事件委托,重渲染不丢绑定) |
| 3171 | + const txTrendEl = $("#tx-trend"); |
| 3172 | + if (txTrendEl) { |
| 3173 | + txTrendEl.addEventListener("click", (e) => { |
| 3174 | + const b = e.target.closest ? e.target.closest(".tsw-btn") : null; |
| 3175 | + if (!b || b.dataset.metric === txTrendMetric) return; |
| 3176 | + txTrendMetric = b.dataset.metric; |
| 3177 | + renderTxTrend(); |
| 3178 | + }); |
| 3179 | + txTrendEl.addEventListener("mousemove", (e) => { |
| 3180 | + const ctx = _trendCtx; |
| 3181 | + const tip = $("#tx-trend-tip"), guide = $("#tx-trend-guide"); |
| 3182 | + const svg = txTrendEl.querySelector("svg"), chartEl = txTrendEl.querySelector(".tx-trend-chart"); |
| 3183 | + if (!ctx || !tip || !guide || !svg || !chartEl) return; |
| 3184 | + const rect = svg.getBoundingClientRect(); |
| 3185 | + if (!rect.width) return; |
| 3186 | + const vb = svg.getAttribute("viewBox").split(" ").map(Number); |
| 3187 | + const x = (e.clientX - rect.left) / rect.width * vb[2]; |
| 3188 | + const step = ctx.buckets.length > 1 ? ctx.X(1) - ctx.X(0) : ctx.iw; |
| 3189 | + const i = Math.max(0, Math.min(ctx.buckets.length - 1, Math.round((x - ctx.PL) / step))); |
| 3190 | + const v = ctx.vals[i]; |
| 3191 | + const cr = chartEl.getBoundingClientRect(); |
| 3192 | + const valTxt = ctx.m.signed ? (v > 0 ? "+" : v < 0 ? "-" : "") + D.fmt(Math.abs(v)) : D.fmt(v); |
| 3193 | + tip.innerHTML = esc(ctx.lbl(ctx.buckets[i])) + "<br><b>" + esc(ctx.m.label) + " " + valTxt + "</b>"; |
| 3194 | + tip.style.display = "block"; |
| 3195 | + guide.style.display = "block"; |
| 3196 | + guide.style.left = (ctx.X(i) / vb[2] * cr.width) + "px"; |
| 3197 | + tip.style.left = Math.min(Math.max(e.clientX - cr.left + 14, 4), Math.max(cr.width - tip.offsetWidth - 4, 4)) + "px"; |
| 3198 | + tip.style.top = Math.min(Math.max(e.clientY - cr.top - tip.offsetHeight - 10, 4), Math.max(cr.height - tip.offsetHeight - 4, 4)) + "px"; |
| 3199 | + }); |
| 3200 | + txTrendEl.addEventListener("mouseleave", () => { |
| 3201 | + const tip = $("#tx-trend-tip"), guide = $("#tx-trend-guide"); |
| 3202 | + if (tip) tip.style.display = "none"; |
| 3203 | + if (guide) guide.style.display = "none"; |
| 3204 | + }); |
| 3205 | + } |
| 3206 | + |
3121 | 3207 | // 表格键盘导航(rant 20:46:57 F):点击行 → 激活高亮,之后 ↑/↓/Enter/Esc 可用 |
3122 | 3208 | KBD_TABLE_IDS.forEach((id) => { |
3123 | 3209 | const c = document.getElementById(id); |
|
0 commit comments