// ════════════════════════════════════════════════════════════════ // InfoLabel — лейбл с всплывающей справкой о метрике (Хармози-style). // Hover/focus на иконке "?" → tooltip с 3 секциями (title / что / зачем). // // Fix log 2026-05-23: // B1: pointerEvents=auto + grace period 200ms → можно навести мышь // внутрь тултипа и прочитать длинный текст не теряя его // B2: hide() debounced (200ms) → дрогнувшая мышь не закрывает // B3: scroll listener throttled (только если scroll > 50px от open-точки) // B4: tooltip ширина/высота measured после render → правильная позиция // B5: viewport boundary clamp слева+справа+сверху+снизу // B6: добавлена стрелка-указатель на trigger // B7: tabIndex=0 → только на ? svg (не вокруг всего children) // B8: SVG xmlns + лучше rendering ? символа (replaced text → path) // ════════════════════════════════════════════════════════════════ const { useState: useStateInfo, useRef: useRefInfo, useEffect: useEffectInfo, useCallback: useCallbackInfo } = React; const TIP_WIDTH = 320; const TIP_GAP = 10; // расстояние между trigger и тултипом const GRACE_MS = 220; // grace period для close const SCROLL_THRESHOLD = 80; // px scroll до auto-close function InfoLabel({ title, what, why, children, asInline = true }) { const [open, setOpen] = useStateInfo(false); const [pos, setPos] = useStateInfo(null); const triggerRef = useRefInfo(null); const tooltipRef = useRefInfo(null); const closeTimerRef = useRefInfo(null); const openScrollYRef = useRefInfo(0); // ── Open helper ─────────────────────────────────────────── const computePos = useCallbackInfo(() => { if (!triggerRef.current) return null; const r = triggerRef.current.getBoundingClientRect(); // Estimate tooltip height — будет refined после render через effect. const tipH = tooltipRef.current ? tooltipRef.current.offsetHeight : 200; const tipW = tooltipRef.current ? tooltipRef.current.offsetWidth : TIP_WIDTH; const spaceBelow = window.innerHeight - r.bottom - TIP_GAP; const spaceAbove = r.top - TIP_GAP; const above = tipH > spaceBelow && spaceAbove > spaceBelow; // Horizontal: trigger center, clamp в [8, viewport-tipW-8] const triggerCenter = r.left + r.width / 2; let left = triggerCenter - tipW / 2; left = Math.max(8, Math.min(left, window.innerWidth - tipW - 8)); const top = above ? r.top - TIP_GAP - tipH : r.bottom + TIP_GAP; // Стрелка указывает на центр trigger относительно tooltip left edge const arrowLeft = Math.max(14, Math.min(triggerCenter - left, tipW - 14)); return { top, left, above, arrowLeft }; }, []); function show() { if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } openScrollYRef.current = window.scrollY; setOpen(true); // pos считается в effect после render (нужен offsetHeight tooltip'а) } function hide() { if (closeTimerRef.current) clearTimeout(closeTimerRef.current); closeTimerRef.current = setTimeout(() => { setOpen(false); setPos(null); }, GRACE_MS); } function cancelHide() { if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } } // ── После render tooltip'а — пересчитать позицию с реальной высотой useEffectInfo(() => { if (!open) return; // requestAnimationFrame чтобы DOM успел отрендерить const id = requestAnimationFrame(() => { const p = computePos(); if (p) setPos(p); }); return () => cancelAnimationFrame(id); }, [open, computePos]); // ── Close on significant scroll/resize/escape ───────────── useEffectInfo(() => { if (!open) return; function onScroll() { if (Math.abs(window.scrollY - openScrollYRef.current) > SCROLL_THRESHOLD) { setOpen(false); setPos(null); } else { // mild scroll — пересчитываем позицию (триггер уехал) const p = computePos(); if (p) setPos(p); } } function onResize() { setOpen(false); setPos(null); } function onKey(e) { if (e.key === 'Escape') { setOpen(false); setPos(null); } } window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onResize); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onResize); window.removeEventListener('keydown', onKey); }; }, [open, computePos]); // ── Cleanup timer on unmount ────────────────────────────── useEffectInfo(() => () => { if (closeTimerRef.current) clearTimeout(closeTimerRef.current); }, []); return ( {children} {/* Question mark as path для consistent рендеринга в Firefox */} {open && (
{/* Arrow указатель на trigger */} {pos && (
)}
); } // ════════ Центральный словарь метрик ════════ const METRIC_DICT = { startups: { title: 'Стартапов в корпусе', what: 'Размер базы стартапов на текущий момент. Каждая запись прошла дедупликацию по домену и нормализацию названия.', why: 'Показывает охват наблюдения. Если число замедляется при росте источников — значит сигналы повторяются и нужно расширять воронку.', }, classified: { title: 'Классифицировано', what: 'Доля стартапов, для которых модель распознала нишу с уверенностью выше 0.7.', why: 'Метрика полноты разметки. Низкое значение означает, что аналитика по нишам строится на неполных данных.', }, sources: { title: 'Источников активно', what: 'Сколько внешних источников отдают данные за последние 24 часа.', why: 'Стабильность поставки. Падение числа источников = риск пропустить тренд.', }, ideas: { title: 'Идей в работе', what: 'Количество идей Стартапзавода, по которым ведётся активное исследование.', why: 'Объём фокус-портфеля Стартапзавода. Влияет на нагрузку команды синтеза.', }, weekNew: { title: 'За последние 7 дней', what: 'Стартапы, впервые появившиеся в корпусе за прошедшие семь дней.', why: 'Импульс пополнения. Резкий рост указывает на разогрев конкретной ниши, который стоит проверить отдельно.', }, // Corpus columns niche: { title: 'Ниша', what: 'Сегмент рынка, к которому отнесён стартап классификатором.', why: 'Группировка по тематике позволяет видеть, где растёт конкуренция, и где идеи Стартапзавода вступают в плотный рынок.', }, source: { title: 'Источник', what: 'Откуда запись попала в корпус: каталог, агрегатор или внутренний скаут.', why: 'Помогает оценить доверие к сигналу. Y Combinator и PitchBook дают разную глубину данных, чем Product Hunt.', }, firstSeen: { title: 'Появился', what: 'Дата первого упоминания стартапа в любом из источников.', why: 'Свежесть сигнала. Молодые записи требуют подтверждения, старые — переоценки актуальности.', }, similarity: { title: 'Близость', what: 'Семантическая релевантность стартапа к идеям Стартапзавода (0 до 1). Цвет точки: красный до 0.50, янтарный 0.50 до 0.75, зелёный выше 0.75.', why: 'Высокая близость = кандидат на углублённый разбор или прямой конкурент идеи. Низкая — контекстная единица для рынка.', }, geo: { title: 'География', what: 'Страна, где зарегистрирована штаб-квартира стартапа по данным источника.', why: 'Помогает увидеть концентрацию направления (Edge AI в США vs Биотех в ЕС) и оценить регуляторный контекст.', }, // Trends scatter axes axisGrowth: { title: 'Импульс кластера', what: 'Темп прироста числа стартапов в кластере по сравнению с предыдущей неделей. Шкала от снижения слева до роста справа.', why: 'Точки в правой части ускоряются — там надо быть первыми. Левая часть — гипотезы, которые остывают.', }, axisVolume: { title: 'Объём кластера', what: 'Число стартапов, отнесённых к кластеру на текущий момент. Размер точки масштабируется пропорционально.', why: 'Большой объём с низким импульсом = насыщенный рынок. Малый объём с высоким импульсом = окно возможностей.', }, // Ideas card competitors: { title: 'Конкурентный ландшафт', what: 'Стартапы из корпуса, выполняющие задачу, близкую к идее. Распределение вердиктов: рискованно / требует валидации / жизнеспособно.', why: 'Понятно, насколько идея уже занята и в какой стадии зрелости конкуренты.', }, linked: { title: 'Связанные идеи', what: 'Другие идеи Стартапзавода, пересекающиеся по методологии или нише.', why: 'Подсказка для команд: одна и та же гипотеза может закрываться разными протоколами, лучше работать связкой.', }, // Cluster page cluster_filterSim: { title: 'Фильтр близости', what: 'Прячет стартапы, у которых релевантность к идее вне выбранного диапазона. Близость считается как косинусное расстояние эмбеддинга описания стартапа от центра кластера идей.', why: 'Не листайте 47 строк глазами. Нажали ≥0.75 — видите прямых конкурентов и понимаете, насколько занят рынок. Нажали 0.50–0.74 — смотрите на смежников, у которых можно подсмотреть бизнес-модель и каналы. Нажали <0.50 — это контекст и фон, нужен перед питчем. Каждый клик экономит 10 минут на ручном сравнении.', }, cluster_sourcesPanel: { title: 'Источники сигнала', what: 'Распределение стартапов кластера по поставщикам данных: каталоги, инвестиционные базы, скауты.', why: 'Если 80% сигнала пришло из одного источника, у вас не картина рынка, а зеркало одного агрегатора. Расширьте воронку источников до того, как делать выводы. Несколько источников с одинаковым сигналом — это уже подтверждение.', }, cluster_geoPanel: { title: 'География кластера', what: 'Топ-6 стран, где зарегистрированы стартапы кластера.', why: 'Концентрация в одной географии означает регуляторный режим, культурный паттерн или сильную локальную школу. Делаете похожее в другой стране — сначала разберитесь почему первые там, а не у вас. Это сэкономит шесть месяцев.', }, cluster_verdictPanel: { title: 'Распределение близости', what: 'Сколько стартапов кластера попадает в каждый диапазон релевантности к идеям Стартапзавода.', why: 'Если в зелёной зоне (≥0.75) больше 60% — рынок плотный, нужен серьёзный диффер. Если 0 — вы либо первые, либо никому это не нужно. Узнайте, что из двух, через 5 углублённых интервью.', }, }; window.InfoLabel = InfoLabel; window.METRIC_DICT = METRIC_DICT;