图表页(总览)
后台的门面:一行 KPI 瓦片(值 + 相对上一期的涨跌)压阵,下面趋势图、对比图从真实资源数据推导。经营总览、数据分析页用它 —— 也是「首页绝不留脚手架欢迎页」的答案。
试这几下:
- 右上角切近 7 天 / 近 30 天 —— 四块 KPI 和两张图一起重算(过滤器圈住它下面的一切)
- 在折线上滑动 —— 十字线吸附到最近的日期,一个 tooltip 报当日值;条形图逐柱悬停提亮 + 报数
- 每张图右上角切「表」 —— 图表的表格孪生,数值不靠悬浮也读得到
规矩
形状契约(蓝本)+ 本 wiki 的 dataviz 口径,demo 都照做了:
- 过滤器一行、在所有图之上,圈住下面全部内容 —— 绝不一图一个过滤器
- KPI 用 stat 瓦片,不用「一根柱子的柱状图」;delta 的颜色 = 方向 ×「涨是不是好事」(退款率下降是绿的)
- 单系列不放图例(标题即图例);趋势用 2px 折线 + 10% 面积水洗;分类对比的名义类目一律同一个系列色(不按大小上色阶)
- tooltip 只锦上添花,不垄断数值:十字线找 X、逐柱有命中区,同时每张图都有表格孪生
- 图表色用经校验的色板(series-1 蓝亮暗两档,
validate_palette.js双模式 PASS);文字一律用文字色,不穿系列色;网格是实线发丝线 - 双 y 轴禁用;饼图不用于比大小
- 真实版数据从资源的 list 查询推导(注意
pageSize上限 —— 全量聚合应该做成服务端统计接口,不要硬拉所有行)
蓝本:
add-chart-page.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-charts.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:图表页</title>
<!--
demo-charts.html —— 「图表页 / 总览」形状的最小可玩实现(蓝本:open-dashboard 的 add-chart-page)。
复现的不变量 + 本 wiki dataviz 口径:
- 过滤器一行、在所有图之上,圈住下面全部内容:切「近 7 天 / 近 30 天」,KPI 和两张图同时重算
- KPI 行是 stat 瓦片(不是一根柱子的柱状图):值 + 相对上一期的 delta(涨跌色 = 方向 × 涨是否为好)
- 趋势 = 单系列折线(2px 圆角)+ 10% 面积水洗;单系列不放图例,标题即图例;十字线 tooltip 找 X
- 分类对比 = 条形,名义类目一律同一个系列色(不按大小上色阶、不一类一色);逐柱 hover tooltip + 提亮
- 每张图带「表」切换 —— tooltip 只锦上添花,数值不靠悬浮也能读到(表格孪生)
- 图表色用经校验的参考色板(series-1 蓝,亮暗两档),文字一律用文字色而非系列色
数据为确定性伪数据(按下标计算,无随机),演示用。
运行时:/vendor 的 React 18 UMD + htm,样式共用 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
/* dataviz 令牌(参考色板实例,亮暗分档;图表表面即卡片表面) */
:root {
--series-1: #2a78d6; --grid: #e1e0d9; --axis-ink: #898781;
--delta-good: #006300; --delta-bad: #d03b3b;
}
@media (prefers-color-scheme: dark) {
:root { --series-1: #3987e5; --grid: #2c2c2a; --delta-good: #0ca30c; --delta-bad: #e5695f; }
}
.chartcard { padding: 14px 16px; margin-bottom: 12px; position: relative; }
.chartcard h2 { margin: 0; font-size: 14px; }
.chartcard .chead { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
svg { display: block; width: 100%; height: auto; }
.gridline { stroke: var(--grid); stroke-width: 1; }
.axis-label { fill: var(--axis-ink); font-size: 10px; }
.bar { fill: var(--series-1); }
.bar:hover, .bar:focus { filter: brightness(1.12); outline: none; }
.tip { position: absolute; pointer-events: none; background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 5px 9px; font-size: 12px; box-shadow: 0 2px 10px rgba(0,0,0,.18);
white-space: nowrap; z-index: 5; }
.tip b { font-size: 13px; }
.tip .k { color: var(--muted); }
.mini { font-size: 12px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useMemo, useEffect, useRef } = React;
const html = htm.bind(React.createElement);
// ---- 确定性伪数据:60 天(当期窗口 + 等长上一期,用来算 delta)----
const DAYS = 60;
const BASE = new Date(2026, 4, 9); // 2026-05-09 起,最后一天 2026-07-07
function fmtDate(i) { const d = new Date(BASE.getFullYear(), BASE.getMonth(), BASE.getDate() + i);
return `${d.getMonth() + 1}-${String(d.getDate()).padStart(2, "0")}`; }
const REVENUE = Array.from({ length: DAYS }, (_, i) =>
Math.round(8200 + 2600 * Math.sin(i / 4.7) + 1400 * Math.sin(i / 1.9) + i * 55));
const ORDERS = Array.from({ length: DAYS }, (_, i) =>
Math.round(92 + 24 * Math.sin(i / 3.1) + i * 0.7));
const CATS = [
{ name: "数码", v: 186 }, { name: "服饰", v: 342 }, { name: "食品", v: 251 },
{ name: "家居", v: 128 }, { name: "图书", v: 74 },
];
const sum = a => a.reduce((s, x) => s + x, 0);
const money = n => "¥ " + Math.round(n).toLocaleString("zh-CN");
const pct = n => (n >= 0 ? "+" : "") + (n * 100).toFixed(1) + "%";
function niceTicks(max) { // y 轴取整刻度:0 与 3 个干净数
const raw = max / 3, mag = 10 ** Math.floor(Math.log10(raw));
const step = [1, 2, 2.5, 5, 10].map(m => m * mag).find(s => s * 3 >= max);
return [0, step, step * 2, step * 3];
}
function StatCard({ label, value, delta, goodWhenUp = true }) {
const good = delta == null ? true : (delta >= 0) === goodWhenUp;
return html`
<div class="card stat">
<p class="stat-label">${label}</p>
<p class="stat-value">${value}</p>
${delta != null && html`
<p class="stat-delta" style=${{ color: good ? "var(--delta-good)" : "var(--delta-bad)" }}>
${delta >= 0 ? "↑" : "↓"} ${pct(Math.abs(delta))} <span style=${{ color: "var(--muted)" }}>vs 上一期</span>
</p>`}
</div>`;
}
// 折线:十字线找 X,一个 tooltip 报当日值;单系列 → 无图例
function LineChart({ data, labels }) {
const [hover, setHover] = useState(null); // {i, px, py}
const ref = useRef(null);
const W = 640, H = 230, L = 48, R = 16, T = 14, B = 26;
const ticks = niceTicks(Math.max(...data));
const yMax = ticks[3];
const x = i => L + (i * (W - L - R)) / (data.length - 1);
const y = v => T + (1 - v / yMax) * (H - T - B);
const path = data.map((v, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const area = `${path} L${x(data.length - 1).toFixed(1)},${y(0)} L${x(0).toFixed(1)},${y(0)} Z`;
const every = Math.ceil(data.length / 7);
const onMove = e => {
const box = ref.current.getBoundingClientRect();
const vx = ((e.clientX - box.left) / box.width) * W;
const i = Math.max(0, Math.min(data.length - 1,
Math.round(((vx - L) / (W - L - R)) * (data.length - 1))));
setHover({ i, px: (x(i) / W) * box.width, py: (y(data[i]) / H) * box.height });
};
return html`
<div style=${{ position: "relative" }}>
<svg ref=${ref} viewBox=${`0 0 ${W} ${H}`} onPointerMove=${onMove} onPointerLeave=${() => setHover(null)}>
${ticks.map(t => html`
<line key=${t} class="gridline" x1=${L} x2=${W - R} y1=${y(t)} y2=${y(t)} />
<text key=${"t" + t} class="axis-label" x=${L - 6} y=${y(t) + 3} text-anchor="end">${t.toLocaleString("zh-CN")}</text>`)}
${labels.map((d, i) => (i % every === 0 ? html`
<text key=${d} class="axis-label" x=${x(i)} y=${H - 8} text-anchor="middle">${d}</text>` : null))}
<path d=${area} fill="var(--series-1)" opacity="0.1" />
<path d=${path} fill="none" stroke="var(--series-1)" stroke-width="2"
stroke-linejoin="round" stroke-linecap="round" />
${hover && html`
<line x1=${x(hover.i)} x2=${x(hover.i)} y1=${T} y2=${H - B} stroke="var(--axis-ink)" stroke-width="1" />
<circle cx=${x(hover.i)} cy=${y(data[hover.i])} r="4.5" fill="var(--series-1)"
stroke="var(--card)" stroke-width="2" />`}
</svg>
${hover && html`
<div class="tip" style=${{ left: Math.min(hover.px + 12, 520) + "px", top: hover.py - 8 + "px" }}>
<b>${money(data[hover.i])}</b> <span class="k">${labels[hover.i]}</span>
</div>`}
</div>`;
}
// 条形:名义类目一律同一系列色;逐柱 hover/focus tooltip;只给最大值直接标数
function BarChart({ data }) {
const [hover, setHover] = useState(null); // {i, px, py}
const ref = useRef(null);
const W = 640, H = 200, L = 48, R = 16, T = 12, B = 26;
const ticks = niceTicks(Math.max(...data.map(d => d.v)));
const yMax = ticks[3];
const band = (W - L - R) / data.length;
const bw = Math.min(24, band * 0.5);
const y = v => T + (1 - v / yMax) * (H - T - B);
const maxV = Math.max(...data.map(d => d.v));
const show = (i, e) => {
const box = ref.current.getBoundingClientRect();
const cx = L + i * band + band / 2;
setHover({ i, px: (cx / W) * box.width, py: (y(data[i].v) / H) * box.height });
};
return html`
<div style=${{ position: "relative" }}>
<svg ref=${ref} viewBox=${`0 0 ${W} ${H}`} onPointerLeave=${() => setHover(null)}>
${ticks.map(t => html`
<line key=${t} class="gridline" x1=${L} x2=${W - R} y1=${y(t)} y2=${y(t)} />
<text key=${"t" + t} class="axis-label" x=${L - 6} y=${y(t) + 3} text-anchor="end">${t}</text>`)}
${data.map((d, i) => {
const cx = L + i * band + band / 2, h = y(0) - y(d.v);
return html`
<g key=${d.name}>
<path class="bar" tabIndex="0" role="img" aria-label=${`${d.name} ${d.v} 件`}
d=${`M${cx - bw / 2},${y(0)} v${-(h - 4)} q0,-4 4,-4 h${bw - 8} q4,0 4,4 v${h - 4} z`}
onPointerEnter=${e => show(i, e)} onFocus=${e => show(i, e)} onBlur=${() => setHover(null)} />
${d.v === maxV && html`
<text class="axis-label" x=${cx} y=${y(d.v) - 5} text-anchor="middle"
style=${{ fill: "var(--fg)", fontWeight: 600 }}>${d.v}</text>`}
<text class="axis-label" x=${cx} y=${H - 8} text-anchor="middle">${d.name}</text>
</g>`;
})}
</svg>
${hover && html`
<div class="tip" style=${{ left: Math.min(hover.px + 12, 520) + "px", top: hover.py - 8 + "px" }}>
<b>${data[hover.i].v} 件</b> <span class="k">${data[hover.i].name}</span>
</div>`}
</div>`;
}
function ChartCard({ title, table, children }) {
const [mode, setMode] = useState("chart"); // 表格孪生:数值不靠 tooltip 也读得到
return html`
<div class="card chartcard">
<div class="chead">
<h2>${title}</h2>
<div class="seg mini">
<button class=${mode === "chart" ? "on" : ""} onClick=${() => setMode("chart")}>图</button>
<button class=${mode === "table" ? "on" : ""} onClick=${() => setMode("table")}>表</button>
</div>
</div>
${mode === "chart" ? children : table}
</div>`;
}
function App() {
const [range, setRange] = useState(7); // 过滤器圈住下面一切:KPI + 两张图同时重算
const view = useMemo(() => {
const rev = REVENUE.slice(DAYS - range), prevRev = REVENUE.slice(DAYS - 2 * range, DAYS - range);
const ord = ORDERS.slice(DAYS - range), prevOrd = ORDERS.slice(DAYS - 2 * range, DAYS - range);
const labels = Array.from({ length: range }, (_, i) => fmtDate(DAYS - range + i));
const aov = sum(rev) / sum(ord), prevAov = sum(prevRev) / sum(prevOrd);
return {
rev, labels,
revTotal: sum(rev), revDelta: sum(rev) / sum(prevRev) - 1,
ordTotal: sum(ord), ordDelta: sum(ord) / sum(prevOrd) - 1,
aov, aovDelta: aov / prevAov - 1,
refund: 0.032, refundDelta: -0.004, // 演示值:退款率降了(降是好事)
};
}, [range]);
return html`
<div class="page">
<header class="head">
<div>
<h1>经营总览</h1>
<p class="sub">过滤器一行圈住全部 —— 切区间,KPI 与两张图一起重算;每张图可切「表」</p>
</div>
<div class="seg">
<button class=${range === 7 ? "on" : ""} onClick=${() => setRange(7)}>近 7 天</button>
<button class=${range === 30 ? "on" : ""} onClick=${() => setRange(30)}>近 30 天</button>
</div>
</header>
<div class="stats">
<${StatCard} label="总收入" value=${money(view.revTotal)} delta=${view.revDelta} />
<${StatCard} label="订单数" value=${view.ordTotal.toLocaleString("zh-CN")} delta=${view.ordDelta} />
<${StatCard} label="客单价" value=${money(view.aov)} delta=${view.aovDelta} />
<${StatCard} label="退款率" value=${(view.refund * 100).toFixed(1) + "%"} delta=${view.refundDelta} goodWhenUp=${false} />
</div>
<${ChartCard} title=${`收入趋势(近 ${range} 天)`}
table=${html`
<table class="list mini"><thead><tr><th>日期</th><th class="num">收入</th></tr></thead><tbody>
${view.labels.map((d, i) => html`
<tr key=${d}><td>${d}</td><td class="num">${money(view.rev[i])}</td></tr>`)}
</tbody></table>`}>
<${LineChart} data=${view.rev} labels=${view.labels} />
<//>
<${ChartCard} title="分类销量(当期)"
table=${html`
<table class="list mini"><thead><tr><th>分类</th><th class="num">销量</th></tr></thead><tbody>
${CATS.map(c => html`<tr key=${c.name}><td>${c.name}</td><td class="num">${c.v} 件</td></tr>`)}
</tbody></table>`}>
<${BarChart} data=${CATS} />
<//>
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>