function JourneyMap() {
  // ─── Data ──────────────────────────────────────────────────────────────
  const COLUMNS = [
    { n: '01', t: 'ИСТОЧНИКИ', nodes: [
      { id: 'tickets',  label: 'Тикеты',                  icon: 'chat' },
      { id: 'calls',    label: 'Звонки',                  icon: 'phone' },
      { id: 'surveys',  label: 'Опросы',                  icon: 'doc' },
      { id: 'usage',    label: 'Продуктовые события',     icon: 'bar' },
      { id: 'social',   label: 'Соцсети',                 icon: 'bell' },
      { id: 'reviews',  label: 'Отзывы в сторах',         icon: 'star' },
    ]},
    { n: '02', t: 'АНАЛИЗ', nodes: [
      { id: 'taxonomy',  label: 'Темы и таксономия', icon: 'grid' },
      { id: 'sentiment', label: 'Сентимент',         icon: 'smile' },
      { id: 'trends',    label: 'Тренды',            icon: 'spark' },
      { id: 'reasoning', label: 'ИИ-обоснование',    icon: 'bulb' },
    ]},
    { n: '03', t: 'КОНТЕКСТ', nodes: [
      { id: 'feature',   label: 'Продуктовая фича', icon: 'tag' },
      { id: 'segment',   label: 'Сегмент',          icon: 'circle' },
      { id: 'usecase',   label: 'Сценарий',         icon: 'gear' },
      { id: 'lifecycle', label: 'Стадия LTV',       icon: 'loop' },
      { id: 'plan',      label: 'Тариф',            icon: 'rows' },
    ]},
    { n: '04', t: 'ИСХОДЫ', nodes: [
      { id: 'retention', label: 'Удержание',           icon: 'loop' },
      { id: 'revenue',   label: 'Выручка',             icon: 'flag' },
      { id: 'roadmap',   label: 'Приоритет роадмапа',  icon: 'arrow' },
      { id: 'adoption',  label: 'Принятие фичи',       icon: 'flag' },
      { id: 'winrate',   label: 'Win rate',            icon: 'cup' },
    ]},
  ];
  const COL_COLORS = ['#f0c674', '#e88a9a', '#9fcf7a', '#7fd4d4'];

  // Edges between adjacent columns. Many-to-many — каждый узел тянет за собой
  // несколько цепей в следующую колонку.
  const EDGES = [
    // 01 → 02  — что из источника естественно ложится в какой тип анализа
    ['tickets',  'taxonomy'],  ['tickets',  'sentiment'], ['tickets',  'trends'],
    ['calls',    'taxonomy'],  ['calls',    'sentiment'], ['calls',    'reasoning'],
    ['surveys',  'sentiment'], ['surveys',  'taxonomy'],  ['surveys',  'reasoning'],
    ['usage',    'trends'],    ['usage',    'reasoning'],
    ['social',   'sentiment'], ['social',   'trends'],    ['social',   'taxonomy'],
    ['reviews',  'sentiment'], ['reviews',  'taxonomy'],  ['reviews',  'trends'],
    // 02 → 03  — анализ привязывается к продуктовому контексту
    ['taxonomy',  'feature'],   ['taxonomy',  'usecase'],
    ['sentiment', 'segment'],   ['sentiment', 'feature'],  ['sentiment', 'lifecycle'],
    ['trends',    'feature'],   ['trends',    'usecase'],  ['trends',    'plan'],
    ['reasoning', 'segment'],   ['reasoning', 'usecase'],  ['reasoning', 'lifecycle'], ['reasoning', 'feature'],
    // 03 → 04  — контекст превращается в бизнес-исход
    ['feature',   'roadmap'],   ['feature',   'adoption'], ['feature',   'revenue'],
    ['segment',   'retention'], ['segment',   'winrate'],  ['segment',   'revenue'],
    ['usecase',   'adoption'],  ['usecase',   'roadmap'],  ['usecase',   'winrate'],
    ['lifecycle', 'retention'], ['lifecycle', 'revenue'],
    ['plan',      'revenue'],   ['plan',      'retention'], ['plan',     'winrate'],
  ];

  // Per-node short content (used in the 4 cards below).
  const NODE_CONTENT = {
    // Sources
    tickets:  { headline: '12 480 / нед',  sub: 'Поток тикетов из Intercom, Zendesk и почты',
                samples: ['«Не приходит код в SMS»', '«Списали дважды за подписку»', '«Куда пропала корзина?»'] },
    calls:    { headline: '3 200 минут',   sub: 'Расшифровки звонков в поддержку и продажи',
                samples: ['Жалоба на ожидание оператора', 'Запрос корпоративного тарифа', 'Отказ от продления'] },
    surveys:  { headline: 'NPS 47',        sub: 'CSAT и NPS, открытые комментарии',
                samples: ['«Стало быстрее, но дороже»', '«Поиск наконец-то работает»', '«Хочу тёмную тему»'] },
    usage:    { headline: '8.4 млн событий', sub: 'Продуктовая аналитика из Amplitude и Mixpanel',
                samples: ['Падение конверсии на шаге оплаты', 'Дроп после онбординга', 'Спайк ошибок 3-D Secure'] },
    social:   { headline: '1 840 упоминаний', sub: 'Telegram, VK, Reddit, форумы',
                samples: ['Тред про новый поиск', 'Жалоба на лимиты Free', 'Запрос API'] },
    reviews:  { headline: '4.6 ★',         sub: 'App Store, Google Play, маркетплейсы',
                samples: ['«Разлогинивает каждый день»', '«Дизайн стал понятнее»', '«Реклама внутри Pro»'] },

    // Analysis
    taxonomy:  { headline: '842 темы',  sub: 'Авто-построенная таксономия тем, без ручной разметки',
                 samples: ['Авторизация и сессии', 'Оплата и подписки', 'Поиск и каталог', 'Скорость работы'] },
    sentiment: { headline: '−12 пп',    sub: 'Сентимент на уровне темы, фичи и клиента',
                 samples: ['Оплата ↓ 22%', 'Новый поиск ↑ 18%', 'Поддержка ↓ 9%'] },
    trends:    { headline: '+38% / нед', sub: 'Аномалии и тренды по темам в реальном времени',
                 samples: ['Спайк «Apple Pay не проходит»', 'Рост «тёмная тема»', 'Падение упоминаний багов входа'] },
    reasoning: { headline: 'GPT-class',  sub: 'ИИ-обоснование с цитатами и контекстом',
                 samples: ['Почему отток вырос в августе', 'Что общего у 3-х негативных тем', 'Какие сегменты затронуты'] },

    // Context
    feature:   { headline: '64 фичи',     sub: 'Привязка отзыва к фиче из роадмапа',
                 samples: ['Касса 2.0', 'Поиск 2.0', 'Тёмная тема', 'Голосовая поддержка'] },
    segment:   { headline: '12 сегментов', sub: 'Сегменты из CRM: бизнес, гео, поведение',
                 samples: ['B2B Enterprise', 'Премиум · iPhone 13/14', 'Активные ежедневно'] },
    usecase:   { headline: '38 сценариев', sub: 'Типичные пути и работы клиента',
                 samples: ['Трекинг тренировок', 'Срочный инцидент', 'Поиск по каталогу'] },
    lifecycle: { headline: '6 стадий',    sub: 'Стадия жизненного цикла клиента',
                 samples: ['Ранняя активация', 'Возврат', 'Расширение', 'Риск оттока'] },
    plan:      { headline: '5 тарифов',   sub: 'Free, Plus, Pro, Премиум, Enterprise',
                 samples: ['Премиум годовой', 'Pro месячный', 'Free → Plus'] },

    // Outcomes
    retention: { headline: '−4.2%',       sub: 'Влияние тем на удержание когорт',
                 samples: ['Разлогин на iOS', 'Ожидание поддержки B2B', 'Лимиты Free'] },
    revenue:   { headline: '1.8 млн ₽/мес', sub: 'Упущенная выручка по темам',
                 samples: ['Apple Pay не проходит', 'Падение конверсии на оплате'] },
    roadmap:   { headline: 'P1 · Q3',     sub: 'Приоритет роадмапа на основе сигналов',
                 samples: ['Тёмная тема', 'Голосовая поддержка', 'Поиск 2.0 v2'] },
    adoption:  { headline: '+38% / нед',  sub: 'Принятие новых фич по упоминаниям',
                 samples: ['Поиск 2.0', 'Виджет статистики'] },
    winrate:   { headline: '−12 пп',      sub: 'Влияние тем на win rate сделок',
                 samples: ['Ожидание поддержки B2B', 'Лимиты API на Pro'] },
  };

  // Дефолтные кейсы по узлам — общий fallback. Поверх накладываются индустриальные
  // оверрайды (INDUSTRY_CASES ниже) — карточки под картой подменяются с crossfade'ом.
  const DEFAULT_CASES = {
    // ── 01 ИСТОЧНИКИ ──────────────────────────────────────────────────
    tickets: {
      signal: { kicker: 'IN-APP CHAT · ИНТЕРКОМ', title: 'Жалоба в поддержку',
        quote: '«Открыл приложение — корзина пустая. Положил 4 товара час назад, теперь всё пропало.»',
        from: '@kirill_99', when: 'Вт · 18:42' },
      intel: { conf: 0.93, title: 'Корзина теряется на iOS после перезапуска',
        sentiment: { label: 'негативный', pct: 82, tone: 'neg' },
        chips: ['корзина', 'ios', 'сессия'] },
      context: { kicker: 'ПОВТОРНЫЕ ПОКУПАТЕЛИ', title: 'iOS · 25–44',
        rows: [['Фича', 'Корзина'], ['Сценарий', 'Покупка в 2 захода'], ['Стадия LTV', 'Активные']] },
      outcomes: { kicker: 'УДЕРЖАНИЕ', headline: '−2.8% retention',
        driver: 'тикеты · 612 жалоб / нед' },
    },
    calls: {
      signal: { kicker: 'РАСШИФРОВКА · ПОДДЕРЖКА', title: 'Звонок, 12 минут',
        quote: '«Жду оператора уже 18 минут, музыка в трубке. Заявка с прошлой недели висит без ответа.»',
        from: 'Клиент #4821', when: 'Пт · 14:23' },
      intel: { conf: 0.91, title: 'Долгое ожидание оператора → риск оттока',
        sentiment: { label: 'негативный', pct: 88, tone: 'neg' },
        chips: ['ожидание', 'sla', 'поддержка'] },
      context: { kicker: 'B2B ENTERPRISE', title: 'Корпоративные · средний чек 280K ₽',
        rows: [['Фича', 'Голосовая поддержка'], ['Сценарий', 'Срочный инцидент'], ['Сегмент', 'Enterprise']] },
      outcomes: { kicker: 'WIN RATE ПРОДЛЕНИЯ', headline: '−12 пп',
        driver: 'звонки · 84 клиента ждали > 15 мин' },
    },
    surveys: {
      signal: { kicker: 'Q1 NPS · ОПРОС', title: 'NPS-комментарий, 9/10',
        quote: '«Купила годовую подписку после того, как подруга показала офлайн-режим — экономит мне поездку каждый день.»',
        from: '@maria.k', when: 'Ср · 09:38' },
      intel: { conf: 0.95, title: 'Офлайн-режим как драйвер апгрейда на годовую',
        sentiment: { label: 'позитивный', pct: 87, tone: 'pos' },
        chips: ['офлайн', 'word-of-mouth', 'апгрейд'] },
      context: { kicker: 'ГОДОВЫЕ ПРЕМИУМ', title: 'Регулярные пользователи · 30–44',
        rows: [['Фича', 'Офлайн-режим'], ['Сценарий', 'Поездка на работу'], ['Стадия LTV', 'Power user']] },
      outcomes: { kicker: 'ПРИНЯТИЕ ФИЧИ', headline: '42% за 30 дней',
        driver: 'офлайн-режим · ↑18 пп' },
    },
    usage: {
      signal: { kicker: 'ПРОДУКТОВЫЕ СОБЫТИЯ · MIXPANEL', title: 'Падение конверсии в кассе',
        quote: '«14% сессий бросают корзину на этапе ввода карты — выше нормы в 2 раза за последние 7 дней.»',
        from: 'analytics.events', when: 'Окно: 7 дней' },
      intel: { conf: 0.93, title: 'Аномалия: рост ошибок 3-D Secure после релиза 4.12',
        sentiment: { label: 'аномалия', pct: 71, tone: 'neu' },
        chips: ['checkout', '3-d-secure', 'релиз-4.12'] },
      context: { kicker: 'ВСЕ ПЛАТЯЩИЕ · iOS', title: 'Шаг «Ввод карты»',
        rows: [['Фича', 'Касса'], ['Сценарий', 'Покупка'], ['Сегмент', 'Платящие']] },
      outcomes: { kicker: 'УПУЩЕННАЯ ВЫРУЧКА', headline: '2.4 млн ₽ / нед',
        driver: 'падение конверсии шага 4' },
    },
    social: {
      signal: { kicker: 'TELEGRAM · КОМЬЮНИТИ', title: 'Сообщение в чате',
        quote: '«Глаза устают вечером, очень не хватает тёмной темы. Готов платить за такую опцию.»',
        from: '@nightowl', when: 'Вт · 23:47' },
      intel: { conf: 0.89, title: 'ИИ-обоснование: 320 упоминаний за квартал',
        sentiment: { label: 'запрос фичи', pct: 62, tone: 'neu' },
        chips: ['тёмная-тема', 'ночной-режим', 'a11y'] },
      context: { kicker: 'ВЕЧЕРНИЕ СЕССИИ', title: 'Сессии после 22:00 · 42% базы',
        rows: [['Фича', 'Тема оформления'], ['Сценарий', 'Чтение перед сном'], ['Стадия LTV', 'Активные']] },
      outcomes: { kicker: 'ПРИОРИТЕТ РОАДМАПА', headline: 'P1 · Q3',
        driver: '3-й по объёму запрос фичи' },
    },
    reviews: {
      signal: { kicker: 'APP STORE · 1★', title: 'Отзыв в App Store',
        quote: '«Приложение разлогинивает каждый раз при закрытии. На этой неделе потеряла свой стрик дважды.»',
        from: '@sunshine_42', when: 'Ср · 20:14' },
      intel: { conf: 0.92, title: 'Сессии авторизации падают при закрытии приложения',
        sentiment: { label: 'негативный', pct: 78, tone: 'neg' },
        chips: ['авторизация', 'сессии', 'стрики'] },
      context: { kicker: 'PLUS', title: 'Активны ежедневно · 25–34',
        rows: [['Фича', 'Вход и сессии'], ['Сценарий', 'Трекинг тренировок'], ['Стадия LTV', 'Ранняя активация']] },
      outcomes: { kicker: 'УДЕРЖАНИЕ', headline: '−4.2% retention',
        driver: 'сторы · 480 отзывов на одну тему' },
    },

    // ── 02 АНАЛИЗ ─────────────────────────────────────────────────────
    taxonomy: {
      signal: { kicker: 'СВОДНОЕ · 12 480 ТИКЕТОВ', title: 'Автотаксономия за неделю',
        quote: '«842 темы построены без ручной разметки. Топ-5 драйверов: оплата, авторизация, поиск, скорость, корзина.»',
        from: 'ai.taxonomy', when: 'Окно: 7 дней' },
      intel: { conf: 0.96, title: 'Темы и подтемы привязаны к 64 фичам автоматически',
        sentiment: { label: 'устойчивая', pct: 96, tone: 'pos' },
        chips: ['авторизация', 'оплата', 'поиск', 'корзина'] },
      context: { kicker: 'ВСЯ БАЗА', title: 'Все платящие · все платформы',
        rows: [['Фича', '64 связанных'], ['Сценарий', '38 активных'], ['Сегмент', 'Все']] },
      outcomes: { kicker: 'ВРЕМЯ ДО ИНСАЙТА', headline: '−74%',
        driver: 'автоматическая разметка vs ручная' },
    },
    sentiment: {
      signal: { kicker: 'СВОДНОЕ · 5 ИСТОЧНИКОВ', title: 'Сентимент по теме «Оплата»',
        quote: '«За 14 дней доля негативных упоминаний по оплате выросла с 11% до 33%. Основной всплеск — клиенты Премиум на iOS.»',
        from: 'ai.sentiment', when: 'Окно: 14 дней' },
      intel: { conf: 0.94, title: 'Резкое смещение сентимента в негативную зону',
        sentiment: { label: 'негативный', pct: 78, tone: 'neg' },
        chips: ['оплата', 'премиум', 'ios', 'apple-pay'] },
      context: { kicker: 'ПРЕМИУМ', title: 'Премиум · iPhone 13/14',
        rows: [['Фича', 'Оплата картой'], ['Сценарий', 'Продление подписки'], ['Тариф', 'Премиум годовой']] },
      outcomes: { kicker: 'УПУЩЕННАЯ ВЫРУЧКА', headline: '1.8 млн ₽ / мес',
        driver: 'сентимент · 3 200 затронутых' },
    },
    trends: {
      signal: { kicker: 'АНОМАЛИЯ · РЕАЛЬНОЕ ВРЕМЯ', title: 'Спайк темы «Apple Pay не проходит»',
        quote: '«За 48 часов рост упоминаний на 6×. Эпицентр — клиенты на iOS 17.4 с включённым 3-D Secure.»',
        from: 'ai.trends', when: 'Окно: 48 часов' },
      intel: { conf: 0.97, title: 'Свежая аномалия с привязкой к версии ОС и релизу 4.12',
        sentiment: { label: 'рост негатива', pct: 91, tone: 'neg' },
        chips: ['apple-pay', 'ios-17.4', 'релиз-4.12'] },
      context: { kicker: 'ПРЕМИУМ · iPhone', title: 'Премиум на iOS 17.4',
        rows: [['Фича', 'Apple Pay'], ['Сценарий', 'Продление'], ['Сегмент', 'Премиум']] },
      outcomes: { kicker: 'ВЫРУЧКА · РИСК', headline: '1.8 млн ₽ / мес',
        driver: 'тренд · 320 жалоб за 48 ч' },
    },
    reasoning: {
      signal: { kicker: 'ИИ-ОБОСНОВАНИЕ', title: 'Почему отток вырос в августе',
        quote: '«3 темы объясняют 71% оттока: разлогин на iOS, ожидание оператора в B2B, лимиты Free. У всех общая черта — повторяемость в одной сессии.»',
        from: 'ai.reasoning', when: 'Окно: 30 дней' },
      intel: { conf: 0.90, title: 'Сжатое объяснение причин с цитатами клиентов',
        sentiment: { label: 'смешанный', pct: 64, tone: 'neu' },
        chips: ['отток', 'разлогин', 'sla', 'лимиты'] },
      context: { kicker: 'РИСК ОТТОКА', title: 'Все сегменты · все платформы',
        rows: [['Фича', '3 темы'], ['Сценарий', 'Отказ от продления'], ['Стадия LTV', 'Риск оттока']] },
      outcomes: { kicker: 'ПРИОРИТЕТ РОАДМАПА', headline: 'P1 · P0 · P1',
        driver: 'ИИ-обоснование · 3 темы → 71% оттока' },
    },

    // ── 03 КОНТЕКСТ ───────────────────────────────────────────────────
    feature: {
      signal: { kicker: 'СВОДНОЕ · ФИЧА «ПОИСК 2.0»', title: 'Голос клиента по фиче',
        quote: '«Новый поиск — огонь, наконец-то находит товары по неточному запросу. Раньше приходилось перебирать вручную.»',
        from: '@marina.v', when: 'Пн · 09:31' },
      intel: { conf: 0.96, title: 'Поиск 2.0: рост позитивных упоминаний на 4×',
        sentiment: { label: 'позитивный', pct: 91, tone: 'pos' },
        chips: ['поиск', 'нечёткий-запрос', 'ux'] },
      context: { kicker: 'АКТИВНЫЕ ПОКУПАТЕЛИ', title: 'Все платформы · 18–55',
        rows: [['Фича', 'Поиск 2.0'], ['Сценарий', 'Поиск по каталогу'], ['Стадия LTV', 'Возврат покупателя']] },
      outcomes: { kicker: 'ПРИНЯТИЕ ФИЧИ', headline: '+38% / нед',
        driver: 'поиск 2.0 · 210 упоминаний' },
    },
    segment: {
      signal: { kicker: 'СВОДНОЕ · СЕГМЕНТ B2B', title: 'Голос B2B-клиентов',
        quote: '«Срочный инцидент — а оператор только в рабочие часы. В договоре написано 24/7, по факту ждём до утра.»',
        from: 'CRM · 84 клиента', when: 'Окно: 30 дней' },
      intel: { conf: 0.92, title: 'B2B: жалобы на SLA выросли x2 квартал к кварталу',
        sentiment: { label: 'негативный', pct: 84, tone: 'neg' },
        chips: ['sla', 'enterprise', 'ожидание'] },
      context: { kicker: 'ENTERPRISE · 280K ₽ ARPU', title: 'B2B · корпоративные',
        rows: [['Фича', 'Поддержка 24/7'], ['Сценарий', 'Срочный инцидент'], ['Сегмент', 'Enterprise']] },
      outcomes: { kicker: 'WIN RATE ПРОДЛЕНИЯ', headline: '−12 пп',
        driver: 'сегмент B2B · 84 клиента' },
    },
    usecase: {
      signal: { kicker: 'СЦЕНАРИЙ · ЧТЕНИЕ ПЕРЕД СНОМ', title: 'Голос пользователей вечерних сессий',
        quote: '«После 22:00 пользуюсь чаще всего, и каждый раз больно глазам — белый фон бьёт. Скоро уйду к конкурентам ради тёмной темы.»',
        from: '@nightowl', when: 'Вт · 23:47' },
      intel: { conf: 0.88, title: 'Сценарий «вечернее использование» = 42% сессий, без тёмной темы',
        sentiment: { label: 'запрос фичи', pct: 68, tone: 'neu' },
        chips: ['тёмная-тема', 'a11y', 'вечерние-сессии'] },
      context: { kicker: 'ПОСЛЕ 22:00', title: 'Вечерние сессии · 42% базы',
        rows: [['Фича', 'Тема оформления'], ['Сценарий', 'Чтение перед сном'], ['Стадия LTV', 'Активные']] },
      outcomes: { kicker: 'РОАДМАП', headline: 'P1 · Q3',
        driver: 'сценарий · 320 упоминаний' },
    },
    lifecycle: {
      signal: { kicker: 'СТАДИЯ · РАННЯЯ АКТИВАЦИЯ', title: 'Голос новых пользователей',
        quote: '«Скачал, попробовал — приложение разлогинило прямо посреди регистрации. Удалил.»',
        from: 'когорта d0–d7', when: 'Окно: 14 дней' },
      intel: { conf: 0.93, title: 'Стадия d0–d7: разлогин убивает активацию в 38% случаев',
        sentiment: { label: 'негативный', pct: 83, tone: 'neg' },
        chips: ['активация', 'd0-d7', 'разлогин'] },
      context: { kicker: 'НОВЫЕ ПОЛЬЗОВАТЕЛИ', title: 'Когорта d0–d7 · все платформы',
        rows: [['Фича', 'Вход и сессии'], ['Сценарий', 'Первый запуск'], ['Стадия LTV', 'Ранняя активация']] },
      outcomes: { kicker: 'УДЕРЖАНИЕ D7', headline: '−4.2 пп',
        driver: 'разлогин · 38% когорты' },
    },
    plan: {
      signal: { kicker: 'ТАРИФ · ПРЕМИУМ ГОДОВОЙ', title: 'Голос Премиум-клиентов',
        quote: '«Третий день не могу оплатить — висит на 3-D Secure и потом ошибка. Хочу продлить подписку, но не дают.»',
        from: '@denis_m', when: 'Чт · 11:02' },
      intel: { conf: 0.94, title: 'Премиум: 22% продлений не проходят из-за ошибок оплаты',
        sentiment: { label: 'негативный', pct: 84, tone: 'neg' },
        chips: ['премиум', 'apple-pay', '3-d-secure'] },
      context: { kicker: 'ПРЕМИУМ ГОДОВОЙ', title: 'Активные подписчики',
        rows: [['Фича', 'Продление'], ['Сценарий', 'Авто-продление'], ['Тариф', 'Премиум годовой']] },
      outcomes: { kicker: 'ВЫРУЧКА', headline: '1.8 млн ₽ / мес',
        driver: 'тариф · 3 200 неудачных продлений' },
    },

    // ── 04 ИСХОДЫ ─────────────────────────────────────────────────────
    retention: {
      signal: { kicker: 'СВОДНОЕ · УДЕРЖАНИЕ D30', title: 'Что бьёт по retention',
        quote: '«Топ-3 темы оттока: разлогин на iOS, лимиты Free, ожидание поддержки B2B. Вместе — 71% всех уходов за месяц.»',
        from: 'ai.retention', when: 'Окно: 30 дней' },
      intel: { conf: 0.95, title: '3 темы объясняют 71% оттока месяц-к-месяцу',
        sentiment: { label: 'негативный', pct: 89, tone: 'neg' },
        chips: ['разлогин', 'лимиты', 'sla'] },
      context: { kicker: 'РИСК ОТТОКА', title: 'Все сегменты · риск-когорта',
        rows: [['Фича', '3 темы'], ['Сценарий', 'Отказ продления'], ['Стадия LTV', 'Риск оттока']] },
      outcomes: { kicker: 'УДЕРЖАНИЕ', headline: '−4.2% retention',
        driver: '3 темы · 71% оттока' },
    },
    revenue: {
      signal: { kicker: 'СВОДНОЕ · ВЫРУЧКА', title: 'Что съедает выручку',
        quote: '«Apple Pay и падение конверсии на оплате стоят 4.2 млн ₽ в месяц. 78% потерь — клиенты Премиум.»',
        from: 'ai.revenue', when: 'Окно: 30 дней' },
      intel: { conf: 0.93, title: '2 темы покрывают 84% упущенной выручки',
        sentiment: { label: 'упущенная выручка', pct: 84, tone: 'neg' },
        chips: ['apple-pay', 'checkout', 'премиум'] },
      context: { kicker: 'ПЛАТЯЩИЕ · ПРЕМИУМ', title: 'iOS · Премиум',
        rows: [['Фича', 'Оплата'], ['Сценарий', 'Продление'], ['Тариф', 'Премиум']] },
      outcomes: { kicker: 'ВЫРУЧКА', headline: '4.2 млн ₽ / мес',
        driver: '2 темы · 3 200 клиентов' },
    },
    roadmap: {
      signal: { kicker: 'СВОДНОЕ · РОАДМАП', title: 'Топ-запросы фич',
        quote: '«1. Тёмная тема — 320 упоминаний. 2. Голосовая поддержка 24/7 — 210. 3. Поиск 2.0 v2 — 180.»',
        from: 'ai.roadmap', when: 'Окно: квартал' },
      intel: { conf: 0.92, title: 'Приоритизация фич по голосу клиента, объёму и LTV',
        sentiment: { label: 'устойчивый спрос', pct: 81, tone: 'pos' },
        chips: ['тёмная-тема', 'голосовая', 'поиск-v2'] },
      context: { kicker: 'ПРОДУКТОВЫЙ КОНТЕКСТ', title: 'Все сегменты · все стадии',
        rows: [['Фича', '3 топ-запроса'], ['Сценарий', 'Расширение'], ['Стадия LTV', 'Все']] },
      outcomes: { kicker: 'ПРИОРИТЕТ', headline: 'P1 · P0 · P1',
        driver: 'роадмап Q3 · 710 упоминаний' },
    },
    adoption: {
      signal: { kicker: 'СВОДНОЕ · ПРИНЯТИЕ', title: 'Как заходит Поиск 2.0',
        quote: '«За 30 дней после релиза адопшен у активных покупателей 42%. Word-of-mouth разгоняет рост в Telegram и VK.»',
        from: 'ai.adoption', when: 'Окно: 30 дней' },
      intel: { conf: 0.94, title: 'Поиск 2.0: +38% адопшена / нед, рост позитива в соцсетях',
        sentiment: { label: 'позитивный', pct: 91, tone: 'pos' },
        chips: ['поиск-2.0', 'word-of-mouth', 'social'] },
      context: { kicker: 'АКТИВНЫЕ ПОКУПАТЕЛИ', title: 'Все платформы',
        rows: [['Фича', 'Поиск 2.0'], ['Сценарий', 'Каталог'], ['Стадия LTV', 'Возврат']] },
      outcomes: { kicker: 'ПРИНЯТИЕ ФИЧИ', headline: '+38% / нед',
        driver: 'поиск 2.0 · 210 упоминаний' },
    },
    winrate: {
      signal: { kicker: 'СВОДНОЕ · СДЕЛКИ B2B', title: 'Что снижает win rate',
        quote: '«В 84 проигранных сделках упоминание SLA встречается в 71%. Корпоративные клиенты не верят в обещанные 24/7.»',
        from: 'ai.winrate', when: 'Окно: квартал' },
      intel: { conf: 0.90, title: 'B2B win rate падает на темах SLA и лимитов API',
        sentiment: { label: 'негативный', pct: 83, tone: 'neg' },
        chips: ['sla', 'enterprise', 'api'] },
      context: { kicker: 'ENTERPRISE', title: 'Корпоративные сделки',
        rows: [['Фича', 'Поддержка 24/7'], ['Сценарий', 'Продление'], ['Сегмент', 'Enterprise']] },
      outcomes: { kicker: 'WIN RATE', headline: '−12 пп',
        driver: 'SLA · 84 проигранных сделки' },
    },
  };

  // Индустриальные кейсы — точечные оверрайды для конкретных узлов. То, что не
  // переопределено, берётся из DEFAULT_CASES. Заполняется по индустриям ниже.
  const INDUSTRY_CASES = {
    fmcg: {
      reviews: {
        signal: { kicker: 'WILDBERRIES · 1★', title: 'Отзыв на маркетплейсе',
          quote: '«Заказала набор "Хрустим" — приехала помятая коробка, 3 батончика сломаны. Третий раз подряд, упаковка не выдерживает доставки.»',
          from: '@elena_msk', when: 'Сб · 16:31' },
        intel: { conf: 0.93, title: 'Упаковка не выдерживает маркетплейс-доставку',
          sentiment: { label: 'негативный', pct: 81, tone: 'neg' },
          chips: ['упаковка', 'wb', 'доставка'] },
        context: { kicker: 'WB · НАБОРЫ', title: 'Доставка маркетплейса · все регионы',
          rows: [['SKU', 'Набор Хрустим 6×35 г'], ['Канал', 'WB'], ['Партия', 'Все']] },
        outcomes: { kicker: 'ПОВТОРНАЯ ПОКУПКА', headline: '−4.6 пп',
          driver: 'отзывы · 480 жалоб на упаковку' },
      },
      social: {
        signal: { kicker: 'TG · БЛОГЕР 220K', title: 'Обзор у "Что съесть"',
          quote: '«Попробовала крем "Поле" — впитывается за 30 секунд, за неделю кожа реально стала мягче. Лучшее, что выходило в этом году.»',
          from: '@chto.s.est', when: 'Пн · 12:20' },
        intel: { conf: 0.96, title: 'Word-of-mouth по крему "Поле": 4 500 позитивных упоминаний',
          sentiment: { label: 'позитивный', pct: 93, tone: 'pos' },
          chips: ['поле', 'блогеры', 'word-of-mouth'] },
        context: { kicker: 'WOMAN 25–44', title: 'TG + VK · городские',
          rows: [['SKU', 'Крем "Поле"'], ['Сценарий', 'Уход за руками'], ['Канал', 'Соцсети']] },
        outcomes: { kicker: 'ПРИНЯТИЕ НОВИНКИ', headline: '+62% к плану',
          driver: 'блогеры · 4 500 упоминаний' },
      },
      sentiment: {
        signal: { kicker: 'СВОДНОЕ · 6 ИСТОЧНИКОВ', title: 'Сентимент по теме "Вкус"',
          quote: '«За 30 дней негатив по клубничной линии вырос с 14% до 38%. Эпицентр — Юг и Поволжье, где сменился поставщик сырья.»',
          from: 'ai.sentiment', when: 'Окно: 30 дней' },
        intel: { conf: 0.94, title: 'Новый поставщик сырья даёт кислый профиль вкуса',
          sentiment: { label: 'негативный', pct: 78, tone: 'neg' },
          chips: ['вкус', 'клубника', 'юг', 'сырьё'] },
        context: { kicker: 'РЕГУЛЯРНЫЕ', title: 'Семьи · Юг + Поволжье',
          rows: [['SKU', 'Йогурт клубника'], ['Сценарий', 'Завтрак'], ['Канал', 'Все']] },
        outcomes: { kicker: 'УПУЩЕННАЯ ВЫРУЧКА', headline: '6.4 млн ₽ / мес',
          driver: 'сентимент · 2 региона · 28K чеков' },
      },
      trends: {
        signal: { kicker: 'АНОМАЛИЯ · РЕАЛЬНОЕ ВРЕМЯ', title: 'Спайк "горький привкус"',
          quote: '«За 72 часа упоминания "горький" по SKU "Кофе зерно 1 кг" выросли в 5×. Эпицентр — закупки после партии #178.»',
          from: 'ai.trends', when: 'Окно: 72 ч' },
        intel: { conf: 0.97, title: 'Аномалия по партии: жалобы концентрируются по гео',
          sentiment: { label: 'рост негатива', pct: 91, tone: 'neg' },
          chips: ['горький', 'кофе-1кг', 'партия-178'] },
        context: { kicker: 'КОФЕ · ПОРТФЕЛЬ', title: 'СЗФО + Москва · ритейл',
          rows: [['SKU', 'Кофе зерно 1 кг'], ['Партия', '#178'], ['Канал', 'Сетевой ритейл']] },
        outcomes: { kicker: 'РИСК ОТЗЫВА ПАРТИИ', headline: '380 жалоб / 72 ч',
          driver: 'тренд · кластер по партии' },
      },
      reasoning: {
        signal: { kicker: 'ИИ-ОБОСНОВАНИЕ', title: 'Почему упала повторная покупка',
          quote: '«3 темы дают 68% падения повторной покупки: даунсайзинг 350 г, кислый клубничный вкус, помятая упаковка на WB.»',
          from: 'ai.reasoning', when: 'Окно: квартал' },
        intel: { conf: 0.90, title: 'Сжатое объяснение причин по 12 SKU',
          sentiment: { label: 'смешанный', pct: 64, tone: 'neu' },
          chips: ['shrinkflation', 'вкус', 'упаковка'] },
        context: { kicker: 'РИСК ОТТОКА', title: 'Регулярные потребители',
          rows: [['SKU', '3 темы'], ['Сценарий', 'Повторная покупка'], ['Канал', 'Все']] },
        outcomes: { kicker: 'ПРИОРИТЕТ', headline: 'P0 · P1 · P1',
          driver: 'ИИ · 3 темы → 68% падения' },
      },
      feature: {
        signal: { kicker: 'НОВИНКА · ПИЦЦА "ПЕППЕРОНЕ ЛАЙТ"', title: 'Голос о новом SKU',
          quote: '«Замороженная пицца "Пеппероне Лайт" — на сковороде корочка хрустит как в кафе. Беру каждую неделю, мужу тоже зашло.»',
          from: '@oleg_food', when: 'Вт · 19:12' },
        intel: { conf: 0.95, title: 'Пицца "Пеппероне Лайт": +4× позитивных упоминаний',
          sentiment: { label: 'позитивный', pct: 92, tone: 'pos' },
          chips: ['пицца', 'пеппероне', 'новинка'] },
        context: { kicker: 'АКТИВНЫЕ ПОКУПАТЕЛИ', title: 'Все каналы · 25–44',
          rows: [['SKU', 'Пеппероне Лайт'], ['Сценарий', 'Быстрый ужин'], ['Канал', 'Ритейл + WB']] },
        outcomes: { kicker: 'ПРИНЯТИЕ НОВИНКИ', headline: '+38% / нед',
          driver: 'новинка · 210 упоминаний' },
      },
      lifecycle: {
        signal: { kicker: 'СТАДИЯ · ПЕРВАЯ ПОКУПКА', title: 'Голос новичков',
          quote: '«Купила крем "Поле" по акции в первый раз — у крема странный запах, будто аптечный. Больше не возьму.»',
          from: 'когорта d0 / WB', when: 'Окно: 14 дней' },
        intel: { conf: 0.93, title: 'Первая покупка "Поле": конверсия в повторку −38 пп',
          sentiment: { label: 'негативный', pct: 82, tone: 'neg' },
          chips: ['запах', 'первое-впечатление', 'поле'] },
        context: { kicker: 'НОВЫЕ ПОКУПАТЕЛИ', title: 'WB · акционная воронка',
          rows: [['SKU', 'Крем "Поле"'], ['Сценарий', 'Первая покупка'], ['Канал', 'WB']] },
        outcomes: { kicker: 'ПОВТОРНАЯ ПОКУПКА', headline: '−4.2 пп',
          driver: 'запах · 38% когорты d0' },
      },
      retention: {
        signal: { kicker: 'СВОДНОЕ · ПОВТОРНАЯ ПОКУПКА', title: 'Что бьёт по retention',
          quote: '«Топ-3 темы потерь ЦА: shrinkflation 350 г, кислый клубничный вкус, помятая доставка WB. 64% потерянных повторок.»',
          from: 'ai.retention', when: 'Окно: квартал' },
        intel: { conf: 0.95, title: '3 темы объясняют 64% падения повторной покупки',
          sentiment: { label: 'негативный', pct: 88, tone: 'neg' },
          chips: ['shrinkflation', 'вкус', 'упаковка'] },
        context: { kicker: 'РЕГУЛЯРНЫЕ', title: 'Семьи · все каналы',
          rows: [['SKU', '3 темы'], ['Сценарий', 'Повторная покупка'], ['Канал', 'Все']] },
        outcomes: { kicker: 'ПОВТОРНАЯ ПОКУПКА', headline: '−6.4 пп',
          driver: '3 темы · 64% потерь' },
      },
      usage: {
        signal: { kicker: 'ЧЕК + SKU · СЕТИ', title: 'Аномалия кросс-покупок',
          quote: '«За 14 дней частота "Хрустим + кола" в одном чеке упала с 38% до 22% в Х5. Каннибализация новым SKU "Чипсы XL".»',
          from: 'sku.signals', when: 'Окно: 14 дней' },
        intel: { conf: 0.91, title: 'XL съел базовый SKU в импульсной зоне',
          sentiment: { label: 'аномалия', pct: 72, tone: 'neu' },
          chips: ['каннибализация', 'XL', 'импульс'] },
        context: { kicker: 'ИМПУЛЬСНАЯ ЗОНА', title: 'Прикассовые стойки · все форматы',
          rows: [['SKU', 'Хрустим базовый'], ['Конкурент', 'Чипсы XL'], ['Канал', 'X5 + Магнит']] },
        outcomes: { kicker: 'ВЫРУЧКА БРЕНДА', headline: '−2.8 млн ₽ / нед',
          driver: 'каннибализация · 1 240 точек' },
      },
    },
    retail: {
      reviews: {
        signal: { kicker: 'Я.КАРТЫ · 2★', title: 'Отзыв на Я.Картах',
          quote: '«Магазин на Дмитровском — грязно, корзинки кривые, на кассе никого. Стояла 20 минут, ушла без покупок.»',
          from: '@dmitrovskoe.7', when: 'Чт · 18:55' },
        intel: { conf: 0.92, title: 'Чистота + укомплектованность касс в северо-западной зоне',
          sentiment: { label: 'негативный', pct: 81, tone: 'neg' },
          chips: ['чистота', 'кассы', 'дмитровское'] },
        context: { kicker: 'СЗАО', title: 'Дмитровское · 12 точек',
          rows: [['Точка', 'Дмитровское 142'], ['Сценарий', 'Дневной закуп'], ['Канал', 'Я.Карты']] },
        outcomes: { kicker: 'УДЕРЖАНИЕ', headline: '−4.6% повторных визитов',
          driver: 'отзывы · 320 за квартал' },
      },
      social: {
        signal: { kicker: 'VK · ЧАТ РАЙОНА', title: 'Сообщение в "Соседи Кунцево"',
          quote: '«В "5×7" на Партизанской просрочка постоянно. Хлеб вчерашний всегда лежит. Кто там менеджер?»',
          from: '@kuntsevo.chat', when: 'Пт · 09:14' },
        intel: { conf: 0.89, title: 'Локальная репутация: жалобы концентрируются на 1 точке',
          sentiment: { label: 'негативный', pct: 78, tone: 'neg' },
          chips: ['просрочка', 'хлеб', 'локальный-чат'] },
        context: { kicker: 'РАЙОН КУНЦЕВО', title: 'Партизанская 84 · последние 30 дней',
          rows: [['Точка', 'Партизанская'], ['Категория', 'Хлеб + молочка'], ['Канал', 'VK-чаты']] },
        outcomes: { kicker: 'ТРАФИК В ТОЧКУ', headline: '−12% LFL',
          driver: 'локальный чат · 84 упоминания' },
      },
      sentiment: {
        signal: { kicker: 'СВОДНОЕ · 6 ИСТОЧНИКОВ', title: 'Сентимент по теме "Очереди"',
          quote: '«За 30 дней негатив по очередям в "5×7" вырос с 18% до 41%. Эпицентр — вечерний час пик в ЦФО.»',
          from: 'ai.sentiment', when: 'Окно: 30 дней' },
        intel: { conf: 0.94, title: 'Очереди: новый график персонала не покрывает пик',
          sentiment: { label: 'негативный', pct: 81, tone: 'neg' },
          chips: ['очереди', 'персонал', 'час-пик'] },
        context: { kicker: 'ЦФО · 18:00–20:00', title: '1 240 точек',
          rows: [['Точка', 'ЦФО'], ['Сценарий', 'Закуп вечером'], ['Канал', 'Все']] },
        outcomes: { kicker: 'УПУЩЕННАЯ ВЫРУЧКА', headline: '12.4 млн ₽ / мес',
          driver: 'сентимент · 1 240 точек' },
      },
      trends: {
        signal: { kicker: 'АНОМАЛИЯ · РЕАЛЬНОЕ ВРЕМЯ', title: 'Спайк "пустые полки"',
          quote: '«За 48 часов рост жалоб на OOS овощей x4. Эпицентр — поставщик "ЮгАгро" сменил график доставок.»',
          from: 'ai.trends', when: 'Окно: 48 ч' },
        intel: { conf: 0.97, title: 'Аномалия OOS: связана со сменой графика поставщика',
          sentiment: { label: 'рост негатива', pct: 91, tone: 'neg' },
          chips: ['oos', 'овощи', 'югагро'] },
        context: { kicker: 'СВЕЖИЕ ОВОЩИ', title: 'ЦФО + Юг · последние 48 ч',
          rows: [['Категория', 'Овощи'], ['Поставщик', 'ЮгАгро'], ['Точка', 'ЦФО + Юг']] },
        outcomes: { kicker: 'УПУЩЕННАЯ ВЫРУЧКА', headline: '3.6 млн ₽ / 48 ч',
          driver: 'тренд · 420 жалоб на OOS' },
      },
      reasoning: {
        signal: { kicker: 'ИИ-ОБОСНОВАНИЕ', title: 'Почему упала повторная посещаемость',
          quote: '«3 темы дают 62% потерь: очереди на кассе вечером, OOS молочки, расхождение бонусов в приложении и магазине.»',
          from: 'ai.reasoning', when: 'Окно: квартал' },
        intel: { conf: 0.90, title: 'Сжатое объяснение причин по сети',
          sentiment: { label: 'смешанный', pct: 64, tone: 'neu' },
          chips: ['очереди', 'oos', 'бонусы'] },
        context: { kicker: 'РИСК ОТТОКА', title: 'Постоянные клиенты',
          rows: [['Точка', '3 темы'], ['Сценарий', 'Повторный визит'], ['Канал', 'Все']] },
        outcomes: { kicker: 'ПРИОРИТЕТ', headline: 'P0 · P1 · P1',
          driver: 'ИИ · 3 темы → 62% потерь' },
      },
      feature: {
        signal: { kicker: 'ФИЧА · КАССЫ САМООБСЛУЖИВАНИЯ', title: 'Голос клиентов о КСО',
          quote: '«Установили самообслуживание в "5×7" Беляево — оплачиваю за 30 секунд, очередей нет. Реально удобно, наконец-то.»',
          from: '@quick.shopper', when: 'Вт · 19:18' },
        intel: { conf: 0.95, title: 'КСО: +4× позитивных упоминаний после раскатки',
          sentiment: { label: 'позитивный', pct: 89, tone: 'pos' },
          chips: ['ксо', 'самообслуживание', 'скорость'] },
        context: { kicker: 'АКТИВНЫЕ ПОКУПАТЕЛИ', title: 'Москва · пилотные точки',
          rows: [['Точка', 'КСО · 84 точки'], ['Сценарий', 'Быстрая оплата'], ['Канал', 'Магазин']] },
        outcomes: { kicker: 'ПРИНЯТИЕ ФИЧИ', headline: '+62% / нед',
          driver: 'КСО · 84 точки' },
      },
      lifecycle: {
        signal: { kicker: 'СТАДИЯ · НОВЫЙ КЛИЕНТ КАРТЫ', title: 'Голос новичков лояльности',
          quote: '«Завёл карту, на первой покупке должны были начислить 500 бонусов — пришло 0. Звонил в поддержку, обещали разобраться, прошло 2 недели.»',
          from: 'когорта d0 / лояльность', when: 'Окно: 14 дней' },
        intel: { conf: 0.93, title: 'Welcome-начисление падает в 22% случаев — сбой sync',
          sentiment: { label: 'негативный', pct: 83, tone: 'neg' },
          chips: ['welcome', 'бонусы', 'sync'] },
        context: { kicker: 'НОВЫЕ ВЛАДЕЛЬЦЫ КАРТ', title: 'Когорта d0–d14',
          rows: [['Сегмент', 'Новые карты'], ['Сценарий', 'Первая покупка'], ['Канал', 'Все']] },
        outcomes: { kicker: 'УДЕРЖАНИЕ D30', headline: '−4.8 пп',
          driver: 'welcome-сбой · 22% когорты' },
      },
      retention: {
        signal: { kicker: 'СВОДНОЕ · ПОВТОРНЫЙ ВИЗИТ', title: 'Что бьёт по retention',
          quote: '«Топ-3 темы оттока: очереди вечером, OOS молочки, расхождение бонусов. 62% потерянных повторных визитов.»',
          from: 'ai.retention', when: 'Окно: квартал' },
        intel: { conf: 0.95, title: '3 темы объясняют 62% оттока повторных визитов',
          sentiment: { label: 'негативный', pct: 88, tone: 'neg' },
          chips: ['очереди', 'oos', 'бонусы'] },
        context: { kicker: 'ПОСТОЯННЫЕ', title: 'Регулярные клиенты',
          rows: [['Сегмент', 'Карта лояльности'], ['Сценарий', 'Повторный визит'], ['Канал', 'Все']] },
        outcomes: { kicker: 'ПОВТОРНЫЙ ВИЗИТ', headline: '−6.4 пп',
          driver: '3 темы · 62% потерь' },
      },
      usage: {
        signal: { kicker: 'СОБЫТИЯ ЛОЯЛЬНОСТИ', title: 'Аномалия активаций карт',
          quote: '«За 14 дней доля активаций карты после первого визита упала с 38% до 24%. Новый QR-онбординг ломается на шаге "введите код из SMS".»',
          from: 'loyalty.events', when: 'Окно: 14 дней' },
        intel: { conf: 0.92, title: 'QR-онбординг падает на шаге SMS-кода',
          sentiment: { label: 'аномалия', pct: 73, tone: 'neu' },
          chips: ['онбординг', 'qr', 'смс'] },
        context: { kicker: 'НОВЫЕ КЛИЕНТЫ', title: 'Все точки · последние 14 дней',
          rows: [['Шаг', 'Введите код'], ['Канал', 'QR на кассе'], ['Точка', 'Все']] },
        outcomes: { kicker: 'ПЕНЕТРАЦИЯ КАРТЫ', headline: '−14 пп',
          driver: 'онбординг · 38% дропа' },
      },
    },
    horeca: {
      reviews: {
        signal: { kicker: 'TRIPADVISOR · 1★', title: 'Отзыв на TripAdvisor',
          quote: '«Wi-Fi не работает, розеток нет, тарелки убирают через 5 минут — как будто выгоняют. Не место для работы.»',
          from: '@digital.nomad', when: 'Ср · 11:42' },
        intel: { conf: 0.92, title: 'Co-working опыт: жалобы на инфраструктуру в дневных сменах',
          sentiment: { label: 'негативный', pct: 78, tone: 'neg' },
          chips: ['wifi', 'розетки', 'co-working'] },
        context: { kicker: 'ДНЕВНЫЕ СЕССИИ', title: 'Все точки · 10:00–17:00',
          rows: [['Канал', 'TripAdvisor + 2GIS'], ['Сценарий', 'Работа из кафе'], ['Точка', 'Все']] },
        outcomes: { kicker: 'УДЕРЖАНИЕ ГОСТЯ', headline: '−4.6% к повторному визиту',
          driver: 'отзывы · 280 жалоб на инфру' },
      },
      social: {
        signal: { kicker: 'INSTAGRAM · STORIES', title: 'Сторис у @msk.foodies',
          quote: '«Зашли в "Cuppa" на Никитской — новый интерьер, посуда красивая, вид на старый особняк. Атмосфера 10/10.»',
          from: '@msk.foodies', when: 'Сб · 14:38' },
        intel: { conf: 0.94, title: 'Word-of-mouth по интерьеру новых точек: +5× позитива',
          sentiment: { label: 'позитивный', pct: 93, tone: 'pos' },
          chips: ['интерьер', 'word-of-mouth', 'instagram'] },
        context: { kicker: 'НОВЫЕ ТОЧКИ', title: 'Никитская · 4 точки',
          rows: [['Точка', 'Никитская'], ['Сценарий', 'Встреча с друзьями'], ['Канал', 'Instagram']] },
        outcomes: { kicker: 'ТРАФИК В ТОЧКУ', headline: '+38% к плану',
          driver: 'word-of-mouth · 1 200 упоминаний' },
      },
      sentiment: {
        signal: { kicker: 'СВОДНОЕ · 6 ИСТОЧНИКОВ', title: 'Сентимент по теме "Скорость выдачи"',
          quote: '«За 14 дней негатив по скорости выдачи кофе вырос с 14% до 36%. Эпицентр — утренний час пик 8:00–10:00.»',
          from: 'ai.sentiment', when: 'Окно: 14 дней' },
        intel: { conf: 0.94, title: 'Скорость: новая кофемашина даёт +30 сек на выдачу',
          sentiment: { label: 'негативный', pct: 81, tone: 'neg' },
          chips: ['скорость', 'кофемашина', 'утро'] },
        context: { kicker: 'УТРЕННИЙ ЧАС ПИК', title: '8:00–10:00 · все точки',
          rows: [['Канал', 'POS + NPS'], ['Сценарий', 'Take-away утром'], ['Точка', 'Москва']] },
        outcomes: { kicker: 'СРЕДНИЙ ЧЕК', headline: '−14% / утро',
          driver: 'сентимент · 380 точек' },
      },
      trends: {
        signal: { kicker: 'АНОМАЛИЯ · РЕАЛЬНОЕ ВРЕМЯ', title: 'Спайк "Wi-Fi не работает"',
          quote: '«За 48 часов рост жалоб на Wi-Fi x6 — провайдер сменил тариф, скорость упала до 5 Мбит на всю сеть.»',
          from: 'ai.trends', when: 'Окно: 48 ч' },
        intel: { conf: 0.97, title: 'Свежая аномалия инфраструктуры — смена провайдера',
          sentiment: { label: 'рост негатива', pct: 91, tone: 'neg' },
          chips: ['wifi', 'провайдер', 'co-working'] },
        context: { kicker: 'ДНЕВНЫЕ СЕССИИ', title: '120 точек · 10:00–17:00',
          rows: [['Канал', 'Все источники'], ['Сценарий', 'Работа из кафе'], ['Точка', 'Все']] },
        outcomes: { kicker: 'УДЕРЖАНИЕ ГОСТЯ', headline: '−6% за 48 ч',
          driver: 'тренд · 420 жалоб на Wi-Fi' },
      },
      reasoning: {
        signal: { kicker: 'ИИ-ОБОСНОВАНИЕ', title: 'Почему гости не возвращаются на 2-й визит',
          quote: '«3 темы дают 62% потерь повторных визитов: скорость утром, стоп-лист вечером, опоздания доставки.»',
          from: 'ai.reasoning', when: 'Окно: квартал' },
        intel: { conf: 0.90, title: 'Сжатое объяснение причин по сети',
          sentiment: { label: 'смешанный', pct: 64, tone: 'neu' },
          chips: ['скорость', 'стоп-лист', 'доставка'] },
        context: { kicker: 'РИСК ОТТОКА', title: 'Все сегменты',
          rows: [['Тема', '3 темы'], ['Сценарий', 'Повторный визит'], ['Канал', 'Все']] },
        outcomes: { kicker: 'ПРИОРИТЕТ', headline: 'P0 · P1 · P1',
          driver: 'ИИ · 3 темы → 62% оттока' },
      },
      feature: {
        signal: { kicker: 'НОВОЕ МЕНЮ "ОСЕНЬ"', title: 'Голос гостей о новом меню',
          quote: '«Тыквенный латте — наконец не приторный как у других, специи чувствуются, не маскирует кофе. Возвращаюсь каждое утро.»',
          from: '@coffee.daily', when: 'Чт · 09:31' },
        intel: { conf: 0.95, title: 'Меню "Осень": +4× позитива к прошлому сезону',
          sentiment: { label: 'позитивный', pct: 92, tone: 'pos' },
          chips: ['осень', 'латте', 'специи'] },
        context: { kicker: 'УТРЕННЯЯ ЦА', title: 'Офисы · 8:00–11:00',
          rows: [['Меню', 'Осенний латте'], ['Сценарий', 'Утренний кофе'], ['Канал', 'POS + NPS']] },
        outcomes: { kicker: 'ПРИНЯТИЕ НОВИНКИ', headline: '+42% / нед',
          driver: 'осеннее меню · 1 200 NPS' },
      },
      lifecycle: {
        signal: { kicker: 'СТАДИЯ · ПЕРВЫЙ ВИЗИТ', title: 'Голос гостей-новичков',
          quote: '«Первый раз зашла в "Cuppa" по совету подруги — кофе вкусный, но бариста ни слова, чек печатают молча. Атмосфера какая-то закрытая.»',
          from: 'когорта d0 / NPS', when: 'Окно: 30 дней' },
        intel: { conf: 0.93, title: 'Первый визит: 38% новичков говорят о "закрытой атмосфере"',
          sentiment: { label: 'негативный', pct: 78, tone: 'neg' },
          chips: ['первое-впечатление', 'персонал', 'small-talk'] },
        context: { kicker: 'НОВИЧКИ', title: 'Первый визит · все точки',
          rows: [['Сегмент', 'Новые гости'], ['Сценарий', 'Первый визит'], ['Канал', 'NPS + отзывы']] },
        outcomes: { kicker: 'ВОЗВРАТ D30', headline: '−4.2 пп',
          driver: 'персонал · 38% когорты' },
      },
      retention: {
        signal: { kicker: 'СВОДНОЕ · ВОЗВРАТ ГОСТЯ', title: 'Что бьёт по retention',
          quote: '«Топ-3 темы: скорость утром, стоп-лист вечером, опоздания доставки. 62% потерь повторных визитов.»',
          from: 'ai.retention', when: 'Окно: квартал' },
        intel: { conf: 0.95, title: '3 темы объясняют 62% потерь повторных визитов',
          sentiment: { label: 'негативный', pct: 88, tone: 'neg' },
          chips: ['скорость', 'стоп-лист', 'доставка'] },
        context: { kicker: 'РИСК ОТТОКА', title: 'Все гости',
          rows: [['Тема', '3 темы'], ['Сценарий', 'Повторный визит'], ['Канал', 'Все']] },
        outcomes: { kicker: 'ВОЗВРАТ ГОСТЯ', headline: '−6.4 пп',
          driver: '3 темы · 62% потерь' },
      },
      usage: {
        signal: { kicker: 'POS · СТОП-ЛИСТЫ', title: 'Аномалия в стоп-листе',
          quote: '«В точке на Маросейке в 18:00 в стоп-листе 7 позиций из 14 — гости разворачиваются, средний чек упал на 22%.»',
          from: 'pos.events', when: 'Окно: 7 дней' },
        intel: { conf: 0.93, title: 'Перерасход стоп-листов: проблема с заказами на ингредиенты',
          sentiment: { label: 'аномалия', pct: 78, tone: 'neu' },
          chips: ['стоп-лист', 'ингредиенты', 'дозаказ'] },
        context: { kicker: 'МАРОСЕЙКА · ВЕЧЕР', title: 'Точка "Cuppa" Маросейка',
          rows: [['Точка', 'Маросейка'], ['Сценарий', 'Дневной перекус'], ['Канал', 'POS']] },
        outcomes: { kicker: 'СРЕДНИЙ ЧЕК', headline: '−22%',
          driver: 'стоп-лист · 7 из 14' },
      },
    },
    fintech: {
      reviews: {
        signal: { kicker: 'BANKI.RU · 1★', title: 'Отзыв на Banki.ru',
          quote: '«Приложение виснет на этапе оплаты ЖКХ — вторая половина месяца, когда все платят, лежит просто. Захожу через сайт — и там очередь.»',
          from: '@msk.payer', when: 'Чт · 19:08' },
        intel: { conf: 0.92, title: 'Производительность ДБО: жалобы на "пиковые дни ЖКХ"',
          sentiment: { label: 'негативный', pct: 81, tone: 'neg' },
          chips: ['производительность', 'жкх', 'пик'] },
        context: { kicker: 'ПИК ЖКХ', title: 'Все сегменты · 20–25 числа',
          rows: [['Канал', 'ДБО'], ['Сценарий', 'Оплата ЖКХ'], ['Сегмент', 'Все']] },
        outcomes: { kicker: 'УДЕРЖАНИЕ', headline: '−2.8 пп',
          driver: 'отзывы · 320 жалоб / мес' },
      },
      social: {
        signal: { kicker: 'PIKABU · ФОРУМ', title: 'Пост в Pikabu',
          quote: '«Заблокировали счёт на 7 дней за "подозрительные операции" — переводил отцу на лечение. Связаться невозможно, поддержка в чате — бот.»',
          from: '@user_42', when: 'Пн · 18:14' },
        intel: { conf: 0.91, title: 'Антифрод: ложные блокировки на сценарии "семейные переводы"',
          sentiment: { label: 'негативный', pct: 87, tone: 'neg' },
          chips: ['антифрод', 'блокировка', 'семейный-перевод'] },
        context: { kicker: 'СЕМЕЙНЫЕ ПЕРЕВОДЫ', title: 'Все сегменты · Pikabu + форумы',
          rows: [['Канал', 'Соцсети'], ['Сценарий', 'Семейный перевод'], ['Сегмент', 'Все']] },
        outcomes: { kicker: 'РЕПУТАЦИЯ', headline: '−18% к индексу доверия',
          driver: 'pikabu · 420 упоминаний' },
      },
      sentiment: {
        signal: { kicker: 'СВОДНОЕ · 6 ИСТОЧНИКОВ', title: 'Сентимент по теме "Антифрод"',
          quote: '«За 30 дней негатив по антифроду вырос с 22% до 48%. Эпицентр — клиенты с регулярными семейными переводами.»',
          from: 'ai.sentiment', when: 'Окно: 30 дней' },
        intel: { conf: 0.94, title: 'Антифрод: ложноположительные блокировки бьют по NPS',
          sentiment: { label: 'негативный', pct: 84, tone: 'neg' },
          chips: ['антифрод', 'false-positive', 'переводы'] },
        context: { kicker: 'СЕМЕЙНЫЕ ПЕРЕВОДЫ', title: 'Регулярные клиенты',
          rows: [['Канал', 'Все'], ['Сценарий', 'Перевод семье'], ['Сегмент', 'ФЛ']] },
        outcomes: { kicker: 'ОТТОК NPS', headline: '−16 пп',
          driver: 'сентимент · 84K клиентов' },
      },
      trends: {
        signal: { kicker: 'АНОМАЛИЯ · РЕАЛЬНОЕ ВРЕМЯ', title: 'Спайк "СБП не проходит"',
          quote: '«За 48 часов рост жалоб на СБП x6, эпицентр — релиз ДБО 4.12. Шлюз ЦБ возвращает таймаут на 22% операций.»',
          from: 'ai.trends', when: 'Окно: 48 ч' },
        intel: { conf: 0.97, title: 'Аномалия по СБП: связана с релизом ДБО',
          sentiment: { label: 'рост негатива', pct: 91, tone: 'neg' },
          chips: ['сбп', 'релиз-4.12', 'таймаут'] },
        context: { kicker: 'ПЛАТЯЩИЕ', title: 'Все сегменты · последние 48 ч',
          rows: [['Канал', 'ДБО'], ['Сценарий', 'СБП-перевод'], ['Сегмент', 'Все']] },
        outcomes: { kicker: 'УПУЩЕННЫЕ КОМИССИИ', headline: '8.4 млн ₽ / 48 ч',
          driver: 'тренд · 420 жалоб' },
      },
      reasoning: {
        signal: { kicker: 'ИИ-ОБОСНОВАНИЕ', title: 'Почему упал NPS после релиза',
          quote: '«3 темы дают 71% падения NPS: СБП "в обработке", производительность в пик ЖКХ, ложный антифрод.»',
          from: 'ai.reasoning', when: 'Окно: 30 дней' },
        intel: { conf: 0.90, title: 'Сжатое объяснение причин по 9 банкам',
          sentiment: { label: 'смешанный', pct: 64, tone: 'neu' },
          chips: ['сбп', 'жкх', 'антифрод'] },
        context: { kicker: 'РИСК ОТТОКА', title: 'Все сегменты',
          rows: [['Тема', '3 темы'], ['Сценарий', 'Отказ от продуктов'], ['Сегмент', 'Все']] },
        outcomes: { kicker: 'ПРИОРИТЕТ', headline: 'P0 · P1 · P1',
          driver: 'ИИ · 3 темы → 71% NPS' },
      },
      feature: {
        signal: { kicker: 'НОВАЯ ИНВЕСТ-КАРТА', title: 'Голос клиентов о новом продукте',
          quote: '«В отличие от других банков, тут реальная доходность за месяц показывается одним числом, без маркетинговых "до 22%". Доверяю.»',
          from: '@invest.user', when: 'Вт · 14:08' },
        intel: { conf: 0.95, title: 'Инвест-карта: +4× позитива, growth с 0 до 84K клиентов',
          sentiment: { label: 'позитивный', pct: 92, tone: 'pos' },
          chips: ['инвест-карта', 'прозрачность', 'доходность'] },
        context: { kicker: 'ИНВЕСТОРЫ', title: 'Премиум + Премиум плюс',
          rows: [['Канал', 'Приложение'], ['Сценарий', 'Управление портфелем'], ['Сегмент', 'Премиум']] },
        outcomes: { kicker: 'ПРИНЯТИЕ ФИЧИ', headline: '+62% / нед',
          driver: 'инвест-карта · 84K клиентов' },
      },
      lifecycle: {
        signal: { kicker: 'СТАДИЯ · ОТКРЫТИЕ СЧЁТА', title: 'Голос новых клиентов',
          quote: '«Открыл счёт онлайн, доставка карты обещали 3 дня, привезли через 11. За это время сделал счёт в другом банке.»',
          from: 'когорта d0–d14', when: 'Окно: 14 дней' },
        intel: { conf: 0.93, title: 'Доставка карты: SLA нарушается в 38% случаев в МО',
          sentiment: { label: 'негативный', pct: 83, tone: 'neg' },
          chips: ['доставка', 'sla', 'открытие'] },
        context: { kicker: 'НОВЫЕ КЛИЕНТЫ', title: 'Когорта d0–d14',
          rows: [['Канал', 'Курьер'], ['Сценарий', 'Открытие счёта'], ['Сегмент', 'Все']] },
        outcomes: { kicker: 'УДЕРЖАНИЕ D30', headline: '−4.2 пп',
          driver: 'доставка · 38% когорты' },
      },
      retention: {
        signal: { kicker: 'СВОДНОЕ · УДЕРЖАНИЕ', title: 'Что бьёт по retention',
          quote: '«Топ-3 темы оттока: ложный антифрод, СБП в обработке, доставка карты. 68% потерь активных клиентов.»',
          from: 'ai.retention', when: 'Окно: квартал' },
        intel: { conf: 0.95, title: '3 темы объясняют 68% оттока',
          sentiment: { label: 'негативный', pct: 88, tone: 'neg' },
          chips: ['антифрод', 'сбп', 'доставка'] },
        context: { kicker: 'РИСК ОТТОКА', title: 'Все сегменты',
          rows: [['Тема', '3 темы'], ['Сценарий', 'Закрытие счёта'], ['Сегмент', 'Все']] },
        outcomes: { kicker: 'УДЕРЖАНИЕ', headline: '−4.8 пп',
          driver: '3 темы · 68% оттока' },
      },
      usage: {
        signal: { kicker: 'СОБЫТИЯ ТРАНЗАКЦИЙ', title: 'Аномалия в отказах',
          quote: '«За 72 часа доля отказов карт на маркетплейсах выросла с 1.2% до 6.8%. Эпицентр — WB + Ozon, 3-D Secure.»',
          from: 'tx.events', when: 'Окно: 72 ч' },
        intel: { conf: 0.93, title: 'Аномалия: 3-D Secure отваливается после релиза ДБО 4.12',
          sentiment: { label: 'аномалия', pct: 79, tone: 'neu' },
          chips: ['3-d-secure', 'релиз-4.12', 'wb'] },
        context: { kicker: 'ПОКУПКИ В ИНТЕРНЕТЕ', title: 'Все карты · WB + Ozon',
          rows: [['Канал', 'Карты онлайн'], ['Сценарий', 'Оплата покупки'], ['Сегмент', 'Все']] },
        outcomes: { kicker: 'УПУЩЕННЫЕ КОМИССИИ', headline: '8.4 млн ₽ / нед',
          driver: 'релиз 4.12 · 84K отказов' },
      },
    },
    saas: {},
  };

  // Industries — отраслевая настройка карты: подменяют названия источников
  // (и местами других узлов) под конкретный домен.
  const INDUSTRIES = [
    { key: 'saas',  label: 'Приложения SaaS', sub: 'B2C и B2B продукты', icon: 'app',
      overrides: { reviews: 'App Store и Google Play', tickets: 'Intercom и Zendesk', calls: 'Звонки sales и CS', usage: 'Amplitude и Mixpanel', social: 'Telegram, VK, Reddit', surveys: 'In-app NPS / CSAT' } },
    { key: 'fmcg',   label: 'FMCG и бренды',     sub: '38 брендов · 12 млн отзывов / мес', icon: 'bottle',
      overrides: { reviews: 'Маркетплейсы', social: 'Соцсети и блогеры', tickets: 'Чаты брендов', usage: 'Чек и SKU-события', surveys: 'Панели и опросы', calls: 'Горячая линия бренда' } },
    { key: 'retail', label: 'Сетевой ритейл',    sub: '5 000+ точек подключено', icon: 'cart',
      overrides: { reviews: 'Я.Карты и 2GIS', tickets: 'Жалобы на кассе', surveys: 'Опрос в чеке', usage: 'События лояльности', calls: 'Контакт-центр сети', social: 'Локальные чаты VK' } },
    { key: 'horeca', label: 'HoReCa',  sub: '14 сетей · NPS по каждой смене', icon: 'fork',
      overrides: { reviews: 'TripAdvisor и 2GIS', tickets: 'Жалобы официанту', surveys: 'NPS по чеку', usage: 'События POS', calls: 'Резервы и доставка', social: 'Instagram и Telegram' } },
    { key: 'fintech', label: 'Финансы и банки',  sub: '9 банков · 3 НПФ', icon: 'bank',
      overrides: { reviews: 'Banki.ru и Сравни', tickets: 'Тикеты ДБО', calls: 'Контакт-центр', usage: 'События транзакций', social: 'Pikabu и форумы', surveys: 'NPS после операции' } },
  ];

  const nodeCol = (id) => COLUMNS.findIndex(c => c.nodes.some(n => n.id === id));
  const nodeMeta = (id) => {
    for (const c of COLUMNS) {
      const n = c.nodes.find(n => n.id === id);
      if (n) return n;
    }
    return null;
  };

  // ─── State ─────────────────────────────────────────────────────────────
  const [focus, setFocus] = React.useState('sentiment');
  const [displayFocus, setDisplayFocus] = React.useState('sentiment');
  const [cardsOpacity, setCardsOpacity] = React.useState(1);
  const [interacted, setInteracted] = React.useState(false);
  const [industry, setIndustry] = React.useState('saas');
  const [isMobile, setIsMobile] = React.useState(
    typeof window !== 'undefined' && window.matchMedia('(max-width: 860px)').matches
  );
  React.useEffect(() => {
    if (typeof window === 'undefined') return;
    const mq = window.matchMedia('(max-width: 860px)');
    const handler = (e) => setIsMobile(e.matches);
    mq.addEventListener ? mq.addEventListener('change', handler) : mq.addListener(handler);
    return () => {
      mq.removeEventListener ? mq.removeEventListener('change', handler) : mq.removeListener(handler);
    };
  }, []);
  const activeIndustry = INDUSTRIES.find(i => i.key === industry) || INDUSTRIES[0];
  const labelFor = (n) => activeIndustry.overrides[n.id] || n.label;
  const CASES = { ...DEFAULT_CASES, ...(INDUSTRY_CASES[industry] || {}) };

  // Crossfade cards: when focus changes, fade out → swap → fade in.
  React.useEffect(() => {
    if (displayFocus === focus) return;
    setCardsOpacity(0);
    const t = setTimeout(() => {
      setDisplayFocus(focus);
      setCardsOpacity(1);
    }, 500);
    return () => clearTimeout(t);
  }, [focus, displayFocus]);

  React.useEffect(() => {
    if (interacted) return;
    const cycle = ['sentiment', 'reviews', 'reasoning', 'feature', 'lifecycle', 'retention', 'trends', 'usage'];
    const id = setInterval(() => {
      setFocus(cur => cycle[(cycle.indexOf(cur) + 1) % cycle.length] || cycle[0]);
    }, 6000);
    return () => clearInterval(id);
  }, [interacted]);

  // Chains that pass THROUGH focus: forward closure (focus → col4) + backward
  // closure (col0 → focus). Edges go left→right; following them directionally
  // gives a clean fan-out and fan-in without dragging in unrelated branches.
  const reachable = React.useMemo(() => {
    const set = new Set([focus]);
    // Forward
    let frontier = [focus];
    while (frontier.length) {
      const next = [];
      for (const node of frontier) {
        for (const [a, b] of EDGES) {
          if (a === node && !set.has(b)) { set.add(b); next.push(b); }
        }
      }
      frontier = next;
    }
    // Backward
    frontier = [focus];
    while (frontier.length) {
      const next = [];
      for (const node of frontier) {
        for (const [a, b] of EDGES) {
          if (b === node && !set.has(a)) { set.add(a); next.push(a); }
        }
      }
      frontier = next;
    }
    return set;
  }, [focus]);

  // Edges along chains through focus: both endpoints reachable AND the edge
  // actually lies on a forward-chain through focus. Because we built reachable
  // by forward+backward walks from focus, any edge (a,b) with both endpoints
  // in `reachable` and a in the forward-or-self set OR b in backward-or-self
  // set lies on such a chain.
  const litEdges = React.useMemo(() => {
    return EDGES.filter(([a, b]) => reachable.has(a) && reachable.has(b));
  }, [reachable]);

  const hover = (id) => { setInteracted(true); setFocus(id); };

  // ─── Geometry ──────────────────────────────────────────────────────────
  const colX = [13, 38, 63, 88];
  const nodeY = (ci, ni) => {
    const total = COLUMNS[ci].nodes.length;
    return 14 + (ni * (84 / Math.max(total - 1, 1)));
  };
  const nodePos = (id) => {
    const ci = nodeCol(id);
    const ni = COLUMNS[ci].nodes.findIndex(n => n.id === id);
    return [colX[ci], nodeY(ci, ni)];
  };

  const edgeD = (a, b) => {
    const [x0, y0] = nodePos(a);
    const [x1, y1] = nodePos(b);
    const cx0 = x0 + (x1 - x0) * 0.55;
    const cx1 = x1 - (x1 - x0) * 0.55;
    return `M ${x0} ${y0} C ${cx0} ${y0}, ${cx1} ${y1}, ${x1} ${y1}`;
  };

  // ─── Icons ─────────────────────────────────────────────────────────────
  const Icon = ({ k, color = 'currentColor' }) => {
    const s = { stroke: color, strokeWidth: 1.5, fill: 'none', strokeLinecap: 'round', strokeLinejoin: 'round' };
    const paths = {
      chat:  <path d="M2 3 H10 V8 H5 L3 10 V8 H2 Z" {...s} />,
      phone: <path d="M3 3 L5 3 L6 5 L4.5 6 C5 7.5 6.5 9 8 9.5 L9 8 L11 9 V11 C7 11 1 8 1 4 V3 Z" {...s} />,
      doc:   <path d="M3 1 H8 L10 3 V11 H3 Z M5 5 H8 M5 7 H8 M5 9 H7" {...s} />,
      bar:   <path d="M2 10 V7 M5 10 V4 M8 10 V6 M11 10 V2" {...s} />,
      bell:  <path d="M3 9 H10 L9 7 V5 A3 3 0 0 0 4 5 V7 Z M5.5 10 A1 1 0 0 0 7.5 10" {...s} />,
      star:  <path d="M6 1 L7.5 4.5 L11 5 L8.5 7.5 L9 11 L6 9.5 L3 11 L3.5 7.5 L1 5 L4.5 4.5 Z" {...s} />,
      grid:  <path d="M2 2 H5 V5 H2 Z M7 2 H10 V5 H7 Z M2 7 H5 V10 H2 Z M7 7 H10 V10 H7 Z" {...s} />,
      smile: <g><circle cx="6" cy="6" r="4.5" stroke={color} strokeWidth="1.5" fill="none" /><circle cx="4.5" cy="5" r="0.5" fill={color} /><circle cx="7.5" cy="5" r="0.5" fill={color} /><path d="M4 7 Q6 8.5 8 7" stroke={color} strokeWidth="1.3" fill="none" strokeLinecap="round" /></g>,
      spark: <path d="M1 9 L4 6 L6 7.5 L11 2 M9 2 H11 V4" {...s} />,
      bulb:  <path d="M4 8 V9 H8 V8 M4 8 A3 3 0 1 1 8 8 M5 10 H7" {...s} />,
      tag:   <path d="M2 6 L6 2 H10 V6 L6 10 Z" {...s} />,
      circle:<circle cx="6" cy="6" r="4" stroke={color} strokeWidth="1.5" fill="none" />,
      gear:  <g><circle cx="6" cy="6" r="2" stroke={color} strokeWidth="1.5" fill="none" /><path d="M6 1 V3 M6 9 V11 M1 6 H3 M9 6 H11 M2.5 2.5 L4 4 M8 8 L9.5 9.5 M2.5 9.5 L4 8 M8 4 L9.5 2.5" stroke={color} strokeWidth="1.3" /></g>,
      loop:  <path d="M2 6 A4 4 0 1 1 6 10 M2 6 L2 3 M2 6 L5 6" {...s} />,
      rows:  <path d="M2 3 H10 M2 6 H10 M2 9 H10" {...s} />,
      flag:  <path d="M3 1 V11 M3 2 H10 L8 4 L10 6 H3" {...s} />,
      arrow: <path d="M2 10 L10 2 M6 2 H10 V6" {...s} />,
      cup:   <path d="M3 2 H9 V5 A3 3 0 0 1 3 5 Z M2 3 H3 M9 3 H10 M5 8 V10 M7 8 V10 M4 10 H8" {...s} />,
    };
    return <svg viewBox="0 0 12 12" width="13" height="13">{paths[k] || paths.circle}</svg>;
  };

  // For each column, list which nodes are in the lit subgraph (excluding focus column gets the focus card).
  const focusCol = nodeCol(focus);
  const colLit = COLUMNS.map((c, ci) =>
    c.nodes.filter(n => reachable.has(n.id) && ci !== focusCol)
  );

  const focusMeta = nodeMeta(focus);
  const focusContent = NODE_CONTENT[focus];

  // ─── Render ────────────────────────────────────────────────────────────
  return (
    <section id="journey" style={{ padding: '64px 0 56px', background: 'var(--ink)', color: 'var(--bg)', position: 'relative', overflow: 'hidden' }}>
      {/* grid */}
      <svg width="100%" height="100%" style={{ position: 'absolute', inset: 0, opacity: 0.16, pointerEvents: 'none' }}>
        <defs>
          <pattern id="jm-grid" width="48" height="48" patternUnits="userSpaceOnUse">
            <path d="M 48 0 L 0 0 0 48" fill="none" stroke="rgba(245,243,238,0.18)" strokeWidth="0.5" />
          </pattern>
        </defs>
        <rect width="100%" height="100%" fill="url(#jm-grid)" />
      </svg>

      <div className="container" style={{ position: 'relative' }}>
        {/* Industry selector — desktop = 5 full cards in row, mobile = icon row + active card below */}
        {isMobile ? (
          <div style={{ marginBottom: 20 }}>
            {/* Icon row — flex instead of grid to bypass global mobile grid overrides */}
            <div style={{
              display: 'flex', gap: 8, marginBottom: 12,
              width: '100%',
            }}>
              {INDUSTRIES.map(ind => {
                const isActive = ind.key === industry;
                return (
                  <button
                    key={ind.key}
                    onClick={() => setIndustry(ind.key)}
                    aria-label={ind.label}
                    style={{
                      flex: '1 1 0', minWidth: 0,
                      height: 56,
                      borderRadius: 10,
                      background: isActive ? 'var(--bg)' : 'rgba(245,243,238,0.04)',
                      border: isActive ? '1px solid var(--bg)' : '1px solid rgba(245,243,238,0.14)',
                      boxShadow: isActive ? '0 6px 20px -8px rgba(0,0,0,0.45)' : 'none',
                      color: isActive ? 'var(--ink)' : 'rgba(245,243,238,0.62)',
                      cursor: 'pointer',
                      display: 'flex', alignItems: 'center', justifyContent: 'center',
                      transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
                      padding: 0,
                    }}
                  >
                    <IndustryIcon name={ind.icon} />
                  </button>
                );
              })}
            </div>

            {/* Active card — like the screenshot */}
            <div
              key={activeIndustry.key}
              style={{
                padding: '14px 18px',
                borderRadius: 12,
                background: 'rgba(245,243,238,0.04)',
                border: '1px solid rgba(245,243,238,0.16)',
                position: 'relative',
                animation: 'jm-ind-in 0.35s cubic-bezier(0.4, 0, 0.2, 1)',
              }}
            >
              <svg width="14" height="14" viewBox="0 0 12 12" style={{
                position: 'absolute', top: 14, right: 14,
                color: 'rgba(245,243,238,0.6)',
              }}>
                <path d="M3 9 L9 3 M4.5 3 L9 3 L9 7.5" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              <div className="display" style={{
                fontSize: 16, fontWeight: 700, lineHeight: 1.2,
                color: 'var(--bg)', paddingRight: 24,
              }}>{activeIndustry.label}</div>
              <div style={{
                fontSize: 12.5, lineHeight: 1.35, marginTop: 4,
                color: 'rgba(245,243,238,0.6)',
              }}>{activeIndustry.sub}</div>
            </div>

            <style>{`
              @keyframes jm-ind-in {
                from { opacity: 0; transform: translateY(-4px); }
                to   { opacity: 1; transform: translateY(0); }
              }
            `}</style>
          </div>
        ) : (
          <div style={{
            display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
            gap: 8, marginBottom: 20,
          }}>
            {INDUSTRIES.map(ind => {
              const isActive = ind.key === industry;
              return (
                <button
                  key={ind.key}
                  onClick={() => setIndustry(ind.key)}
                  style={{
                    textAlign: 'left',
                    padding: '10px 14px',
                    borderRadius: 10,
                    background: isActive ? 'var(--bg)' : 'rgba(245,243,238,0.03)',
                    color: isActive ? 'var(--ink)' : 'rgba(245,243,238,0.78)',
                    border: isActive ? '1px solid var(--bg)' : '1px solid rgba(245,243,238,0.14)',
                    boxShadow: isActive ? '0 10px 30px -10px rgba(0,0,0,0.5)' : 'none',
                    cursor: 'pointer',
                    position: 'relative',
                    transition: 'all 0.35s cubic-bezier(0.4, 0, 0.2, 1)',
                  }}
                >
                  <svg width="12" height="12" viewBox="0 0 12 12" style={{
                    position: 'absolute', top: 12, right: 12,
                    color: isActive ? 'var(--ink-2)' : 'rgba(245,243,238,0.55)',
                  }}>
                    <path d="M3 9 L9 3 M4.5 3 L9 3 L9 7.5" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                  <div className="display" style={{
                    fontSize: 14, fontWeight: 700, lineHeight: 1.2,
                    marginBottom: 4, paddingRight: 16,
                    color: isActive ? 'var(--ink)' : 'var(--bg)',
                  }}>{ind.label}</div>
                  <div style={{
                    fontSize: 11, lineHeight: 1.3,
                    color: isActive ? 'var(--muted)' : 'rgba(245,243,238,0.5)',
                  }}>{ind.sub}</div>
                </button>
              );
            })}
          </div>
        )}

        {/* Map (на мобиле — карта целиком в ширину экрана, без скролла) */}
        <div className="jm-scroll" style={{ marginBottom: 20 }}>
        <div className="jm-map" style={{
          position: 'relative',
          aspectRatio: '1180 / 440',
          width: '100%',
          maxHeight: 'calc(100vh - 360px)',
        }}>
          {/* Column headers */}
          <div style={{
            position: 'absolute', top: -8, left: 0, right: 0,
            display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', pointerEvents: 'none',
          }}>
            {COLUMNS.map((c, ci) => (
              <div key={c.n} style={{ padding: '0 24px' }}>
                <div className="mono" style={{ fontSize: 11, letterSpacing: '0.14em', color: 'rgba(245,243,238,0.4)' }}>
                  <span style={{ color: COL_COLORS[ci], opacity: 0.85 }}>{c.n}</span>
                  &nbsp;&nbsp;{c.t}
                </div>
              </div>
            ))}
          </div>

          {/* SVG: all edges always rendered; lit overlay opacity transitions for smooth fade */}
          <svg viewBox="0 0 100 100" preserveAspectRatio="none" width="100%" height="100%" style={{ position: 'absolute', inset: 0 }}>
            <defs>
              <linearGradient id="jm-grad-01" x1="0" x2="1" y1="0" y2="0">
                <stop offset="0" stopColor={COL_COLORS[0]} /><stop offset="1" stopColor={COL_COLORS[1]} />
              </linearGradient>
              <linearGradient id="jm-grad-12" x1="0" x2="1" y1="0" y2="0">
                <stop offset="0" stopColor={COL_COLORS[1]} /><stop offset="1" stopColor={COL_COLORS[2]} />
              </linearGradient>
              <linearGradient id="jm-grad-23" x1="0" x2="1" y1="0" y2="0">
                <stop offset="0" stopColor={COL_COLORS[2]} /><stop offset="1" stopColor={COL_COLORS[3]} />
              </linearGradient>
              <filter id="jm-glow"><feGaussianBlur stdDeviation="0.5" /></filter>
            </defs>

            {/* Dim base + animated lit overlay per edge */}
            {EDGES.map(([a, b], i) => {
              const lit = reachable.has(a) && reachable.has(b);
              const cA = nodeCol(a);
              const grad = `url(#jm-grad-${Math.min(cA, 2)}${Math.min(cA + 1, 3)})`;
              const d = edgeD(a, b);
              return (
                <g key={`e-${i}`}>
                  <path d={d} stroke="rgba(245,243,238,0.07)" strokeWidth="0.12" fill="none" vectorEffect="non-scaling-stroke" />
                  <path d={d} stroke={grad} strokeWidth="1.6" fill="none"
                        opacity={lit ? 0.35 : 0}
                        vectorEffect="non-scaling-stroke"
                        style={{ filter: 'url(#jm-glow)', transition: 'opacity 1.1s cubic-bezier(0.4, 0, 0.2, 1)' }} />
                  <path d={d} stroke={grad} strokeWidth="0.75" fill="none"
                        opacity={lit ? 0.9 : 0}
                        vectorEffect="non-scaling-stroke"
                        style={{ filter: 'url(#jm-glow)', transition: 'opacity 1.1s cubic-bezier(0.4, 0, 0.2, 1)' }} />
                  <path d={d} stroke={grad} strokeWidth="0.3" fill="none"
                        opacity={lit ? 1 : 0}
                        vectorEffect="non-scaling-stroke"
                        style={{ transition: 'opacity 1.1s cubic-bezier(0.4, 0, 0.2, 1)' }} />
                </g>
              );
            })}

            {/* faint dot field */}
            {Array.from({ length: 8 }).map((_, r) =>
              Array.from({ length: 22 }).map((_, c) => (
                <circle key={`${r}-${c}`} cx={4 + c * 4.3} cy={10 + r * 11} r="0.16" fill="rgba(245,243,238,0.22)" />
              ))
            )}
          </svg>

          {/* Pills */}
          {COLUMNS.map((c, ci) => c.nodes.map((n, ni) => {
            const x = colX[ci], y = nodeY(ci, ni);
            const onPath = reachable.has(n.id);
            const isFocus = n.id === focus;
            return (
              <div
                key={n.id}
                onMouseEnter={() => hover(n.id)}
                onFocus={() => hover(n.id)}
                onClick={() => hover(n.id)}
                tabIndex={0}
                style={{
                  position: 'absolute',
                  left: `${x}%`, top: `${y}%`,
                  transform: 'translate(-50%, -50%)',
                  cursor: 'pointer', outline: 'none',
                  zIndex: isFocus ? 3 : onPath ? 2 : 1,
                }}
              >
                <div style={{
                  display: 'inline-flex', alignItems: 'center', gap: 7,
                  padding: '6px 13px 6px 11px',
                  borderRadius: 999,
                  background: onPath ? 'var(--bg)' : 'rgba(245,243,238,0.04)',
                  color: onPath ? 'var(--ink)' : 'rgba(245,243,238,0.45)',
                  border: isFocus ? `1px solid ${COL_COLORS[ci]}` : onPath ? '1px solid var(--bg)' : '1px solid rgba(245,243,238,0.12)',
                  fontSize: 13, fontWeight: 500,
                  boxShadow: isFocus
                    ? `0 0 0 5px ${COL_COLORS[ci]}33, 0 0 50px ${COL_COLORS[ci]}aa`
                    : onPath ? '0 4px 14px rgba(0,0,0,0.4)' : 'none',
                  whiteSpace: 'nowrap',
                  transition: 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
                  opacity: onPath ? 1 : 0.55,
                }}>
                  <Icon k={n.icon} color={onPath ? 'currentColor' : 'rgba(245,243,238,0.45)'} />
                  {labelFor(n)}
                </div>
              </div>
            );
          }))}
        </div>
        </div>

        {/* Detail cards: Signals / Intelligence / Context / Outcomes — кейс по фокусу */}
        <div style={{
          display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14,
          borderTop: '1px solid rgba(245,243,238,0.14)', paddingTop: 28,
          opacity: cardsOpacity,
          transition: 'opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
        }}>
          {(() => {
            const data = CASES[displayFocus] || CASES.sentiment;
            const labels = ['СИГНАЛ', 'АНАЛИЗ', 'КОНТЕКСТ', 'ИСХОД'];
            const subTags = [data.signal.kicker, `CONF ${data.intel.conf.toFixed(2)}`, data.context.kicker, data.outcomes.kicker];

            const Card = ({ ci, children, accent }) => {
              const color = COL_COLORS[ci];
              return (
                <div style={{
                  background: accent ? `linear-gradient(180deg, ${color}14, rgba(245,243,238,0.03))` : 'rgba(245,243,238,0.03)',
                  border: accent ? `1px solid ${color}55` : '1px solid rgba(245,243,238,0.12)',
                  borderRadius: 10, padding: 18, position: 'relative',
                  boxShadow: accent ? `0 0 0 1px ${color}11` : 'none',
                  display: 'flex', flexDirection: 'column', gap: 12,
                  minHeight: 270,
                }}>
                  <div className="mono" style={{
                    fontSize: 10.5, color: 'rgba(245,243,238,0.55)',
                    letterSpacing: '0.12em', display: 'flex', alignItems: 'center', gap: 8,
                  }}>
                    <span style={{ width: 6, height: 6, borderRadius: '50%', background: color, boxShadow: `0 0 8px ${color}` }} />
                    <span style={{ color: 'var(--bg)' }}>{`0${ci + 1}`}</span>
                    <span style={{ opacity: 0.4 }}>·</span>
                    <span style={{ color: color }}>{labels[ci]}</span>
                    <span style={{ opacity: 0.4, marginLeft: 'auto', textTransform: 'uppercase', fontSize: 10, letterSpacing: '0.1em' }}>
                      {subTags[ci]}
                    </span>
                  </div>
                  {children}
                </div>
              );
            };

            const toneColor = (t) => t === 'pos' ? COL_COLORS[2] : t === 'neu' ? COL_COLORS[0] : COL_COLORS[1];

            return (
              <React.Fragment>
                {/* 01 SIGNAL */}
                <Card ci={0}>
                  <div style={{ fontSize: 16, fontWeight: 600, color: 'var(--bg)', lineHeight: 1.3 }}>
                    {data.signal.title}
                  </div>
                  <div className="serif" style={{ fontSize: 14.5, color: 'rgba(245,243,238,0.82)', lineHeight: 1.5 }}>
                    {data.signal.quote}
                  </div>
                  <div style={{ borderTop: '1px solid rgba(245,243,238,0.1)', marginTop: 'auto', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
                    <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
                      <span className="mono" style={{ color: 'rgba(245,243,238,0.5)', letterSpacing: '0.08em', textTransform: 'uppercase', fontSize: 10.5 }}>От</span>
                      <span className="mono" style={{ color: 'rgba(245,243,238,0.9)' }}>{data.signal.from}</span>
                    </div>
                    <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
                      <span className="mono" style={{ color: 'rgba(245,243,238,0.5)', letterSpacing: '0.08em', textTransform: 'uppercase', fontSize: 10.5 }}>Когда</span>
                      <span className="mono" style={{ color: 'rgba(245,243,238,0.9)' }}>{data.signal.when}</span>
                    </div>
                  </div>
                </Card>

                {/* 02 INTELLIGENCE */}
                <Card ci={1}>
                  <div style={{ fontSize: 16, fontWeight: 600, color: 'var(--bg)', lineHeight: 1.3 }}>
                    {data.intel.title}
                  </div>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                    <span className="mono" style={{ fontSize: 10, color: 'rgba(245,243,238,0.5)', letterSpacing: '0.1em' }}>СЕНТИМЕНТ</span>
                    <div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(245,243,238,0.1)', position: 'relative', overflow: 'hidden' }}>
                      <div style={{
                        position: 'absolute', left: 0, top: 0, bottom: 0,
                        width: `${data.intel.sentiment.pct}%`,
                        background: `linear-gradient(90deg, ${toneColor(data.intel.sentiment.tone)}, ${toneColor(data.intel.sentiment.tone)}66)`,
                        transition: 'width 0.5s ease',
                      }} />
                    </div>
                    <span style={{ fontSize: 12, fontWeight: 500, color: toneColor(data.intel.sentiment.tone) }}>
                      {data.intel.sentiment.label}
                    </span>
                  </div>
                  <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 'auto' }}>
                    {data.intel.chips.map(ch => (
                      <span key={ch} style={{
                        fontSize: 11.5, padding: '4px 10px', borderRadius: 999,
                        background: `${COL_COLORS[1]}1c`,
                        border: `1px solid ${COL_COLORS[1]}44`,
                        color: COL_COLORS[1],
                        fontFamily: 'JetBrains Mono, monospace',
                      }}>{ch}</span>
                    ))}
                  </div>
                </Card>

                {/* 03 CONTEXT */}
                <Card ci={2}>
                  <div style={{ fontSize: 16, fontWeight: 600, color: 'var(--bg)', lineHeight: 1.3 }}>
                    {data.context.title}
                  </div>
                  <div style={{ display: 'flex', flexDirection: 'column', gap: 9, marginTop: 'auto' }}>
                    {data.context.rows.map(([k, v]) => (
                      <div key={k} style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: 13 }}>
                        <span className="mono" style={{ color: 'rgba(245,243,238,0.5)', letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: 10.5 }}>{k}</span>
                        <span style={{ color: 'var(--bg)', textAlign: 'right', fontWeight: 500 }}>{v}</span>
                      </div>
                    ))}
                  </div>
                </Card>

                {/* 04 OUTCOMES — accent card */}
                <Card ci={3} accent>
                  <div className="mono" style={{ fontSize: 10.5, color: 'rgba(245,243,238,0.6)', letterSpacing: '0.12em' }}>
                    {data.outcomes.kicker}
                  </div>
                  <div className="display" style={{
                    fontSize: 36, fontWeight: 700, color: COL_COLORS[3],
                    letterSpacing: '-0.02em', lineHeight: 1.05,
                    fontStyle: 'italic',
                  }}>
                    {data.outcomes.headline}
                  </div>
                  <div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: 13, marginTop: 'auto', borderTop: '1px solid rgba(245,243,238,0.1)', paddingTop: 12 }}>
                    <span className="mono" style={{ color: 'rgba(245,243,238,0.5)', letterSpacing: '0.08em', textTransform: 'uppercase', fontSize: 10.5 }}>Драйвер</span>
                    <span style={{ color: 'var(--bg)', textAlign: 'right' }}>{data.outcomes.driver}</span>
                  </div>
                </Card>
              </React.Fragment>
            );
          })()}
        </div>
      </div>

      <style>{`
        @keyframes jm-fade {
          from { opacity: 0; transform: translateY(4px); }
          to   { opacity: 1; transform: none; }
        }
      `}</style>
    </section>
  );
}

