// ════════════════════════════════════════════════════════════════ // ClusterDetailPage — drill-down при клике "Открыть стартапы кластера" // Контекст кластера + мини-аналитика + список стартапов // ════════════════════════════════════════════════════════════════ const { useMemo: useMemoCD, useState: useStateCD } = React; function ClusterDetailPage({ cluster, onBack, onOpenStartup }) { const D = window.SDH_DATA; const tintHue = cluster.momentum === 'rising' ? 'emerald' : cluster.momentum === 'cooling' ? 'rose' : 'info'; // Deterministic subset of corpus belonging to this cluster const clusterStartups = useMemoCD(() => { // seed from cluster.id like "t1" const seedNum = cluster.id.charCodeAt(1) * 7 + (cluster.id.length > 2 ? cluster.id.charCodeAt(2) : 0); const rng = (n) => ((seedNum * 9301 + n * 49297) % 233280) / 233280; // pick `cluster.count` startups from corpus by stable hash const ranked = D.corpus .map((s, i) => ({ s, key: rng(i) })) .sort((a, b) => a.key - b.key) .slice(0, cluster.count) .map(({ s }, i) => { // bias similarity higher in cluster return { ...s, similarity: Math.min(1, s.similarity * 0.5 + 0.45 + rng(i + 999) * 0.15) }; }); return ranked; }, [cluster.id, cluster.count]); // Stats const newThisWeek = clusterStartups.filter(s => s.daysAgo <= 7).length; const avgSim = clusterStartups.reduce((sum, s) => sum + s.similarity, 0) / Math.max(1, clusterStartups.length); const fundedCount = clusterStartups.filter(s => s.funding_usd).length; // Source distribution const sourceDist = useMemoCD(() => { const map = {}; clusterStartups.forEach(s => { map[s.source] = map[s.source] || { id: s.source, label: s.sourceLabel, hue: s.sourceHue, count: 0 }; map[s.source].count += 1; }); return Object.values(map).sort((a, b) => b.count - a.count); }, [clusterStartups]); // Geo distribution const geoDist = useMemoCD(() => { const map = {}; clusterStartups.forEach(s => { map[s.geo] = (map[s.geo] || 0) + 1; }); return Object.entries(map) .map(([geo, count]) => ({ geo, count })) .sort((a, b) => b.count - a.count) .slice(0, 6); }, [clusterStartups]); // Sort & filter state for the list const [sort, setSort] = useStateCD('sim'); const [verdict, setVerdict] = useStateCD('all'); // all | high | mid | low const filteredStartups = useMemoCD(() => { let arr = clusterStartups; if (verdict === 'high') arr = arr.filter(s => s.similarity >= 0.75); else if (verdict === 'mid') arr = arr.filter(s => s.similarity >= 0.5 && s.similarity < 0.75); else if (verdict === 'low') arr = arr.filter(s => s.similarity < 0.5); arr = arr.slice(); if (sort === 'sim') arr.sort((a, b) => b.similarity - a.similarity); else if (sort === 'date') arr.sort((a, b) => a.daysAgo - b.daysAgo); else if (sort === 'name') arr.sort((a, b) => a.name.localeCompare(b.name)); else if (sort === 'funded') arr.sort((a, b) => (b.funding_usd || 0) - (a.funding_usd || 0)); return arr; }, [clusterStartups, sort, verdict]); const high = clusterStartups.filter(s => s.similarity >= 0.75).length; const mid = clusterStartups.filter(s => s.similarity >= 0.5 && s.similarity < 0.75).length; const low = clusterStartups.filter(s => s.similarity < 0.5).length; return (
{/* HERO with tint */}
{/* Breadcrumb */}
кластер {cluster.id.toUpperCase()}
{/* Title row */}
семантический кластер

{renderTitleWithItalic(cluster.title)}

{cluster.recommendation || 'Сила сигнала растёт. Следующий шаг: прототип на трёх пользовательских сценариях.'}

{/* Side stat tile */}
{cluster.count} стартапов
динамика за 30 дней
{/* Stat strip */}
= 0.75 ? 'emerald' : avgSim >= 0.5 ? 'amber' : 'rose'} /> = 0 ? '+' : ''}${Math.round(cluster.growth * 100)}%`} hue={cluster.growth > 0.05 ? 'emerald' : cluster.growth < -0.05 ? 'rose' : 'info'} />
{/* Breakdown row */}
{/* Sources */}
{sourceDist.map(s => ( ))}
{/* Geo */}
{geoDist.map(g => ( ))}
{/* Verdict mix */}
{high > 0 &&
} {mid > 0 &&
} {low > 0 &&
}
{/* FILTER */} фильтр близости } right={ } /> {/* LIST */}
{filteredStartups.length} стартапов в выборке
стартап
ниша
источник
появился
близость
гео
{filteredStartups.length === 0 && (

В выборке нет стартапов с выбранными фильтрами.

)} {filteredStartups.map(s => ( ))}
); } // ── helpers ────────────────────────────────────────────────────── function renderTitleWithItalic(title) { // Italicize first noun-ish word for editorial accent const words = title.split(' '); if (words.length < 2) return title; return ( {words[0]}{' '} {words[1]} {words.length > 2 ? ' ' + words.slice(2).join(' ') : ''} ); } function MiniStat({ label, value, hue, metric }) { const body = (
{label}
{value}
); return (
{metric ? (
{body}
) : body}
); } function Panel({ title, subtitle, children, help }) { return (
{help ? {title} : title}
{subtitle &&
{subtitle}
} {children}
); } function BreakdownRow({ label, count, total, hue, mono }) { const pct = (count / total) * 100; return (
{label}
{count} · {Math.round(pct)}%
); } function VerdictCell({ hue, label, count }) { return (
{label}
{count}
); } window.ClusterDetailPage = ClusterDetailPage;