401 lines
15 KiB
JavaScript
401 lines
15 KiB
JavaScript
// RetentionView(留存质量):整体 D1/D7/D30 概要卡、新增×次日留存组合图、
|
||
// 留存趋势(汇总/单 App)、App/区域留存热力对比表。数据契约见 SocialBiApp.jsx 的 useSocialBi()。
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { useSocialBi } from "../SocialBiApp.jsx";
|
||
import { EChart } from "../../charts/EChart.jsx";
|
||
import { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
|
||
import { formatCount, formatPercentValue, formatRatioPercent, isBlank } from "../format.js";
|
||
import { ALL, bucketDay } from "../state.js";
|
||
import "./retention-view.css";
|
||
|
||
// 图表 canvas 内无法读 CSS 变量,这里用与 social-v2.css token 一致的十六进制值。
|
||
const CHART_ACCENT = "#2557d6"; // var(--sbi-accent)
|
||
const CHART_ACCENT_2 = "#0e7490"; // var(--sbi-accent-2)
|
||
const CHART_GRID_LINE = "#eef3f9";
|
||
const CHART_TEXT = "#5b7089";
|
||
const CHART_GRID = { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 };
|
||
const CHART_LEGEND = { itemHeight: 9, itemWidth: 14, textStyle: { color: CHART_TEXT, fontSize: 12 }, top: 0 };
|
||
const CHART_TOOLTIP = { backgroundColor: "#ffffff", borderColor: CHART_GRID_LINE, textStyle: { color: CHART_TEXT }, trigger: "axis" };
|
||
|
||
const RETENTION_SERIES = [
|
||
{ color: CHART_ACCENT, key: "d1_retention_rate" },
|
||
{ color: CHART_ACCENT_2, key: "d7_retention_rate" },
|
||
{ color: "#7c3aed", key: "d30_retention_rate" }
|
||
];
|
||
|
||
const HEAT_KEYS = new Set(["d1_retention_rate", "d7_retention_rate", "d30_retention_rate"]);
|
||
const TABLE_METRIC_KEYS = [
|
||
"new_users",
|
||
"active_users",
|
||
"d1_retention_rate",
|
||
"d7_retention_rate",
|
||
"d30_retention_rate",
|
||
"paid_conversion_rate"
|
||
];
|
||
|
||
const GRANULARITY_LABELS = { day: "日", month: "月", week: "周" };
|
||
|
||
// 把日粒度行按 bucketDay 分桶后逐桶聚合(比例按分子分母重算),桶 key 升序。
|
||
function bucketAggregates(rows, granularity) {
|
||
const map = new Map();
|
||
rows.forEach((row) => {
|
||
const bucket = bucketDay(row.stat_day, granularity);
|
||
const list = map.get(bucket);
|
||
if (list) {
|
||
list.push(row);
|
||
} else {
|
||
map.set(bucket, [row]);
|
||
}
|
||
});
|
||
const buckets = [...map.keys()].sort();
|
||
return { aggregates: buckets.map((key) => aggregateMetricMaps(map.get(key))), buckets };
|
||
}
|
||
|
||
function categoryAxis(buckets) {
|
||
return {
|
||
axisLabel: { color: CHART_TEXT },
|
||
axisLine: { lineStyle: { color: CHART_GRID_LINE } },
|
||
axisTick: { show: false },
|
||
data: buckets,
|
||
type: "category"
|
||
};
|
||
}
|
||
|
||
// 留存热力单元格:值越高蓝底越深(上限 0.85),深度超过 0.35 换白字保证对比度;null 无底色显示 "--"。
|
||
function renderMetricCell(key, value) {
|
||
if (!HEAT_KEYS.has(key)) {
|
||
return <td key={key}>{formatMetric(key, value)}</td>;
|
||
}
|
||
if (isBlank(value)) {
|
||
return <td key={key}>--</td>;
|
||
}
|
||
const depth = Math.min(0.85, Number(value) * 2);
|
||
const style = { backgroundColor: `rgba(37, 87, 214, ${Number(depth.toFixed(3))})` };
|
||
if (depth > 0.35) {
|
||
style.color = "#ffffff";
|
||
}
|
||
return (
|
||
<td key={key} style={style}>
|
||
{formatRatioPercent(value)}
|
||
</td>
|
||
);
|
||
}
|
||
|
||
export function RetentionView() {
|
||
const { derived, filters, isLoading } = useSocialBi();
|
||
const { appDaily, appTotals, regionDaily, regionScoped, regionTotals } = derived;
|
||
const [trendApp, setTrendApp] = useState(ALL);
|
||
const [tableDim, setTableDim] = useState("app");
|
||
|
||
const granularityLabel = GRANULARITY_LABELS[filters.granularity] || "日";
|
||
// 筛选变化后所选 App 可能不在结果里,回退到汇总(不直接改 state,渲染期兜底即可)。
|
||
const activeTrendApp = trendApp !== ALL && appTotals.some((row) => row.app_code === trendApp) ? trendApp : ALL;
|
||
// 选了具体区域时,全视图(概要/图表/对比表)统一切到区域行口径,避免同屏两套数字。
|
||
const dailyRows = regionScoped ? regionDaily : appDaily;
|
||
|
||
// 1) 概要卡:选了具体区域时 App 级合计改用区域行聚合,与顶栏语义一致。
|
||
const summary = useMemo(() => {
|
||
const rows = regionScoped ? regionTotals : appTotals;
|
||
return rows.length ? aggregateMetricMaps(rows) : null;
|
||
}, [appTotals, regionScoped, regionTotals]);
|
||
|
||
// 2) 新增(柱/左轴)× 次日留存率(线/右轴),按全局粒度分桶;null 让线断开。
|
||
const comboOption = useMemo(() => {
|
||
const { aggregates, buckets } = bucketAggregates(dailyRows, filters.granularity);
|
||
if (!buckets.length) {
|
||
return null;
|
||
}
|
||
return {
|
||
grid: { ...CHART_GRID },
|
||
legend: { ...CHART_LEGEND },
|
||
series: [
|
||
{
|
||
barMaxWidth: 26,
|
||
data: aggregates.map((row) => metricChartValue("new_users", row.new_users)),
|
||
itemStyle: { borderRadius: [3, 3, 0, 0], color: CHART_ACCENT },
|
||
name: metricLabel("new_users"),
|
||
tooltip: { valueFormatter: (value) => formatCount(value) },
|
||
type: "bar"
|
||
},
|
||
{
|
||
connectNulls: false,
|
||
data: aggregates.map((row) => metricChartValue("d1_retention_rate", row.d1_retention_rate)),
|
||
itemStyle: { color: CHART_ACCENT_2 },
|
||
lineStyle: { color: CHART_ACCENT_2, width: 2.5 },
|
||
name: metricLabel("d1_retention_rate"),
|
||
symbol: "circle",
|
||
symbolSize: 6,
|
||
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
|
||
type: "line",
|
||
yAxisIndex: 1
|
||
}
|
||
],
|
||
tooltip: { ...CHART_TOOLTIP, axisPointer: { type: "shadow" } },
|
||
xAxis: categoryAxis(buckets),
|
||
yAxis: [
|
||
{
|
||
axisLabel: { color: CHART_TEXT },
|
||
splitLine: { lineStyle: { color: CHART_GRID_LINE } },
|
||
type: "value"
|
||
},
|
||
{
|
||
axisLabel: { color: CHART_TEXT, formatter: "{value}%" },
|
||
splitLine: { show: false },
|
||
type: "value"
|
||
}
|
||
]
|
||
};
|
||
}, [dailyRows, filters.granularity]);
|
||
|
||
// 3) D1/D7/D30 留存趋势:汇总或单 App 的日行分桶聚合(区域筛选时用区域日行)。
|
||
const trendOption = useMemo(() => {
|
||
const rows = activeTrendApp === ALL ? dailyRows : dailyRows.filter((row) => row.app_code === activeTrendApp);
|
||
const { aggregates, buckets } = bucketAggregates(rows, filters.granularity);
|
||
if (!buckets.length) {
|
||
return null;
|
||
}
|
||
return {
|
||
grid: { ...CHART_GRID },
|
||
legend: { ...CHART_LEGEND },
|
||
series: RETENTION_SERIES.map(({ color, key }) => ({
|
||
connectNulls: false,
|
||
data: aggregates.map((row) => metricChartValue(key, row[key])),
|
||
itemStyle: { color },
|
||
lineStyle: { color, width: 2.5 },
|
||
name: metricLabel(key),
|
||
symbol: "circle",
|
||
symbolSize: 5,
|
||
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
|
||
type: "line"
|
||
})),
|
||
tooltip: { ...CHART_TOOLTIP },
|
||
xAxis: categoryAxis(buckets),
|
||
yAxis: {
|
||
axisLabel: { color: CHART_TEXT, formatter: "{value}%" },
|
||
splitLine: { lineStyle: { color: CHART_GRID_LINE } },
|
||
type: "value"
|
||
}
|
||
};
|
||
}, [activeTrendApp, dailyRows, filters.granularity]);
|
||
|
||
// 4) 热力对比表:按 App / 按区域;区域筛选时"按 App"改为区域行按 App 聚合,保持口径一致。
|
||
const tableSource = useMemo(() => {
|
||
if (tableDim === "region") {
|
||
return regionTotals;
|
||
}
|
||
if (!regionScoped) {
|
||
return appTotals;
|
||
}
|
||
const byApp = new Map();
|
||
regionTotals.forEach((row) => {
|
||
const list = byApp.get(row.app_code) || [];
|
||
list.push(row);
|
||
byApp.set(row.app_code, list);
|
||
});
|
||
return [...byApp.entries()].map(([appCode, rows]) => ({
|
||
app_code: appCode,
|
||
app_name: rows[0].app_name,
|
||
...aggregateMetricMaps(rows)
|
||
}));
|
||
}, [appTotals, regionScoped, regionTotals, tableDim]);
|
||
|
||
const tableRows = useMemo(() => {
|
||
return tableSource
|
||
.map((row) => ({
|
||
key: tableDim === "region" ? `${row.app_code}:${row.region_id}` : row.app_code,
|
||
label: tableDim === "region" ? `${row.app_name}·${row.region_name}` : row.app_name,
|
||
row
|
||
}))
|
||
.sort((a, b) => (Number(b.row.new_users) || 0) - (Number(a.row.new_users) || 0));
|
||
}, [tableDim, tableSource]);
|
||
|
||
const tableTotal = useMemo(() => {
|
||
return tableSource.length ? aggregateMetricMaps(tableSource) : null;
|
||
}, [tableSource]);
|
||
|
||
const hasRows = appTotals.length > 0 || appDaily.length > 0 || regionTotals.length > 0;
|
||
|
||
if (isLoading && !hasRows) {
|
||
return (
|
||
<div className="sbi-rt-view">
|
||
<div className="sbi-rt-summary">
|
||
{RETENTION_SERIES.map(({ key }) => (
|
||
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
||
<span className="sbi-skeleton sbi-rt-skeleton-line" />
|
||
<span className="sbi-skeleton sbi-rt-skeleton-value" />
|
||
</section>
|
||
))}
|
||
</div>
|
||
<section className="sbi-card">
|
||
<span className="sbi-skeleton sbi-rt-skeleton-chart" />
|
||
</section>
|
||
<section className="sbi-card">
|
||
<span className="sbi-skeleton sbi-rt-skeleton-chart" />
|
||
</section>
|
||
<section className="sbi-card">
|
||
<span className="sbi-skeleton sbi-rt-skeleton-table" />
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!hasRows) {
|
||
return (
|
||
<div className="sbi-rt-view">
|
||
<section className="sbi-card">
|
||
<div className="sbi-empty">
|
||
<strong>暂无留存数据</strong>
|
||
<span>当前筛选没有返回任何 App 数据,试试调整日期区间或放宽 App/区域筛选。</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="sbi-rt-view">
|
||
<div className="sbi-rt-summary">
|
||
{RETENTION_SERIES.map(({ key }) => {
|
||
const baseUsers = summary?.[key.replace("_retention_rate", "_retention_base_users")];
|
||
return (
|
||
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
||
<span className="sbi-rt-summary-label sbi-metric-hint" title={metricTooltip(key)}>
|
||
整体{metricLabel(key)}
|
||
</span>
|
||
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
|
||
<span className="sbi-rt-summary-note">
|
||
{regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
|
||
{isBlank(baseUsers) ? "" : ` · cohort 样本 ${formatCount(baseUsers)} 人`}
|
||
</span>
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<section className="sbi-card">
|
||
<div className="sbi-card-header">
|
||
<strong>新增与次日留存</strong>
|
||
<small>柱=新增(左轴)· 线=次日留存率(右轴)· 按{granularityLabel}分桶</small>
|
||
</div>
|
||
{comboOption ? (
|
||
<EChart className="sbi-rt-chart-combo" option={comboOption} />
|
||
) : (
|
||
<div className="sbi-empty">
|
||
<strong>暂无按日数据</strong>
|
||
<span>当前筛选没有返回按日序列,无法绘制新增与留存趋势。</span>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="sbi-card">
|
||
<div className="sbi-card-header">
|
||
<strong>留存趋势</strong>
|
||
<small>D1 / D7 / D30 · 按{granularityLabel}分桶</small>
|
||
<div className="sbi-card-toolbar">
|
||
<div className="sbi-seg" role="radiogroup" aria-label="留存趋势维度">
|
||
<button
|
||
aria-checked={activeTrendApp === ALL}
|
||
className={activeTrendApp === ALL ? "is-active" : ""}
|
||
onClick={() => setTrendApp(ALL)}
|
||
role="radio"
|
||
type="button"
|
||
>
|
||
汇总
|
||
</button>
|
||
{appTotals.map((row) => (
|
||
<button
|
||
aria-checked={activeTrendApp === row.app_code}
|
||
className={activeTrendApp === row.app_code ? "is-active" : ""}
|
||
key={row.app_code}
|
||
onClick={() => setTrendApp(row.app_code)}
|
||
role="radio"
|
||
type="button"
|
||
>
|
||
{row.app_name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{trendOption ? (
|
||
<EChart className="sbi-rt-chart-trend" option={trendOption} />
|
||
) : (
|
||
<div className="sbi-empty">
|
||
<strong>暂无留存趋势数据</strong>
|
||
<span>{activeTrendApp === ALL ? "当前筛选没有返回按日序列。" : "该 App 在当前区间没有按日序列。"}</span>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="sbi-card sbi-rt-table-card">
|
||
<div className="sbi-card-header">
|
||
<strong>留存对比</strong>
|
||
<small>D1/D7/D30 底色越深留存越高</small>
|
||
<div className="sbi-card-toolbar">
|
||
<div className="sbi-seg" role="radiogroup" aria-label="留存对比维度">
|
||
<button
|
||
aria-checked={tableDim === "app"}
|
||
className={tableDim === "app" ? "is-active" : ""}
|
||
onClick={() => setTableDim("app")}
|
||
role="radio"
|
||
type="button"
|
||
>
|
||
按 App
|
||
</button>
|
||
<button
|
||
aria-checked={tableDim === "region"}
|
||
className={tableDim === "region" ? "is-active" : ""}
|
||
onClick={() => setTableDim("region")}
|
||
role="radio"
|
||
type="button"
|
||
>
|
||
按区域
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{tableRows.length ? (
|
||
<div className="sbi-table-scroll">
|
||
<table className="sbi-table">
|
||
<thead>
|
||
<tr>
|
||
<th className="is-left">对象</th>
|
||
{TABLE_METRIC_KEYS.map((key) => (
|
||
<th key={key}>
|
||
<span className="sbi-metric-hint" title={metricTooltip(key)}>
|
||
{metricLabel(key)}
|
||
</span>
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr className="is-total">
|
||
<td className="is-left">合计</td>
|
||
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, tableTotal?.[key]))}
|
||
</tr>
|
||
{tableRows.map((item) => (
|
||
<tr key={item.key}>
|
||
<td className="is-left">{item.label}</td>
|
||
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="sbi-empty">
|
||
<strong>暂无对比数据</strong>
|
||
<span>{tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}</span>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<p className="sbi-rt-footnote">
|
||
留存按注册日 cohort 计算;观察期未到的日期显示 "--"。hyapp App 的按日留存暂不提供(仅整段区间口径)。
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|