Открыть меню
Переключить меню настроек
Открыть персональное меню
Вы не представились системе
Ваш IP-адрес будет виден всем, если вы внесёте какие-либо изменения.

MediaWiki:ScmcChemistry.js

Страница интерфейса MediaWiki

Замечание: Возможно, после публикации вам придётся очистить кэш своего браузера, чтобы увидеть изменения.

  • Firefox / Safari: Удерживая клавишу Shift, нажмите на панели инструментов Обновить либо нажмите Ctrl+F5 или Ctrl+R (⌘+R на Mac)
  • Google Chrome: Нажмите Ctrl+Shift+R (⌘+Shift+R на Mac)
  • Edge: Удерживая Ctrl, нажмите Обновить либо нажмите Ctrl+F5
  • Opera: Нажмите Ctrl+F5.
/**
 * Интерактивный каталог химии SSMC.
 * Страница: MC:Химия
 * Данные: MediaWiki:ScmcChemCatalog.js
 */
(function (mw, $) {
    'use strict';

    var ROOT_ID = 'scmc-chemistry-app';
    var CATALOG_PAGE = 'MediaWiki:ScmcChemCatalog.js';
    var STYLE_PAGE = 'MediaWiki:ScmcChemistry.css';
    var NUMBER = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 3 });
    var TABS = [
        { id: 'ordnance', label: 'Боевая химия' },
        { id: 'medicine', label: 'Медицина' },
        { id: 'drinks', label: 'Напитки' },
        { id: 'elements', label: 'Элементы' },
        { id: 'other', label: 'Другие вещества' }
    ];
    var ORIGIN_LABELS = {
        rmc14: 'RMC14',
        stories: 'Stories',
        'upstream-reference': 'Upstream'
    };
    var METABOLISM_LABELS = {
        Alcohol: 'Алкоголь',
        Drink: 'Напиток',
        Food: 'Пища',
        Medicine: 'Лекарство',
        Narcotic: 'Наркотик',
        Poison: 'Яд'
    };
    var EFFECT_LABELS = {
        Anticorrosive: 'Противокислотное действие',
        Antihallucinogenic: 'Подавление галлюцинаций',
        Antitoxic: 'Антитоксическое действие',
        Biocidic: 'Восстановление клеток',
        Carcinogenic: 'Канцерогенное действие',
        ChemHealEyeDamage: 'Лечит повреждения глаз',
        ChemVomit: 'Вызывает рвоту',
        Corrosive: 'Разъедающее действие',
        CureZombieInfection: 'Лечит зомби-инфекцию',
        CauseZombieInfection: 'Заражает зомби-инфекцией',
        Drunk: 'Вызывает опьянение',
        Electrogenetic: 'Электрогенетическое действие',
        Emote: 'Вызывает непроизвольную реакцию',
        EqualHealthChange: 'Изменяет здоровье',
        FlammableReaction: 'Повышает воспламеняемость',
        Flowing: 'Изменяет текучесть крови',
        Focusing: 'Повышает концентрацию',
        Fueling: 'Топливное действие',
        GenericStatusEffect: 'Накладывает статусный эффект',
        HealthChange: 'Изменяет здоровье',
        Hemogenic: 'Восстанавливает кровь',
        Hypoxemic: 'Вызывает кислородное голодание',
        Jitter: 'Вызывает дрожь',
        Ketogenic: 'Снижает запас питательных веществ',
        ModifyStatusEffect: 'Изменяет статусный эффект',
        MovespeedModifier: 'Изменяет скорость движения',
        Neogenetic: 'Восстановление тканей',
        Nutritious: 'Питательное действие',
        Oxidizing: 'Окисляющее действие',
        Oxygenating: 'Насыщает организм кислородом',
        PopupMessage: 'Показывает сообщение',
        SatiateHunger: 'Утоляет голод',
        SatiateThirst: 'Утоляет жажду',
        Thermostabilizing: 'Стабилизирует температуру тела',
        Toxic: 'Токсическое действие',
        Viscous: 'Замедляющее действие',
        AdjustReagent: 'Изменяет количество реагента'
    };
    var DAMAGE_LABELS = {
        Airloss: 'кислородного голодания',
        Asphyxiation: 'кислородного голодания',
        Bloodloss: 'кровопотери',
        Blunt: 'тупого', Brute: 'механического', Burn: 'термического',
        Caustic: 'кислотного', Cellular: 'клеточного', Cold: 'холодового',
        Genetic: 'генетического', Heat: 'ожогового', Piercing: 'колотого',
        Poison: 'токсического', Radiation: 'радиационного', Slash: 'режущего',
        Toxin: 'токсического'
    };
    var DAMAGE_CLASSES = {
        Airloss: 'oxygen', Asphyxiation: 'oxygen', Bloodloss: 'oxygen',
        Blunt: 'physical', Brute: 'physical', Piercing: 'physical', Slash: 'physical',
        Burn: 'burn', Heat: 'burn', Cold: 'cold', Poison: 'toxin', Toxin: 'toxin',
        Caustic: 'acid', Genetic: 'genetic', Cellular: 'cellular', Radiation: 'radiation'
    };
    var STATUS_LABELS = {
        Drowsiness: 'сонливость', Jitter: 'дрожь', RMCUnconscious: 'потерю сознания',
        ScrambledAccent: 'невнятную речь', StaminaModifier: 'изменение выносливости',
        StatusEffectSeeingRainbow: 'галлюцинации', Unconscious: 'потерю сознания'
    };
    var EMOTE_LABELS = {
        Cough: 'кашель', Gasp: 'судорожный вдох', Laugh: 'смех',
        Scream: 'крик', Sneeze: 'чихание', Yawn: 'зевоту'
    };
    var PLANT_LABELS = {
        PlantAdjustGrowth: 'возраст растения', PlantAdjustHealth: 'здоровье растения',
        PlantAdjustMutationLevel: 'уровень мутации', PlantAdjustMutationMod: 'шанс мутации',
        PlantAdjustNutrition: 'питание растения', PlantAdjustPests: 'число вредителей',
        PlantAdjustPotency: 'потенцию растения', PlantAdjustToxins: 'уровень токсинов',
        PlantAdjustWater: 'уровень воды', PlantAdjustWeeds: 'число сорняков'
    };
    var ORGAN_LABELS = {
        Animal: 'животного', Bloodsucker: 'кровососа', Human: 'человека',
        Rat: 'крысы', Slime: 'слайма', Vox: 'вокса', Xeno: 'ксеноморфа'
    };
    var MOB_STATE_LABELS = {
        Alive: 'жив', Critical: 'критическое состояние', Dead: 'мёртв'
    };
    var REAGENT_LABELS = {
        UncookedAnimalProteins: 'сырые животные белки'
    };
    var state = {
        data: null,
        activeTab: 'ordnance',
        query: '',
        productReactions: Object.create(null)
    };

    function el(tag, className, text) {
        var node = document.createElement(tag);
        if (className) {
            node.className = className;
        }
        if (text !== undefined && text !== null) {
            node.textContent = String(text);
        }
        return node;
    }

    function add(parent) {
        Array.prototype.slice.call(arguments, 1).forEach(function (child) {
            if (child) {
                parent.appendChild(child);
            }
        });
        return parent;
    }

    function formatNumber(value) {
        return typeof value === 'number' ? NUMBER.format(value) : String(value);
    }

    function formatAmount(value) {
        return formatNumber(value) + ' ед.';
    }

    function normalize(value) {
        return String(value || '').toLocaleLowerCase('ru-RU').replace(/ё/g, 'е').trim();
    }

    function capitalizeFirst(value) {
        var text = String(value || '').trim();
        return text ? text.charAt(0).toLocaleUpperCase('ru-RU') + text.slice(1) : text;
    }

    function slug(value) {
        return String(value || 'section')
            .toLocaleLowerCase('ru-RU')
            .replace(/[^a-zа-яё0-9]+/gi, '-')
            .replace(/^-+|-+$/g, '');
    }

    function safeColor(value) {
        var probe = document.createElement('span').style;
        probe.color = '';
        probe.color = String(value || '');
        return probe.color ? value : '#7f8c98';
    }

    function getReagent(id) {
        return state.data.reagents[id] || state.data.dependencies[id] || null;
    }

    function getName(id) {
        var reagent = getReagent(id);
        return capitalizeFirst(reagent && reagent.name ? reagent.name : REAGENT_LABELS[id] || id);
    }

    function getColor(id) {
        var reagent = getReagent(id);
        return safeColor(reagent && reagent.properties && reagent.properties.color);
    }

    function indexReactions() {
        state.productReactions = Object.create(null);
        Object.keys(state.data.reactions || {}).forEach(function (reactionId) {
            var reaction = state.data.reactions[reactionId];
            (reaction.products || []).forEach(function (product) {
                if (!state.productReactions[product.id]) {
                    state.productReactions[product.id] = [];
                }
                state.productReactions[product.id].push(reaction);
            });
        });
    }

    function validateCatalog(data) {
        return data && data.schemaVersion === 1 && data.catalogSections &&
            data.reagents && data.dependencies && data.reactions &&
            TABS.every(function (tab) {
                return Array.isArray(data.catalogSections[tab.id]);
            });
    }

    function loadStyle() {
        mw.loader.load(mw.util.getUrl(STYLE_PAGE, {
            action: 'raw',
            ctype: 'text/css',
            maxage: 0,
            smaxage: 0
        }), 'text/css');
    }

    function loadCatalog() {
        window.ScmcChemCatalog = null;
        return mw.loader.getScript(mw.util.getUrl(CATALOG_PAGE, {
            action: 'raw',
            ctype: 'text/javascript',
            maxage: 0,
            smaxage: 0
        })).then(function () {
            if (!validateCatalog(window.ScmcChemCatalog)) {
                throw new Error('MediaWiki:ScmcChemCatalog.js не содержит поддерживаемый каталог');
            }
            return window.ScmcChemCatalog;
        });
    }

    function renderLoading(root) {
        root.textContent = '';
        add(root, add(el('div', 'scmc-chem__status scmc-chem__status--loading'),
            el('span', 'scmc-chem__spinner'),
            el('span', '', 'Загружаю химический каталог…')
        ));
    }

    function renderError(root, error) {
        root.textContent = '';
        var block = add(el('div', 'scmc-chem__status scmc-chem__status--error'),
            el('strong', '', 'Каталог не загрузился.'),
            el('span', '', ' Проверьте MediaWiki:ScmcChemCatalog.js.')
        );
        var retry = el('button', 'scmc-chem__retry', 'Повторить');
        retry.type = 'button';
        retry.addEventListener('click', function () { start(root); });
        add(block, retry);
        add(root, block);
        if (window.console && console.error) {
            console.error('[ScmcChemistry]', error);
        }
    }

    function createHero() {
        var count = TABS.reduce(function (total, tab) {
            return total + state.data.catalogSections[tab.id].length;
        }, 0);
        return add(el('header', 'scmc-chem__hero'),
            add(el('div', 'scmc-chem__hero-copy'),
                el('p', 'scmc-chem__eyebrow', 'SSMC · справочник'),
                el('h1', 'scmc-chem__title', 'Химия'),
                el('p', 'scmc-chem__lead', 'Реагенты, рецепты, дозировки и эффекты. Данные хранятся прямо на вики.')
            ),
            add(el('div', 'scmc-chem__stats'),
                el('span', 'scmc-chem__stat', count + ' записей'),
                el('span', 'scmc-chem__stat', Object.keys(state.data.reactions).length + ' реакций')
            )
        );
    }

    function renderTabs(root) {
        var tabs = root.querySelector('.scmc-chem__tabs');
        tabs.textContent = '';
        TABS.forEach(function (tab) {
            var button = el('button', 'scmc-chem__tab');
            button.type = 'button';
            button.setAttribute('role', 'tab');
            button.setAttribute('aria-selected', String(tab.id === state.activeTab));
            add(button,
                el('span', '', tab.label),
                el('span', 'scmc-chem__tab-count', state.data.catalogSections[tab.id].length)
            );
            button.addEventListener('click', function () {
                state.activeTab = tab.id;
                renderTabs(root);
                renderResults(root);
            });
            add(tabs, button);
        });
    }

    function groupEntries(entries) {
        var groups = Object.create(null);
        entries.forEach(function (entry) {
            var path = Array.isArray(entry.sectionPath) && entry.sectionPath.length ? entry.sectionPath : ['Без раздела'];
            var key = path.join(' › ');
            if (!groups[key]) {
                groups[key] = { title: path[path.length - 1], path: path, entries: [] };
            }
            groups[key].entries.push(entry);
        });
        return Object.keys(groups).map(function (key) { return groups[key]; });
    }

    function entryMatches(entry) {
        if (!state.query) {
            return true;
        }
        var reagent = getReagent(entry.id) || {};
        var haystack = [entry.id, entry.name, reagent.name, reagent.description]
            .concat(entry.sectionPath || []).join(' ');
        return normalize(haystack).indexOf(state.query) !== -1;
    }

    function renderSectionNav(root, groups) {
        var nav = root.querySelector('.scmc-chem__section-nav');
        nav.textContent = '';
        nav.hidden = groups.length < 2;
        groups.forEach(function (group, index) {
            var link = el('a', 'scmc-chem__section-link');
            link.href = '#scmc-chem-section-' + index + '-' + slug(group.title);
            add(link,
                el('span', '', capitalizeFirst(group.title)),
                el('span', 'scmc-chem__section-link-count', group.entries.length)
            );
            add(nav, link);
        });
    }

    function renderMixture(items, className) {
        var list = el('div', className);
        (items || []).forEach(function (item) {
            add(list, add(el('div', 'scmc-chem__mixture-item'),
                el('span', 'scmc-chem__mixture-dot'),
                el('span', 'scmc-chem__mixture-name', capitalizeFirst(item.name || getName(item.id))),
                el('span', 'scmc-chem__mixture-amount', formatAmount(item.amount))
            ));
            list.lastChild.firstChild.style.backgroundColor = getColor(item.id);
        });
        return list;
    }

    function renderReaction(reaction) {
        var box = el('div', 'scmc-chem__reaction');
        var equation = add(el('div', 'scmc-chem__equation'),
            renderMixture(reaction.reactants, 'scmc-chem__reactants'),
            el('span', 'scmc-chem__arrow', '→'),
            renderMixture(reaction.products, 'scmc-chem__products')
        );
        add(box, el('div', 'scmc-chem__reaction-label', 'Рецепт'), equation);
        if (!reaction.products || !reaction.products.length) {
            add(box, el('div', 'scmc-chem__reaction-warning', 'Реакция не создаёт реагент и может иметь опасный эффект.'));
        }
        return box;
    }

    function effectType(effect) {
        return String(effect && effect.yamlTag || '').replace(/^!type:/, '') || 'Эффект';
    }

    function action(text, kind) {
        return el('span', 'scmc-chem__effect-action scmc-chem__effect-action--' + kind, text);
    }

    function damageType(type) {
        var className = DAMAGE_CLASSES[type] || 'physical';
        return el('span', 'scmc-chem__damage-type scmc-chem__damage-type--' + className, DAMAGE_LABELS[type] || type);
    }

    function line() {
        var node = el('div', 'scmc-chem__effect-details');
        Array.prototype.slice.call(arguments).forEach(function (part) {
            node.appendChild(part && part.nodeType ? part : document.createTextNode(String(part)));
        });
        return node;
    }

    function probabilityText(value) {
        return value === undefined ? '' : ' · вероятность ' + formatNumber(value * 100) + '%';
    }

    function damageEntries(value) {
        var damage = value && value.damage;
        var result = [];
        if (Array.isArray(damage)) {
            damage.forEach(function (item) {
                Object.keys(item || {}).forEach(function (type) {
                    result.push({ type: type, amount: item[type] });
                });
            });
        } else if (damage && typeof damage === 'object') {
            ['groups', 'types'].forEach(function (section) {
                Object.keys(damage[section] || {}).forEach(function (type) {
                    result.push({ type: type, amount: damage[section][type] });
                });
            });
            if (!damage.groups && !damage.types) {
                Object.keys(damage).forEach(function (type) {
                    if (typeof damage[type] === 'number') {
                        result.push({ type: type, amount: damage[type] });
                    }
                });
            }
        }
        return result;
    }

    function effectKind(type, value) {
        var beneficial = /^(Anticorrosive|Antihallucinogenic|Antitoxic|Biocidic|ChemHealEyeDamage|CureZombieInfection|Electrogenetic|Focusing|Hemogenic|Neogenetic|Nutritious|Oxygenating|SatiateHunger|SatiateThirst|Thermostabilizing)$/;
        var harmful = /^(Carcinogenic|CauseZombieInfection|ChemVomit|Corrosive|Fueling|Hypoxemic|Jitter|Ketogenic|Oxidizing|Toxic|Viscous)$/;
        if (type === 'HealthChange' || type === 'EqualHealthChange') {
            var damage = damageEntries(value);
            return damage.length && damage.every(function (item) { return item.amount <= 0; }) ? 'beneficial' :
                damage.length && damage.every(function (item) { return item.amount >= 0; }) ? 'harmful' : 'warning';
        }
        if (beneficial.test(type)) {
            return 'beneficial';
        }
        if (harmful.test(type)) {
            return 'harmful';
        }
        return 'warning';
    }

    function conditionText(condition, reagent, hideDoseCondition) {
        var type = effectType(condition);
        var value = condition.value || {};
        if (type === 'ReagentThreshold') {
            if (hideDoseCondition) {
                return '';
            }
            return 'при содержании ' + (value.reagent ? getName(value.reagent) + ' ' : '') +
                (value.min !== undefined ? 'от ' + formatAmount(value.min) : '') +
                (value.max !== undefined ? ' до ' + formatAmount(value.max) : '');
        }
        if (type === 'Temperature') {
            return 'при температуре ' +
                (value.min !== undefined ? 'от ' + formatNumber(value.min) + ' K' : '') +
                (value.max !== undefined ? 'до ' + formatNumber(value.max) + ' K' : '');
        }
        if (type === 'MobStateCondition') {
            var stateName = value.mobstate || value.state || 'особое';
            return 'если цель находится в состоянии: ' + (MOB_STATE_LABELS[stateName] || stateName);
        }
        if (type === 'OrganType') {
            var organ = value.type || value.organType || 'подходящего типа';
            return 'для организма ' + (ORGAN_LABELS[organ] || organ);
        }
        return 'при выполнении дополнительного условия';
    }

    function renderEffect(effect, reagent, tier) {
        var type = effectType(effect);
        var value = effect.value && typeof effect.value === 'object' ? effect.value : {};
        var kind = effectKind(type, value);
        var card = el('div', 'scmc-chem__effect scmc-chem__effect--' + kind);
        add(card, el('div', 'scmc-chem__effect-name', EFFECT_LABELS[type] || ('Эффект «' + type + '»')));

        var damage = damageEntries(value);
        if (damage.length) {
            damage.forEach(function (item) {
                var healing = item.amount < 0;
                var oxygenLike = /^(Airloss|Asphyxiation|Bloodloss)$/.test(item.type);
                add(card, oxygenLike ? line(
                    action(healing ? 'Лечит' : 'Наносит', healing ? 'beneficial' : 'harmful'),
                    ' ' + formatNumber(Math.abs(item.amount)) + ' ед. урона от ', damageType(item.type)
                ) : line(
                    action(healing ? 'Лечит' : 'Наносит', healing ? 'beneficial' : 'harmful'),
                    ' ' + formatNumber(Math.abs(item.amount)) + ' ед. ', damageType(item.type), ' урона'
                ));
            });
        } else if (/^(Antitoxic|Toxic)$/.test(type)) {
            add(card, line(action(type === 'Antitoxic' ? 'Лечит' : 'Наносит', type === 'Antitoxic' ? 'beneficial' : 'harmful'),
                ' ' + formatNumber(value.potency || 1) + ' ед. ', damageType('Toxin'), ' урона'));
        } else if (/^(Biocidic|Carcinogenic)$/.test(type)) {
            add(card, line(action(type === 'Biocidic' ? 'Лечит' : 'Наносит', type === 'Biocidic' ? 'beneficial' : 'harmful'),
                ' ' + formatNumber(value.potency || 1) + ' ед. ', damageType('Cellular'), ' урона'));
        } else if (/^(Neogenetic|Electrogenetic)$/.test(type)) {
            add(card, line(action('Лечит', 'beneficial'), ' ' + formatNumber(value.potency || 1) + ' ед. ', damageType(type === 'Neogenetic' ? 'Brute' : 'Genetic'), ' урона'));
        } else if (/^(Anticorrosive|Corrosive)$/.test(type)) {
            add(card, line(action(type === 'Anticorrosive' ? 'Лечит' : 'Наносит', type === 'Anticorrosive' ? 'beneficial' : 'harmful'),
                ' ' + formatNumber(value.potency || 1) + ' ед. ', damageType('Caustic'), ' урона'));
        } else if (/^(Oxygenating|Hypoxemic)$/.test(type)) {
            add(card, line(action(type === 'Oxygenating' ? 'Лечит' : 'Наносит', type === 'Oxygenating' ? 'beneficial' : 'harmful'),
                ' ' + formatNumber(value.potency || 1) + ' ед. урона от ', damageType('Airloss')));
        } else if (type === 'Oxidizing') {
            add(card, line(action('Наносит', 'harmful'), ' ' + formatNumber(value.potency || 1) + ' ед. ', damageType('Burn'), ' урона'));
        } else if (type === 'Hemogenic') {
            add(card, line(action('Восстанавливает', 'beneficial'), ' кровь · сила ' + formatNumber(value.potency || 1)));
        } else if (type === 'AdjustReagent') {
            var amount = Number(value.amount || 0);
            add(card, line(action(amount < 0 ? 'Удаляет' : 'Добавляет', amount < 0 ? 'beneficial' : 'utility'),
                ' ' + formatAmount(Math.abs(amount)) + ' реагента «' + getName(value.reagent) + '»'));
        } else if (type === 'GenericStatusEffect' || type === 'ModifyStatusEffect') {
            var status = value.component || value.key || value.effectProto || 'статусный эффект';
            add(card, line(action(value.type === 'Remove' ? 'Снимает' : 'Вызывает', value.type === 'Remove' ? 'beneficial' : 'harmful'),
                ' ' + (STATUS_LABELS[status] || status) + (value.time !== undefined ? ' на ' + formatNumber(value.time) + ' с' : '')));
        } else if (type === 'Emote') {
            add(card, line(action('Вызывает', 'warning'), ' ' + (EMOTE_LABELS[value.emote] || value.emote || 'непроизвольную реакцию') + probabilityText(value.probability)));
        } else if (type === 'Jitter') {
            add(card, line(action('Вызывает', 'harmful'), ' дрожь' + probabilityText(value.probability)));
        } else if (type === 'ChemVomit') {
            add(card, line(action('Вызывает', 'harmful'), ' рвоту' + probabilityText(value.probability)));
        } else if (type === 'SatiateThirst' || type === 'SatiateHunger') {
            add(card, line(action(type === 'SatiateThirst' ? 'Утоляет жажду' : 'Утоляет голод', 'beneficial'),
                value.factor !== undefined ? ' · коэффициент ' + formatNumber(value.factor) : ''));
        } else if (type === 'Drunk') {
            add(card, line(action('Опьяняет', 'warning'), value.boozePower !== undefined ? ' · сила ' + formatNumber(value.boozePower) : ''));
        } else if (type === 'MovespeedModifier') {
            add(card, line(action('Изменяет скорость', 'utility'),
                ' · ходьба ×' + formatNumber(value.walkSpeedModifier || 1) + ' · бег ×' + formatNumber(value.sprintSpeedModifier || 1)));
        } else if (type === 'CureZombieInfection' || type === 'CauseZombieInfection') {
            add(card, line(action(type === 'CureZombieInfection' ? 'Лечит' : 'Заражает', type === 'CureZombieInfection' ? 'beneficial' : 'harmful'),
                (type === 'CureZombieInfection' ? ' зомби-инфекцию' : ' зомби-инфекцией') + (value.innoculate ? ' и создаёт иммунитет' : '')));
        } else if (type === 'PopupMessage') {
            add(card, line(action('Показывает', 'utility'), ' игровое сообщение' + probabilityText(value.probability)));
        } else {
            var potency = value.potency !== undefined ? ' · сила ' + formatNumber(value.potency) : '';
            var multiplier = value.multiplier !== undefined ? ' · множитель ' + formatNumber(value.multiplier) : '';
            add(card, line(action(EFFECT_LABELS[type] || 'Воздействует', kind === 'beneficial' ? 'beneficial' : kind === 'harmful' ? 'harmful' : 'utility'), potency + multiplier + probabilityText(value.probability)));
        }

        (value.conditions || []).forEach(function (condition) {
            var text = conditionText(condition, reagent, tier !== 'normal' && effectType(condition) === 'ReagentThreshold');
            if (text) {
                add(card, el('div', 'scmc-chem__effect-condition', 'Условие: ' + text));
            }
        });
        return card;
    }

    function effectDose(effect, reagent) {
        var conditions = effect.value && effect.value.conditions || [];
        var mins = conditions.filter(function (condition) {
            var value = condition.value || {};
            return effectType(condition) === 'ReagentThreshold' &&
                (!value.reagent || value.reagent === reagent.id) && value.min !== undefined;
        }).map(function (condition) { return Number(condition.value.min); });
        return mins.length ? Math.max.apply(Math, mins) : null;
    }

    function effectTier(effect, reagent) {
        var dose = effectDose(effect, reagent);
        var properties = reagent.properties || {};
        if (dose !== null && properties.criticalOverdose !== undefined && dose >= properties.criticalOverdose) {
            return 'critical';
        }
        if (dose !== null && properties.overdose !== undefined && dose >= properties.overdose) {
            return 'overdose';
        }
        return 'normal';
    }

    function tierTitle(tier, reagent) {
        var properties = reagent.properties || {};
        if (tier === 'critical') {
            return 'Критическая передозировка' + (properties.criticalOverdose !== undefined ? ' · от ' + formatAmount(properties.criticalOverdose) : '');
        }
        if (tier === 'overdose') {
            return 'Передозировка' + (properties.overdose !== undefined ? ' · от ' + formatAmount(properties.overdose) : '');
        }
        return 'Обычная доза' + (properties.overdose !== undefined ? ' · до ' + formatAmount(properties.overdose) : '');
    }

    function renderEffects(effects, reagent) {
        var list = el('div', 'scmc-chem__effect-list');
        if (!effects || !effects.length) {
            add(list, el('p', 'scmc-chem__empty', 'Эффекты не указаны.'));
            return list;
        }
        var tiers = { normal: [], overdose: [], critical: [] };
        effects.forEach(function (effect) {
            tiers[effectTier(effect, reagent)].push(effect);
        });
        ['normal', 'overdose', 'critical'].forEach(function (tier) {
            if (!tiers[tier].length) {
                return;
            }
            var block = el('div', 'scmc-chem__effect-tier scmc-chem__effect-tier--' + tier);
            add(block, el('div', 'scmc-chem__effect-tier-title', tierTitle(tier, reagent)));
            tiers[tier].forEach(function (effect) {
                add(block, renderEffect(effect, reagent, tier));
            });
            add(list, block);
        });
        return list;
    }

    function plantEffectKind(type, amount) {
        if (/Mutation/.test(type)) {
            return 'warning';
        }
        var harmfulWhenPositive = /Toxins|Weeds|Pests/.test(type);
        return (harmfulWhenPositive ? amount <= 0 : amount >= 0) ? 'beneficial' : 'harmful';
    }

    function renderPlantEffects(effects) {
        var list = el('div', 'scmc-chem__effect-list');
        (effects || []).forEach(function (effect) {
            var type = effectType(effect);
            var value = effect.value || {};
            if (type === 'PlantCryoxadone') {
                var special = el('div', 'scmc-chem__effect scmc-chem__effect--beneficial');
                add(special, el('div', 'scmc-chem__effect-name', 'Криогенное восстановление'));
                add(special, line(action('Омолаживает', 'beneficial'), ' растение при низкой температуре'));
                add(list, special);
                return;
            }
            var amount = Number(value.amount || 0);
            var kind = plantEffectKind(type, amount);
            var card = el('div', 'scmc-chem__effect scmc-chem__effect--' + kind);
            add(card, el('div', 'scmc-chem__effect-name', capitalizeFirst(PLANT_LABELS[type] || ('Эффект «' + type + '»'))));
            add(card, line(
                action(amount >= 0 ? 'Повышает' : 'Снижает', kind === 'beneficial' ? 'beneficial' : kind === 'harmful' ? 'harmful' : 'warning'),
                ' ' + (PLANT_LABELS[type] || 'показатель') + ' на ' + formatNumber(Math.abs(amount)) +
                    probabilityText(value.probability)
            ));
            add(list, card);
        });
        return list;
    }

    function renderMetabolisms(reagent) {
        var wrapper = el('div', 'scmc-chem__card-section');
        add(wrapper, el('h4', 'scmc-chem__card-subtitle', 'Воздействие'));
        var metabolisms = reagent.properties && reagent.properties.metabolisms;
        var keys = Object.keys(metabolisms || {});
        if (!keys.length) {
            add(wrapper, el('p', 'scmc-chem__empty', 'Метаболические эффекты не указаны.'));
            return wrapper;
        }
        keys.forEach(function (key) {
            var metabolism = metabolisms[key] || {};
            var block = el('div', 'scmc-chem__metabolism');
            var title = METABOLISM_LABELS[key] || key;
            if (metabolism.metabolismRate !== undefined) {
                title += ' · ' + formatAmount(metabolism.metabolismRate) + '/с';
            }
            add(block, el('div', 'scmc-chem__metabolism-title', title), renderEffects(metabolism.effects, reagent));
            add(wrapper, block);
        });
        return wrapper;
    }

    function renderPlantMetabolism(reagent) {
        var effects = reagent.properties && reagent.properties.plantMetabolism;
        if (!Array.isArray(effects) || !effects.length) {
            return null;
        }
        var wrapper = el('div', 'scmc-chem__card-section');
        var block = el('div', 'scmc-chem__metabolism');
        add(wrapper, el('h4', 'scmc-chem__card-subtitle', 'Воздействие на растения'));
        add(block, el('div', 'scmc-chem__metabolism-title', 'Метаболизм растений'), renderPlantEffects(effects));
        add(wrapper, block);
        return wrapper;
    }

    function renderCard(entry) {
        var reagent = getReagent(entry.id) || {
            id: entry.id,
            name: entry.name,
            origin: entry.origin,
            properties: {}
        };
        var color = safeColor(reagent.properties && reagent.properties.color);
        var details = el('details', 'scmc-chem__card');
        details.style.setProperty('--scmc-chem-color', color);
        var summary = el('summary', 'scmc-chem__card-summary');
        var heading = add(el('span', 'scmc-chem__card-heading'),
            el('span', 'scmc-chem__card-name', capitalizeFirst(reagent.name || entry.name || entry.id)),
            el('code', 'scmc-chem__card-id', entry.id)
        );
        add(summary,
            el('span', 'scmc-chem__color'),
            heading,
            el('span', 'scmc-chem__origin scmc-chem__origin--' + (reagent.origin || entry.origin || 'upstream-reference'), ORIGIN_LABELS[reagent.origin || entry.origin] || reagent.origin || entry.origin),
            el('span', 'scmc-chem__chevron')
        );
        summary.firstChild.style.backgroundColor = color;
        var body = el('div', 'scmc-chem__card-body');
        add(body, el('p', reagent.description ? 'scmc-chem__description' : 'scmc-chem__empty', reagent.description || 'Описание не указано.'));
        var overdose = reagent.properties && reagent.properties.overdose;
        var critical = reagent.properties && reagent.properties.criticalOverdose;
        if (overdose !== undefined || critical !== undefined) {
            var doses = el('div', 'scmc-chem__dose-row');
            if (overdose !== undefined) {
                add(doses, el('span', 'scmc-chem__dose scmc-chem__dose--warning', 'Передозировка: ' + formatAmount(overdose)));
            }
            if (critical !== undefined) {
                add(doses, el('span', 'scmc-chem__dose scmc-chem__dose--danger', 'Критическая: ' + formatAmount(critical)));
            }
            add(body, doses);
        }
        var reactions = state.productReactions[entry.id] || [];
        if (reactions.length) {
            var recipeSection = el('div', 'scmc-chem__card-section');
            add(recipeSection, el('h4', 'scmc-chem__card-subtitle', 'Получение'));
            reactions.forEach(function (reaction) { add(recipeSection, renderReaction(reaction)); });
            add(body, recipeSection);
        }
        add(body, renderMetabolisms(reagent));
        add(body, renderPlantMetabolism(reagent));
        add(details, summary, body);
        return details;
    }

    function renderResults(root) {
        var result = root.querySelector('.scmc-chem__results');
        var counter = root.querySelector('.scmc-chem__result-count');
        result.textContent = '';
        var entries = state.data.catalogSections[state.activeTab].filter(entryMatches);
        var groups = groupEntries(entries);
        counter.textContent = entries.length + ' найдено';
        renderSectionNav(root, groups);
        if (!entries.length) {
            add(result, el('div', 'scmc-chem__no-results', 'Ничего не найдено. Измените запрос.'));
            return;
        }
        groups.forEach(function (group, index) {
            var section = el('section', 'scmc-chem__section');
            section.id = 'scmc-chem-section-' + index + '-' + slug(group.title);
            var heading = add(el('div', 'scmc-chem__section-heading'),
                el('h2', 'scmc-chem__section-title', capitalizeFirst(group.title)),
                el('span', 'scmc-chem__breadcrumbs', group.path.map(capitalizeFirst).join(' › ')),
                el('span', 'scmc-chem__section-count', group.entries.length)
            );
            var cards = el('div', 'scmc-chem__cards');
            group.entries.forEach(function (entry) { add(cards, renderCard(entry)); });
            add(section, heading, cards);
            add(result, section);
        });
    }

    function renderApp(root) {
        root.textContent = '';
        add(root, createHero());
        var controls = el('div', 'scmc-chem__controls');
        var search = el('label', 'scmc-chem__search');
        var input = el('input', 'scmc-chem__search-input');
        input.type = 'search';
        input.placeholder = 'Название, ID или раздел…';
        input.autocomplete = 'off';
        input.setAttribute('aria-label', 'Поиск по химическому каталогу');
        input.addEventListener('input', function () {
            state.query = normalize(input.value);
            renderResults(root);
        });
        add(search, el('span', 'scmc-chem__search-icon', '⌕'), input);
        add(controls, search, el('span', 'scmc-chem__result-count'));
        add(root, controls, el('div', 'scmc-chem__tabs'));
        var nav = el('nav', 'scmc-chem__section-nav');
        nav.setAttribute('aria-label', 'Подразделы каталога');
        add(root, nav, el('main', 'scmc-chem__results'));
        var source = state.data.source || {};
        add(root, el('footer', 'scmc-chem__footer', 'Локальный каталог MediaWiki' + (source.commit ? ' · версия ' + String(source.commit).slice(0, 8) : '')));
        renderTabs(root);
        renderResults(root);
    }

    function start(root) {
        renderLoading(root);
        loadCatalog().then(function (data) {
            state.data = data;
            indexReactions();
            renderApp(root);
        }).catch(function (error) {
            renderError(root, error);
        });
    }

    function init() {
        var root = document.getElementById(ROOT_ID);
        if (!root) {
            return;
        }
        loadStyle();
        start(root);
    }

    $(init);
}(mediaWiki, jQuery));