// ════════════════════════════════════════════════════════════════
// CorpusPage — реестр 3068 стартапов
// Hero + warm stat cards + filter bar + virtualized rows
// Layer B integration: hero micro-bars, row CategoryPills + SimDot + DateDot
// ════════════════════════════════════════════════════════════════
const { useState: useStateCorpus, useMemo: useMemoCorpus, useRef: useRefCorpus, useEffect: useEffectCorpus } = React;
function CorpusPage() {
const D = window.SDH_DATA;
const [niche, setNiche] = useStateCorpus('all');
const [source, setSource] = useStateCorpus('all');
const [q, setQ] = useStateCorpus('');
const [sort, setSort] = useStateCorpus('date'); // date | sim | name
const [openStartup, setOpenStartup] = useStateCorpus(null);
// close on Esc
useEffectCorpus(() => {
function onKey(e) { if (e.key === 'Escape') setOpenStartup(null); }
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const filtered = useMemoCorpus(() => {
let arr = D.corpus;
if (niche !== 'all') arr = arr.filter(s => s.niche === niche);
if (source !== 'all') arr = arr.filter(s => s.source === source);
if (q.trim()) {
const qq = q.trim().toLowerCase();
arr = arr.filter(s => s.name.toLowerCase().includes(qq) || s.desc.toLowerCase().includes(qq));
}
arr = arr.slice();
if (sort === 'date') arr.sort((a, b) => a.daysAgo - b.daysAgo);
else if (sort === 'sim') arr.sort((a, b) => b.similarity - a.similarity);
else arr.sort((a, b) => a.name.localeCompare(b.name));
return arr;
}, [niche, source, q, sort, D.corpus]);
// Stat cards (hero)
const heroStats = [
{ key: 'total', metric: 'startups', label: 'всего стартапов', value: D.corpus.length, hue: 'info', series: D.series.startups, delta: 12.4 },
{ key: 'week', metric: 'weekNew', label: 'за последние 7 дней', value: countNew(D.corpus, 7), hue: 'emerald', series: D.series.startups.slice(-7), delta: 8.1 },
{ key: 'class', metric: 'classified', label: 'классифицировано', value: D.series.classified[D.series.classified.length-1].value, hue: 'amber', series: D.series.classified, delta: 4.6 },
{ key: 'srcs', metric: 'sources', label: 'источников активно', value: D.series.sources[D.series.sources.length-1].value, hue: 'purple', series: D.series.sources, delta: 2.0 },
];
return (
{/* HERO */}
реестр · корпус данных
Полный реестр стартапов
Один источник данных для исследований Стартапзавода. Сегодня в базе{' '}
{D.corpus.length.toLocaleString('ru-RU')}
{' '}
компаний из {D.SOURCES.length} источников. Обновление ежедневно в 10:00 по МСК.
{/* 4 mini stat cards */}
{heroStats.map(s => {
const m = window.METRIC_DICT[s.metric];
return (
{s.label}
{s.value.toLocaleString('ru-RU')}
= 0 ? 'var(--success)' : 'var(--danger)',
}}>{s.delta >= 0 ? '+' : ''}{s.delta}%
);
})}
{/* FILTER BAR */}
фильтр
setNiche('all')}>
все ниши {D.corpus.length.toLocaleString('ru-RU')}
{D.NICHES.map(n => {
const count = D.corpus.filter(s => s.niche === n.id).length;
return (
setNiche(n.id)}>
{n.label} {count}
);
})}
>
}
right={
<>
setQ(e.target.value)}
placeholder="поиск по названию или описанию"
style={{
background: 'var(--surface-elevated)',
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-md)',
padding: '7px 12px 7px 30px',
fontSize: 13,
color: 'var(--text-primary)',
width: 240,
outline: 'none',
}}
/>
setSource(e.target.value)} style={{
background: 'var(--surface-elevated)',
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-md)',
padding: '7px 28px 7px 12px',
fontSize: 13,
color: 'var(--text-primary)',
appearance: 'none',
backgroundImage: `url("data:image/svg+xml;utf8, ")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 10px center',
}}>
все источники
{D.SOURCES.map(s => {s.label} )}
setSort(e.target.value)} style={{
background: 'var(--surface-elevated)',
border: '1px solid var(--border-default)',
borderRadius: 'var(--radius-md)',
padding: '7px 28px 7px 12px',
fontSize: 13,
color: 'var(--text-primary)',
appearance: 'none',
backgroundImage: `url("data:image/svg+xml;utf8, ")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 10px center',
}}>
сортировать: дата
сортировать: близость
сортировать: название
>
}
/>
{/* RESULTS HEAD */}
{filtered.length.toLocaleString('ru-RU')}
{' '}результатов
горячие клавиши: / поиск · j /k навигация
{/* VIRTUALIZED LIST */}
{openStartup && setOpenStartup(null)} />}
);
}
function countNew(corpus, days) {
return corpus.filter(s => s.daysAgo <= days).length;
}
// ── VirtualRows ──────────────────────────────────────────────
function VirtualRows({ items, rowH = 56, height = 640, onOpen }) {
const [scroll, setScroll] = useStateCorpus(0);
const ref = useRefCorpus(null);
const totalH = items.length * rowH;
const visibleCount = Math.ceil(height / rowH) + 6;
const startIdx = Math.max(0, Math.floor(scroll / rowH) - 3);
const endIdx = Math.min(items.length, startIdx + visibleCount);
return (
{/* table header */}
стартап
ниша
источник
появился
близость
гео
setScroll(e.target.scrollTop)}
style={{ height, overflowY: 'auto', position: 'relative' }}
>
{items.slice(startIdx, endIdx).map((s, i) => (
))}
);
}
function CorpusRow({ startup, y, h, onOpen }) {
const s = startup;
return (
onOpen && onOpen(s)} style={{
position: 'absolute', top: y, left: 0, right: 0, height: h,
display: 'grid',
gridTemplateColumns: '1fr 130px 130px 110px 80px 70px',
gap: 12,
padding: '0 16px',
alignItems: 'center',
borderBottom: '1px solid var(--border-subtle)',
cursor: 'pointer',
transition: 'background .15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-sunken)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{s.nicheLabel}
{s.sourceLabel}
{s.daysAgo === 0 ? 'сегодня' : `${s.daysAgo}д`}
{s.geo}
);
}
window.CorpusPage = CorpusPage;