Files
endorphin-tabularium-docs/apps/docs/src/components/IncomeDistribution.tsx
T

76 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 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;
return <g key={group.key}>
<text x={8} y={y - 8} fontSize="12" fill="#202724">{categories.get(group.category)}</text>
<text x={8} y={y + 10} fontSize="10" fill="#637066">{group.provider} · {group.model} · n={group.stats.n}</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>圆点:产品观测 竖线:样本均值 菱形:样本中位数。按业务类别与支付来源分组,点击点或下表产品名可看原始证据。</figcaption>
</figure>
<div className="research-table-wrap"><table><caption>{metric}样本统计;金额按显示值计算,CV使用样本标准差</caption><thead><tr><th>类别 / 来源 / 模式</th><th>n</th><th>均值</th><th>中位数</th><th>均值/中位数</th><th>CV</th><th>去除最大值后的均值 / 下降幅度</th><th>产品与限制</th></tr></thead><tbody>{groups.map((group) => <tr key={group.key}>
<td>{categories.get(group.category)}<small>{group.provider} · {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>;
}