// 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
{formatMetric(key, value)} | ;
}
if (isBlank(value)) {
return -- | ;
}
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 (
{formatRatioPercent(value)}
|
);
}
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 (
{RETENTION_SERIES.map(({ key }) => (
))}
);
}
if (!hasRows) {
return (
暂无留存数据
当前筛选没有返回任何 App 数据,试试调整日期区间或放宽 App/区域筛选。
);
}
return (
{RETENTION_SERIES.map(({ key }) => {
const baseUsers = summary?.[key.replace("_retention_rate", "_retention_base_users")];
return (
整体{metricLabel(key)}
{formatRatioPercent(summary?.[key])}
{regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
{isBlank(baseUsers) ? "" : ` · cohort 样本 ${formatCount(baseUsers)} 人`}
);
})}
新增与次日留存
柱=新增(左轴)· 线=次日留存率(右轴)· 按{granularityLabel}分桶
{comboOption ? (
) : (
暂无按日数据
当前筛选没有返回按日序列,无法绘制新增与留存趋势。
)}
留存趋势
D1 / D7 / D30 · 按{granularityLabel}分桶
{appTotals.map((row) => (
))}
{trendOption ? (
) : (
暂无留存趋势数据
{activeTrendApp === ALL ? "当前筛选没有返回按日序列。" : "该 App 在当前区间没有按日序列。"}
)}
留存对比
D1/D7/D30 底色越深留存越高
{tableRows.length ? (
| 对象 |
{TABLE_METRIC_KEYS.map((key) => (
{metricLabel(key)}
|
))}
| 合计 |
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, tableTotal?.[key]))}
{tableRows.map((item) => (
| {item.label} |
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))}
))}
) : (
暂无对比数据
{tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}
)}
留存按注册日 cohort 计算;观察期未到的日期显示 "--"。hyapp App 的按日留存暂不提供(仅整段区间口径)。
);
}