88 lines
11 KiB
TypeScript
88 lines
11 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import type { ResearchSnapshot } from "@/lib/ai-research";
|
||
import { groupIncome, platformIncomeSample } from "@/lib/research-statistics";
|
||
|
||
const usd = (value: number | null) => value === null ? "—" : new Intl.NumberFormat("zh-CN", {
|
||
style: "currency", currency: "USD", maximumFractionDigits: 0,
|
||
}).format(value);
|
||
const percent = (value: number | null) => value === null ? "—" : `${(value * 100).toFixed(1)}%`;
|
||
|
||
export default function IncomeDistribution({ data, onSelect }: {
|
||
data: ResearchSnapshot; onSelect: (id: string) => void;
|
||
}) {
|
||
const [dateWindow, setDateWindow] = useState("7");
|
||
const [year, setYear] = useState("");
|
||
const [annualize, setAnnualize] = useState(false);
|
||
const [splitModels, setSplitModels] = useState(true);
|
||
const [logScale, setLogScale] = useState(true);
|
||
const sample = useMemo(() => platformIncomeSample(data, {
|
||
windowDays: dateWindow === "all" ? null : Number(dateWindow), foundedYear: year ? Number(year) : undefined, annualize,
|
||
}), [data, dateWindow, year, annualize]);
|
||
const groups = useMemo(() => groupIncome(sample.points, splitModels).sort((a, b) =>
|
||
a.category.localeCompare(b.category) || a.provider.localeCompare(b.provider) || a.model.localeCompare(b.model)), [sample.points, splitModels]);
|
||
const categoryGroups = [...new Set(groups.map((group) => group.category))].map((category) => ({
|
||
category, rows: groups.filter((group) => group.category === category),
|
||
}));
|
||
const categories = new Map(data.categories.map((category) => [category.id, category.name]));
|
||
const metric = annualize ? "MRR×12参考年化" : "平台MRR";
|
||
const values = sample.points.map((point) => point.value);
|
||
const minValue = Math.min(...values);
|
||
const maxValue = Math.max(1, ...values);
|
||
const axisMin = logScale && minValue > 0 && Number.isFinite(minValue) ? 10 ** Math.floor(Math.log10(minValue)) : 0;
|
||
const axisMax = Math.max(axisMin * 10, 10 ** Math.ceil(Math.log10(maxValue)));
|
||
const scale = logScale ? (value: number) => Math.log10(value + 1) : (value: number) => value;
|
||
const left = 250, right = 865;
|
||
const x = (value: number) => left + (scale(value) - scale(axisMin)) / (scale(axisMax) - scale(axisMin)) * (right - left);
|
||
const ticks = logScale ? [0, 1, 10, 100, 1000, 10000, 100000, 1000000, 10000000]
|
||
.filter((value) => value >= axisMin && value <= axisMax) : [0, .25, .5, .75, 1].map((fraction) => fraction * axisMax);
|
||
return <section className="research-distribution" aria-labelledby="income-distribution-title">
|
||
<h3 id="income-distribution-title">收入分布与均值敏感性</h3>
|
||
<p>单独比较支付平台显示的 USD MRR。每个点是一个产品或业务,法律主体与支付账户的独立性尚未全部核实;这不是行业总体或公司财报样本。</p>
|
||
<div className="research-filters">
|
||
<label>最后同步日期窗口<select value={dateWindow} onChange={(event) => setDateWindow(event.target.value)}><option value="7">截止日前 7 天内</option><option value="30">截止日前 30 天内</option><option value="90">截止日前 90 天内</option><option value="all">全部已知日期(混合历史)</option></select></label>
|
||
<label>资料中的创立年<select value={year} onChange={(event) => setYear(event.target.value)}><option value="">2025 与 2026</option><option value="2025">2025</option><option value="2026">2026</option></select></label>
|
||
<label>显示单位<select value={annualize ? "annualized" : "mrr"} onChange={(event) => setAnnualize(event.target.value === "annualized")}><option value="mrr">MRR(美元/月)</option><option value="annualized">MRR×12参考年化(美元/年)</option></select></label>
|
||
</div>
|
||
<div className="income-options"><label><input type="checkbox" checked={splitModels} onChange={(event) => setSplitModels(event.target.checked)} /> 按收费与交付模式细分</label><label><input type="checkbox" checked={logScale} onChange={(event) => setLogScale(event.target.checked)} /> 对数横轴</label></div>
|
||
<p>{splitModels ? "分组:业务类别 × 支付平台 × 收费与交付模式。同一类别下的多行是不同统计组,各自计算均值和中位数。" : "分组:业务类别 × 支付平台。同一平台内的收费模式已合并,不同支付平台仍分别统计。"}</p>
|
||
<p className="research-notice">资料截至 {data.asOf},纳入 {sample.points.length} 个产品,另有 {sample.excluded.length} 个 MRR 观测未纳入。{annualize ? "乘12只改变显示单位,不证明全年收入、续约或ARR;CV与相对敏感性不变。" : "MRR不等于近30日收款;平台数据连接也不等于财务审计。"}{dateWindow === "all" && " 当前包含不同时间的历史观测,不能称为同期对照。"}</p>
|
||
{sample.points.length ? <>
|
||
<figure>
|
||
<div className="research-table-wrap"><svg className="income-distribution-chart" viewBox={`0 0 900 ${groups.length * 70 + 65}`} role="group" aria-label={`${metric}分布,${logScale ? "对数" : "线性"}横轴;圆点为产品,竖线为均值,菱形为中位数`}>
|
||
{ticks.map((tick) => <g key={tick}><line x1={x(tick)} x2={x(tick)} y1={15} y2={groups.length * 70 + 10} stroke="#dfe6e0" /><text x={x(tick)} y={groups.length * 70 + 35} textAnchor="middle" fontSize="11" fill="#637066">{usd(tick)}</text></g>)}
|
||
{groups.map((group, index) => {
|
||
const y = index * 70 + 40;
|
||
const firstInCategory = index === 0 || groups[index - 1].category !== group.category;
|
||
const categoryRowCount = categoryGroups.find((entry) => entry.category === group.category)!.rows.length;
|
||
return <g key={group.key} role="group" aria-label={`${categories.get(group.category)},${group.provider},${group.model},${group.stats.n}个产品`}>
|
||
{firstInCategory && <>
|
||
{index > 0 && <line x1={8} x2={right} y1={y - 34} y2={y - 34} stroke="#dfe6e0" />}
|
||
<text x={8} y={y - 8} fontSize="12" fontWeight="600" fill="#202724">{categories.get(group.category)}({categoryRowCount}组)</text>
|
||
{categoryRowCount > 1 && <line x1={10} x2={10} y1={y + 3} y2={y + (categoryRowCount - 1) * 70 + 12} stroke="#95b4a6" strokeWidth={2} />}
|
||
</>}
|
||
<text x={20} y={y + 10} fontSize="10" fill="#637066">{group.provider} · {group.model} · n={group.stats.n}</text>
|
||
{group.stats.n < 3 && <text x={20} y={y + 26} fontSize="9" fill="#637066">{group.stats.n === 1 ? "仅1个产品,无法判断组内离散度" : "仅2个产品,不代表领域典型收入"}</text>}
|
||
<line x1={x(group.stats.min)} x2={x(group.stats.max)} y1={y} y2={y} stroke="#95b4a6" strokeWidth={2} />
|
||
<line x1={x(group.stats.mean)} x2={x(group.stats.mean)} y1={y - 17} y2={y + 17} stroke="#b17725" strokeWidth={3}><title>样本均值 {usd(group.stats.mean)}</title></line>
|
||
<path d={`M${x(group.stats.median)},${y - 7} l7,7 l-7,7 l-7,-7 Z`} fill="#8a78ad"><title>样本中位数 {usd(group.stats.median)}</title></path>
|
||
{group.points.map((point, position) => <g key={point.company.id} role="button" tabIndex={0} aria-label={`查看${point.company.name},${metric} ${usd(point.value)}`} onClick={() => onSelect(point.company.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onSelect(point.company.id); } }}>
|
||
<title>{point.company.name} · {usd(point.value)} · 数据日期 {point.observation.observedAt}{point.observation.connectionExpired ? " · 连接提示过期" : ""}</title>
|
||
<circle cx={x(point.value)} cy={y + ((position % 5) - 2) * 4} r={10} fill="transparent" />
|
||
<circle cx={x(point.value)} cy={y + ((position % 5) - 2) * 4} r={4.5} fill="#27745b" stroke="white" />
|
||
</g>)}
|
||
</g>;
|
||
})}
|
||
</svg></div>
|
||
<figcaption>圆点:产品观测 竖线:样本均值 菱形:样本中位数。业务类别标题下各行按支付平台{splitModels && "及收费与交付模式"}分别统计;n为该组产品数。点击点或下表产品名可看原始证据。</figcaption>
|
||
</figure>
|
||
<div className="research-table-wrap"><table className="income-statistics"><caption>{metric}样本统计;每行是类别下的一个统计组,CV使用样本标准差</caption><thead><tr><th scope="col">业务类别</th><th scope="col">支付平台 / 收费模式</th><th scope="col">n</th><th scope="col">均值</th><th scope="col">中位数</th><th scope="col">均值/中位数</th><th scope="col">CV</th><th scope="col">去除最大值后的均值 / 下降幅度</th><th scope="col">产品与限制</th></tr></thead>{categoryGroups.map(({ category, rows }) => <tbody key={category}>{rows.map((group, index) => <tr key={group.key}>
|
||
{index === 0 && <th scope="rowgroup" rowSpan={rows.length}>{categories.get(category)}<small>{rows.length}个统计组</small></th>}
|
||
<td>{group.provider}<small>{group.model}</small></td><td>{group.stats.n}</td><td>{usd(group.stats.mean)}</td><td>{usd(group.stats.median)}</td><td>{group.stats.meanMedianRatio?.toFixed(2) ?? "—"}</td><td>{group.stats.cv?.toFixed(2) ?? "—"}</td><td>{usd(group.stats.meanWithoutLargest)}<small>{percent(group.stats.largestRemovalDrop)}</small></td>
|
||
<td>{group.points.map((point) => <span key={point.company.id}><button className="research-company-name" onClick={() => onSelect(point.company.id)}>{point.company.name}</button>{" "}</span>)}<small>{group.stats.n < 3 ? "样本少于3,不能据此判断领域典型收入。" : "自选的小样本,须结合产品年龄、成本与留存评估。"}</small></td>
|
||
</tr>)}</tbody>)}</table></div>
|
||
</> : <p className="research-message">当前窗口和创立年份没有符合条件的 MRR 记录。</p>}
|
||
<details><summary>统计规则与解释</summary><p>每个产品只取符合条件的最新一条平台 MRR;币种为美元,要求有效数据日期、确定数值及支付来源。公司 ARR、估算、融资、目标和近30日收入均不进入这里。未知值不补零。</p><p>CV=样本标准差÷均值,标准差分母为 n−1;n=1 不算 CV。n=2 时均值必然等于中位数,不能据此认为均值有代表性。较大的均值/中位数差距、CV或移除最大值的变化,仅提示进一步核查样本构成。</p><p>支付商与平台的退款、试用、年付、币种及指标计算可能不同(<a href="https://trustmrr.com/faq" target="_blank" rel="noopener noreferrer">平台方法说明</a>)。不能将 MRR 与收款差异、单个高值或高离散度直接解释为财务造假。比较商业模式还需要毛利、获客成本、留存、人工交付和客户集中度。</p></details>
|
||
{sample.excluded.length > 0 && <details><summary>未纳入的 MRR 记录({sample.excluded.length})</summary><ul>{sample.excluded.map(({ observation, reason }) => <li key={observation.id}><button onClick={() => onSelect(observation.company)}>{observation.name}</button>:{usd(observation.value)} / 月,{observation.observedAt ?? "日期未知"};{reason}</li>)}</ul></details>}
|
||
</section>;
|
||
}
|