function IndustryIcon({ name }) {
  const s = { stroke: 'currentColor', strokeWidth: 1.6, fill: 'none', strokeLinecap: 'round', strokeLinejoin: 'round' };
  const icons = {
    app:    <g {...s}><rect x="6" y="3" width="12" height="18" rx="2.5" /><line x1="6" y1="7" x2="18" y2="7" /><line x1="11" y1="18" x2="13" y2="18" /></g>,
    bottle: <g {...s}><path d="M10 3 H14 V6 L16 9 V20 A1 1 0 0 1 15 21 H9 A1 1 0 0 1 8 20 V9 L10 6 Z" /><line x1="9" y1="13" x2="15" y2="13" /></g>,
    cart:   <g {...s}><path d="M3 5 H5 L7 16 H19 L21 8 H7" /><circle cx="9" cy="20" r="1.2" /><circle cx="17" cy="20" r="1.2" /></g>,
    fork:   <g {...s}><path d="M8 3 V11 A2 2 0 0 1 6 13 A2 2 0 0 1 4 11 V3 M6 13 V21" /><path d="M16 3 C18 3 19 5 19 8 V12 H17 V21" /></g>,
    bank:   <g {...s}><path d="M3 10 L12 4 L21 10 Z" /><line x1="5" y1="12" x2="5" y2="18" /><line x1="9" y1="12" x2="9" y2="18" /><line x1="15" y1="12" x2="15" y2="18" /><line x1="19" y1="12" x2="19" y2="18" /><line x1="3" y1="20" x2="21" y2="20" /></g>,
  };
  return <svg width="22" height="22" viewBox="0 0 24 24">{icons[name] || icons.app}</svg>;
}

window.JourneyMap = JourneyMap;
