PageSourceSearch

https://planethub.ru/app.js?v=0.10.145

js planethub.ru collected 2026-10-07 19:57:57 UTC 221,664 bytes, 3,135 lines download raw bytes

1
2const D=window.EVE_PI_DATA;
3const $=id=>document.getElementById(id);
4const products=new Map(D.products.map(p=>[p.id,p]));
5
6const PROJECT_SCHEMA="eve-pi-planner-project";
7const PROJECT_VERSION=85;
8const METRIKA_COUNTER_ID=111613438;
9
10let current=null;
11let selected=[];
12let clones=[{name:"Клон 1",limit:6,ccLevel:6}];
13let authorizedClones=[],accountCsrf="",savedPlanCount=0;
14let planetSettings={};
15let globalPlanetSettings={maxP0:99,p0p1:true,p1p2:true,p2p3:true,p3p4:true};
16let productionSettings={separateP0:true,sharedP0Limit:1,separateFactories:false,factoryLimit:1};
17let storageSettings={};
18let planetTemplateSettings={};
19let planetCounts=Object.fromEntries(D.meta.planetTypes.map(pt=>[pt,0]));
20let planetCountsDirty=false;
21let allocationMemo=new Map();
22let targetColors={};
23let marketSettings={enabled:false,hub:"Jita",tax:3.6,broker:0,customsTax:2,hauling:0};
24let marketTrends=new Map(),marketPrices=new Map(),marketPriceDate=null,marketLoading=false,marketLoadToken=0;
25let profitabilityRankingCache=[],profitabilityRankingSignature="",profitabilityRankingVerifiedSignature="";
26let profitabilityRankingPromise=null,profitabilityRankingPromiseSignature="",profitabilityRankingPromiseRevision=-1;
27let profitabilityRankingToken=0,profitabilityStateRevision=0,profitabilityRankingStage="";
28let autosaveWarningShown=false;
29let autoProfitRunning=false,autoProfitToken=0;
30const trackedPlanSignatures=new Set();
31let planetAutoApplyTimer=null;
32let selectedSystemInfo=null,systemLookupToken=0,systemMessageTimer=null;
33const MARKET_HUBS={
34  Jita:{region:10000002,system:30000142},
35  Amarr:{region:10000043,system:30002187},
36  Dodixie:{region:10000032,system:30002659},
37  Rens:{region:10000030,system:30002510}
38};
39const PLANET_TYPE_BY_ID={11:"Temperate",12:"Ice",13:"Gas",2014:"Oceanic",2015:"Lava",2016:"Barren",2017:"Storm",2063:"Plasma"};
40const SYSTEM_CACHE_TTL=24*60*60*1000;
41// Актуальные объёмы единицы PI-материала в Tranquility (м³).
42const PRODUCT_VOLUME={0:.005,1:.19,2:.75,3:3,4:50};
43const TRANSFER_EPSILON=1e-7;
44const STORAGE_CAPACITY={launchpad:10000,storage:12000};
45const PLANET_STRUCTURE_TYPE_ID={
46  Barren:{launchpad:2544,storage:2541},Gas:{launchpad:2543,storage:2536},Ice:{launchpad:2552,storage:2257},Lava:{launchpad:2555,storage:2558},
47  Oceanic:{launchpad:2542,storage:2535},Plasma:{launchpad:2556,storage:2560},Storm:{launchpad:2557,storage:2561},Temperate:{launchpad:2256,storage:2562}
48};
49function planetStructureTypeId(planetType,kind){
50  const generated=window.PLANET_HUB_TEMPLATES?.structureTypeIds?.(planetType)?.[kind];
51  return Number(generated)||(PLANET_STRUCTURE_TYPE_ID[planetType]||PLANET_STRUCTURE_TYPE_ID.Barren)[kind];
52}
53// Fixed taxable values used by EVE Online customs offices (ISK per unit).
54const CUSTOMS_TAX_VALUE={0:5,1:400,2:7200,3:60000,4:1200000};
55const TARGET_PALETTE=["#ff6b6b","#4dabf7","#ffd43b","#69db7c","#da77f2","#ff922b","#38d9a9","#748ffc","#f06595","#a9e34b","#22b8cf","#ffa8a8"];
56
57let siteLanguage="ru";
58try{siteLanguage=localStorage.getItem("planetHubLanguage")==="en"?"en":"ru"}catch(_error){}
59const originalText=new WeakMap(),originalAttributes=new WeakMap();
60const EN_TRANSLATIONS=[
61  ["PLANET HUB Планетарный планировщик","PLANET HUB Planetary Industry Planner"],
62  ["Здесь вы можете быстро определить, что можете добывать и производить","Quickly determine what you can extract and produce"],
63  ["исходя из имеющихся планет в системе, количества клонов и планет, которые они могут освоить.","from the planets in your system, the number of clones, and the planets they can colonize."],
64  ["ДОСТУПНЫЕ ПЛАНЕТЫ","AVAILABLE PLANETS"],["Количество планет по типам","Planet counts by type"],
65  ["Укажите фактическое количество планет, которыми располагаете.","Enter the actual number of planets available to you."],
66  ["Введите систему","Enter a system"],["Название солнечной системы","Solar system name"],["Найти","Find"],
67  ["ПОТЕНЦИАЛ ПРОИЗВОДСТВА","PRODUCTION POTENTIAL"],["КЛОНЫ","CLONES"],
68  ["Доступные клоны","Available clones"],["Для каждого клона укажите, сколько планет он может освоить. Максимум — 6.","Set the colony limit for each clone. Maximum: 6."],
69  ["Добавить клона","Add clone"],["Применить","Apply"],["Клонов:","Clones:"],["Всего доступно планет:","Total planets available:"],
70  ["Уровень КЦ:","CC level:"],["Клон не выбран","Clone not selected"],
71  ["Укажите количество планет","Enter planet counts"],["После заполнения формы появятся настройки конкретных планет и доступные материалы P0–P4.","After completing the form, planet settings and available P0–P4 materials will appear."],
72  ["После каждого выбора остальные позиции пересчитываются с учётом клонов и настроек конкретных планет.","Every selection recalculates the remaining options using clone and planet settings."],
73  ["Показывать недоступные","Show unavailable"],["ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ","ADVANCED SETTINGS"],
74  ["Отдельная добыча P0 для каждого компонента","Separate P0 extraction for each component"],
75  ["Включено: одинаковый P0 для разных выбранных компонентов добывается отдельными лиÐ
75½Ð¸ÑÐ¼Ð¸ на разных планетах. Выключено: несколько P0-линий можно разместить вместе. Заданный лимит добытчиков P0 действует в обоих режимах и учитывается автоподбором.","Enabled: the same P0 required by different selected components uses separate extraction lines on different planets. Disabled: multiple P0 lines may share a planet. The selected P0 extractor limit applies in both modes and is respected by auto-build."],
76  ["Максимальное количество добывающих P0-линий на одной планете","Maximum number of P0 extraction lines on one planet"],
77  ["Добытчиков P0 на одной планете","P0 extraction lines per planet"],["Перерабатывать P0–P1 сразу?","Process P0–P1 locally?"],
78  ["Описание раздельной добычи P0 и лимита добытчиков","Separate P0 extraction and extractor-limit description"],
79  ["Флажок запрещает объединять одну добывающую лиÐ
79½Ð¸ÑŽ P0 между разными выбранными материалами. Число справа задаёт максимум добывающих P0-линий именно на одной планете, а не во всём плане. Например, при лимите 1 две отдельные линии будут размещены на двух планетах.","The checkbox prevents one P0 extraction line from being shared by different selected materials. The number on the right limits P0 extraction lines on each planet, not in the whole plan. For example, with a limit of 1, two separate lines are placed on two planets."],
80  ["Описание переработки P0–P1","P0–P1 processing description"],
81  ["Включено: добытый P0 перерабатывается в P1 на той же планете. Выключено: планета только добывает P0, а переработка в P1 на ней не размещается.","Enabled: extracted P0 is processed into P1 on the same planet. Disabled: the planet only extracts P0 and does not host P1 processing."],
82  ["Отдельные сборочные планеты","Dedicated factory planets"],
83  ["Максимум загруженности производства","Maximum production utilization"],["Оценка стоимости материалов","Material value estimate"],
84  ["Количество производственных линий на одной планете","Number of production lines per planet"],
85  ["Производственных линий на одной планете","Production lines per planet"],["Описание производственных линий","Production line limit description"],
86  ["Задаёт максимальное количество производственных заводов на одной планете. Учитываются все уровни: P0–P1, P1–P2, P2–P3 и P3–P4. Например, один завод Silicon и один завод Rocket Fuel считаются как 2 линии. ECU, космопорты и хранилища в лимит не входят.","Sets the maximum number of production facilities on one planet. Every tier counts: P0–P1, P1–P2, P2–P3, and P3–P4. For example, one Silicon facility and one Rocket Fuel facility count as 2 lines. ECUs, Launchpads, and Storage Facilities do not count toward this limit."],
87  ["Налог с продаж, %","Sales tax, %"],["Брокерская комиссия, %","Broker fee, %"],["Ставка налога таможни, %","Customs tax rate, %"],
88  ["Перевозка, ISK за м³","Hauling, ISK per m³"],["Цены ещё не загружены","Prices have not been loaded"],["Обновить цены","Refresh prices"],
89  ["Построить план","Build plan"],["Автоподбор использует заданные налоги, перевозку, лимит добытчиков P0 и лимит производственных линий. Включённый режим отдельных сборочных планет сохраняется.","Auto-build uses your taxes, hauling cost, P0 extractor limit, and production-line limit. Enabled dedicated factory planets remain enabled."],
90  ["Отзыв или предложение в Telegram","Feedback or suggestion on Telegram"],["Отзыв или предложение","Feedback or suggestion"],
91  ["Открыть инструкцию","Open user guide"],["Закрыть инструкцию","Close user guide"],
92  ["Инструкция по использованию","User guide"],["СПРАВКА","HELP"],["Назад","Previous"],["Далее","Next"],
93  ["ПРЕДПРОСМОТР PI-ШАБЛОНА","PI TEMPLATE PREVIEW"],["Шаблон планеты","Planet template"],
94  ["Закрыть предпросмотр","Close preview"],["Фактическое расположение построек","Actual structure layout"],
95  ["Потенциальное расположение в EVE по координатам шаблона и фактические линии связи.","Potential EVE layout from the template coordinates with its actual links."],
96  ["ЧТО ДЕЛАЕТ ШАБЛОН","WHAT THIS TEMPLATE DOES"],["СОСТАВ","COMPOSITION"],
97  ["РЕСУРСЫ КОМАНДНОГО ЦЕНТРА","COMMAND CENTER RESOURCES"],["ОБОЗНАЧЕНИЯ","LEGEND"],
98  ["Посмотреть шаблон","Preview template"],["Скопировать шаблон","Copy template"],["Закрыть","Close"],
99  ["Нажмите на изображение для увеличения","Click the image to zoom"],["Шаги инструкции","Guide steps"],["Инструкция — шаг","Guide — step"],["Шаг","Step"],
100  ["Перебираем производственные конфигурации…","Testing production configurations…"],["Прибыльный вариант не найден.","No profitable plan was found."],["Оптимальный план построен:","Optimal plan built:"],
101  ["ТЕКУЩИЙ ПЛАН","CURRENT PLAN"],["Снять все","Clear all"],["По клонам","By clone"],["Цепочка","Chain"],
102  ["Итоговая стоимость плана","Final plan value"],["Загрузка цен…","Loading prices…"],
103  ["ЭКОНОМИКА ТЕКУЩЕГО ПЛАНА","CURRENT PLAN ECONOMICS"],["Итоговая стоимость полученного материала","Final value of produced material"],
104  ["НЕОБХОДИМАЯ СТАБИЛЬНАЯ ДОБЫЧА P0 ДЛЯ РАБОТЫ 24/7","STABLE P0 EXTRACTION REQUIRED FOR 24/7 OPERATION"],
105  ["Если фактическая добыча ниже, заводы будут простаивать и реальный результат окажется меньше прогноза.","If actual extraction is lower, factories will idle and actual output will be below the forecast."],
106  ["ПЕРЕДАЧИ МЕЖДУ КЛОНАМИ","TRANSFERS BETWEEN CLONES"],["планет задействовано","planets used"],["Свободно планет у клона:","Unused planets for clone:"],
107  [" — сборочная"," — assembly"],["передать:","transfer:"],["Добыча P0","P0 extraction"],["Рецептурная цепочка построена из PI-схем SDE.","The production chain is built from SDE PI schematics."],
108  ["Ограничения конкретных планет влияют на размещение во вкладке «По клонам».","Planet constraints affect placement on the ‘By clone’ tab."],
109  ["Выбрано","Selected"],["Задействовано планет","Planets used"],["Минимум планет","Minimum planets"],["Доступно клонам","Available to clones"],["Свободно планет","Unused planets"],
110  ["План невыполним","Plan is not feasible"],["Клон","Clone"],["Количество планет:","Planet count:"],
111  ["Ручной состав","Manual composition"],["сутки","day"],["дней","days"],["Выпуск:","Output:"],
112  ["Загружаем рыночные цены…","Loading market prices…"],
113  ["Для расчёта экономики не хватает актуальных рыночных данных.","Current market data is unavailable for this estimate."],
114  ["Включено: добытый P0 сразу перерабатывается в P1 на той же планете. Выключено: добывающая планета производит только P0, а переработка P0–P1 переносится на сборочную планету.","
114Enabled: extracted P0 is immediately processed into P1 on the same planet. Disabled: the extraction planet produces only P0, while P0–P1 processing moves to a factory planet."],
115  ["Включено: заводы P1–P2, P2–P3 и P3–P4 размещаются только на планетах без добычи P0. Выключено: эти заводы могут совмещаться с добычей, если позволяют заданные ограничения.","Enabled: P1–P2, P2–P3, and P3–P4 factories are placed only on planets without P0 extraction. Disabled: these factories may share extraction planets when the selected limits allow it."],
116  ["Включено: свободные планеты сначала повторяют полную выбранную цепочку, затем производят её P3, P2, P1 и P0. Выключено: выбранная цепочка строится только один раз.","Enabled: free planets first repeat the complete selected chain, then produce its P3, P2, P1, and P0. Disabled: the selected chain is built only once."],
117  ["Включено: программа сравнивает доступные производственные конфигурации по ориентировочной чистой продаже за 30 дней, включая полезную продукцию оставшихся планет при максимальной загрузке. Выключено: рыночные запросы не выполняются.","Enabled: available production configurations are compared by estimated net sales over 30 days, including useful output from remaining planets at maximum utilization. Disabled: no market requests are made."],
118  ["При межпланетной перевозке учитываются оба сбора: экспорт с исходной планеты и импорт на следующую. Импорт рассчитывается как 50% экспортного сбора.","Interplanetary hauling includes both fees: export from the source planet and import to the destination. The import fee is calculated as 50% of the export fee."],
119  ["Сравнивается ориентировочная чистая продажа за 30 дней. Выпуск ограничивается реальным узким местом всей рецептурной цепочки и средней ликвидностью рынка. Расчётная цена — средневзвешенная цена фактических сделок за последние 30 завершённых дней. Учтены налог с продаж, брокерская комиссия, экспорт и импорт через таможню, а также перевозка; добыча P0 остаётся ориентировочной без заданного дебита экстракторов.","Estimated net sales over 30 days are compared. Output is limited by the actual bottleneck of the entire production chain and average market liquidity. The calculated price is the volume-weighted average of actual trades over the last 30 completed days. Sales tax, broker fee, customs export and import, and hauling are included; P0 extraction remains an estimate without specified extractor yields."],
120  ["Фактическая добыча зависит от залежей, программы экстрактора и частоты её перезапуска. При меньшей добыче заводы будут простаивать, а реальная прибыль окажется ниже прогноза.","Actual extraction depends on deposits, the extractor program, and restart frequency. With lower extraction, factories will idle and actual profit will be below the forecast."],
121  ["Эта конфигурация производства невозможна.","This production configuration is not feasible."],
122  ["Выбранные материалы сохранены — измените настройки, количество планет или клонов.","The selected materials were kept — change the settings, planet counts, or clones."],
123  ["Не хватает планетарных слотов для раздельной добычи P0","Not enough colony slots for separate P0 extraction"],
124  ["Не хватает планетарных слотов у клонов с учётом ограничений добычи P0","Not enough clone colony slots with the selected P0 extraction limits"],
125  ["Для отдельного этапа","The separate stage"],["не хватает свободного планетарного слота у клонов","does not have a free clone colony slot"],
126  ["Количество планет изменено. Ожидается автоматический перерасчёт…","Planet counts changed. Waiting for automatic recalculation…"],
127  ["Подготавливаем проверку производственных конфигураций…","Preparing production configuration checks…"],
128  ["Сравниваем все доступные производственные конфигурации…","Comparing all available production configurations…"],
129  ["Проверяем актуальность результата повторным расчётом…","Verifying the result with a second calculation…"],
130  ["Результаты изменились — выполняем новый расчёт…","Results changed — starting a new calculation…"],
131  ["Подготовка автоподбора максимальной прибыли…","Preparing maximum-profit auto-build…"],
132  ["Ошибка автоподбора:","Auto-build error:"],["Ошибка пересчёта:","Recalculation error:"],
133  ["Сначала укажите количество планет","Enter planet counts first"],["Сначала включите оценку стоимости материалов","Enable material value estimation first"],
134  ["Дождитесь загрузки рыночных цен","Wait for market prices to load"],["Не хватает доступных планетарных слотов.","Not enough available colony slots."],
135  ["Система найдена. ESI недоступен — использованы резервные данные.","System found. ESI is unavailable — fallback data was used."],
136  ["Система найдена, состав планет заполнен","System found and planet counts filled in"],["Система не найдена","System not found"],
137  ["Не удалось получить данные системы из ESI","Could not retrieve system data from ESI"],["Введите название системы","Enter a system name"],
138  ["ESI не вернул список планет","ESI did not return a planet list"],["Неизвестный тип планеты ESI:","Unknown ESI planet type:"],
139  ["Средневзвешенная цена сделок за","Volume-weighted trade price over"],["Последний завершённый день относительно средней:","Last completed day versus average:"],
140  ["за единицу","per unit"],[" из 6"," of 6"],
141  ["Недостаточно рыночных данных за 30 дней","Insufficient market data for 30 days"],["Цена загружается","Price is loading"],
142  ["Цены актуальны на","Prices updated on"],["средневзвешенная цена сделок за 30 дней","volume-weighted trade price over 30 days"],
143  ["Загрузка суточных индексов:","Loading daily market data:"],["Не удалось получить цены. Проверьте подключение к интернету","Could not retrieve prices. Check your internet connection"],
144  ["Оставьте 0, если комиссия не взимается","Leave 0 if no fee is charged"],
145  ["НЕОБХОДИМЫЕ КОМАНДНЫЕ ЦЕНТРЫ","REQUIRED COMMAND CENTERS"],["Скопировать список командных центров","Copy command center list"],
146  ["Список командных центров скопирован","Command center list copied"],["Командный центр:","Command Center:"],["шт.","units"],
147  ["Космопорт","Launchpad"],["Хранилище","Storage Facility"],["Загрузить","Loa
147d"],["Всего","Total"],["Вместимость","Capacity"],
148  ["Количество космопортов","Launchpad count"],["Количество хранилищ","Storage Facility count"],
149  ["Нет доступных компонентов для копирования","No available components to copy"],["Автоматическое копирование заблокировано браузером. Нажмите Ctrl+C, затем Enter:","Automatic copying was blocked by the browser. Press Ctrl+C, then Enter:"],
150  ["Не удалось скопировать","Could not copy"],["Скопировать доступные компоненты","Copy available components"],["Нет доступных","None available"],
151  ["нет доступных компонентов","has no available components"],[" скопированы"," copied"],[" скопирован"," copied"],["Скопировать показанные материалы","Copy displayed materials"],["Показанные материалы","Displayed materials"],
152  ["В системе отсутствует необходимый P0","The required P0 is not available in the system"],["Нет P0:","Missing P0:"],["Нужно больше доступных планет","More available planets are required"],
153  ["Необходимо планет","Planets required"],["САМЫЙ ВЫГОДНЫЙ ДОСТУПНЫЙ ВАРИАНТ","MOST PROFITABLE AVAILABLE OPTION"],
154  ["Загружаем текущие цены…","Loading current prices…"],["P0 ДЛЯ НЕПРЕРЫВНОЙ РАБОТЫ 24/7","P0 FOR CONTINUOUS 24/7 OPERATION"],
155  ["Прогноз достижим при стабильной добыче не ниже указанных объёмов.","The forecast is achievable with stable extraction at or above the listed volumes."],
156  ["Ресурс P0","P0 resource"],["В час","Per hour"],["В сутки","Per day"],["За 30 дней","Over 30 days"],
157  ["заводов конечного продукта:","final-product factories:"],["выпуск:","output:"],["занято","used"],["дополнительно:","additional:"],
158  ["завод. цели","target factories"],["план.","planets"],["30 дн.","30 days"],["30 дней","30 days"],
159  ["сырьё:","source:"],["Планет:","Planets:"],["Удалить","Remove"],
160  ["Расчёт производственного плана","Calculating production plan"],["Подготовка данных…","Preparing data…"],
161  ["Проверка выбранной конфигурации…","Checking selected configuration…"],["Конфигурация невозможна — формирование предупреждения…","Configuration is not feasible — preparing warning…"],
162  ["Обновление доступных материалов…","Updating available materials…"],["Пересчёт списков P0–P4…","Recalculating P0–P4 lists…"],
163  ["Формирование плана по клонам…","Building clone plan…"],["Готово","Done"],["Укажите хотя бы одну доступную планету","Enter at least one available planet"],
164  ["Диагностический файл создан","Diagnostic file created"],["Неизвестный формат проекта","Unknown project format"],["В старом проекте нет данных о количестве планет","The old project contains no planet-count data"],
165  ["Проект загружен","Project loaded"],["Не удалось загрузить проект:","Could not load project:"],["Автосохранение недоступно в этом браузере","Autosave is unavailable in this browser"],
166  ["Материалы P0–P4","P0–P4 MATERIALS"],["Дополнительные настройки планирования","Advanced planning settings"],["Выбор языка","Language selection"],
167  ["Запустить интерактивное обучение","Start interactive tour"],["Интерактивное обучение","Interactive tour"],["Пропустить обучение","Skip tour"],
168  ["Описание раздельной добычи P0","Separate P0 extraction description"],["Описание переработки P0–P1","P0–P1 processing description"],
169  ["Описание отдельных сборочных планет","Dedicated factory planets description"],["Описание максимальной загрузки","Maximum utilization description"],["Описание оценки стоимости","Material value estimation description"],
170  ["Рынок","Market"],["Экспорт считается по полной ставке, импорт на другую планету — по половине ставки","Export uses the full rate; import to another planet uses half the rate"],
171  ["Поблагодарить","Say thanks"],["Не за что :) Оставьте пожалуйста отзыв","You're welcome :) Please leave a review"],
172  ["Сделано ленивым для ленивых","Made by the lazy for the lazy"],["Экспорт для проверки","Export for verification"],
173  ["Поиск…","Searching…"],["млрд","bn"],["млн","M"],["тыс.","K"],
174  ["Пустошь","Barren"],["Газ","Gas"],["Лёд","Ice"],["Лава","Lava"],["Океан","Oceanic"],["Плазма","Plasma"],["Буря","Storm"],["Умеренная","Temperate"]
175].sort((a,b)=>b[0].length-a[0].length);
176function translateSiteText(value){
177  if(siteLanguage!=="en")return value;
178  return EN_TRANSLATIONS.reduce((text,[ru,en])=>text.split(ru).join(en),String(value));
179}
180const siteLocale=()=>siteLanguage==="en"?"en-US":"ru-RU";
181function applySiteLanguage(root=document.body){
182  if(!root)return;
183  if(document.documentElement?.getAttribute?.("lang")!==siteLanguage)document.documentElement?.setAttribute?.("lang",siteLanguage);
184  document.title=siteLanguage==="en"?"PLANET HUB — EVE Online Planetary Industry Planner":"PLANET HUB — Планетарный планировщик EVE Online";
185  const visit=node=>{
186    if(node.nodeType===3){if(!originalText.has(node))originalText.set(node,node.nodeValue);node.nodeValue=siteLanguage==="en"?translateSiteText(originalText.get(node)):originalText.get(node);return}
187    if(node.nodeType!==1&&node!==document.body)return;
188    if(node.nodeType===1){
189      if(!originalAttributes.has(node))originalAttributes.set(node,Object.fromEntries(["placeholder","title","aria-label","data-tooltip"].filter(name=>node.hasAttribute?.(name)).map(name=>[name,node.getAttribute(name)])));
190      for(const [name,value] of Object.entries(originalAttributes.get(node)||{}))node.setAttribute(name,siteLanguage==="en"?translateSiteText(value):value);
191    }
192    for(const child of node.childNodes||[])visit(child);
193  };
194  visit(root);
195  ["ru","en"].forEach(lang=>{const button=$("lang"+lang[0].toUpperCase()+lang.slice(1)+"Btn");if(button){button.classList.toggle("active",siteLanguage===lang);button.setAttribute("aria-pressed",String(siteLanguage===lang))}});
196}
197function setSiteLanguage(language){
198  siteLanguage=language==="en"?"en":"ru";
199  try{localStorage.setItem("planetHubLanguage",siteLanguage)}catch(_error){}
200  if($("cloneList"))renderClones();
201  if($("potentialColumns"))renderPotential();
202  if(current&&$("materialColumns"))mats();
203  if(selected.length)plan();
204  applySiteLanguage();
205  if($("guidedTour")&&!$("guidedTour").classList.contains("hidden"))renderGuidedTour();
206}
207
208const GUIDED_TOUR_STORAGE_KEY="planetHubGuidedTourV8";
209const GUIDED_TOUR_RESUME_KEY="planetHubGuidedTourResumeV8";
210let guidedTourIndex=0,guidedTourStepsCache=[],guidedTourReturnFocus=null,guidedTourAutomatic=false,guidedTourPhase="all";
211let guidedTourAwaitTarget=null;
212const guidedTourCopy=(ru,en)=>siteLanguage==="en"?en:ru;
213function guidedTourElementVisible(element){return !!element&&!element.classList.contains("hidden")}
214function guidedTourSteps(phase="all",state=null){
215  const setupSteps=[
216    {
217      target:"header .headerContent",
218      title:guidedTourCopy("Добро пожаловать в PLANET HUB","Welcome to PLANET HUB"),
219      text:guidedTourCopy("Это короткое обучение использует настоящий интерфейс сайта. Оно покажет, где задать исходные данные, выбрать материалы и получить готовый производственный план.","This short tour uses the live site interface. It shows where to enter source data, choose materials, and obtain a complete production plan.")
220    }
221  ];
222  const cloneStep={
223    target:".clonePanel",awaitsInput:true,
224    title:guidedTourCopy("Настройте клонов","Configure clones"),
225    text:guidedTourCopy("Теперь добавьте нужных клонов и укажите доступное число колоний для каждого. Если достаточно одного клона, ничего добавлять не нужно — проверьте его лимит и нажмите «Далее».","Now add the required clones and set each colony limit. If one clone is enough, add nothing—check its limit and click Next."),
226    actionLabel:guidedTourCopy("Клоны настроены","Clones configured")
227  };
228  const plannerSteps=[cloneStep,{
229    target:".plannerSettings",
230    awaitsInput:true,
231    title:guidedTourCopy("Сначала настройте производство","Configure production first"),
232    text:guidedTourCopy("До выбора материала задайте отдельную добычу, переработку P0–P1, число добывающих и производственных линий, отдельные сборочные планеты и максимальную загрузку. Подсвеченные настройки можно менять прямо сейчас.","Before choosing a material, configure separate extraction, P0–P1 processing, extractor and production line limits, dedicated factory planets, and maximum utilization. You can change the highlighted settings now.")
233  },{
234    target:"#marketEvaluationOption",
235    choice:"marketEvaluation",
236    title:guidedTourCopy("Нужна оценка стоимости?","Do you need market evaluation?"),
237    text:guidedTourCopy("Оценка загружает рыночные данные и позволяет программе найти самый выгодный доступный вариант. Без оценки вы сразу выберете желаемый материал самостоятельно.","Market evaluation loads price data and lets the planner find the most profitable available option. Without it, you can immediately choose the desired material yourself."),
238    secondaryLabel:guidedTourCopy("Без оценки","Without evaluation"),
239    primaryLabel:guidedTourCopy("Включить оценку","Enable evaluation")
240  }];
241  const planStep={
242    target:"#planPanel",
243    title:guidedTourCopy("Используйте готовый план","Use the finished plan"),
244    text:guidedTourCopy("План разбит по клонам и планетам. В нём указаны производство, загрузка и передачи. Возле каждого PI-шаблона можно скопировать JSON или открыть точную визуализацию построек.","The plan is grouped by clone and planet, including production, loads, and transfers. Each PI template can be copied as JSON or opened as an exact structure preview.")
245  };
246  const transferStep={
247    target:"#assignments .transferSummary",
248    title:guidedTourCopy("Проверьте передачи между клонами","Check transfers between clones"),
249    text:guidedTourCopy("Здесь указано, какой клон, какому клону и какие материалы должен передать. Если возле материала показана дробь, передаётся только указанная доля; без дроби материал передаётся целиком.","This section shows which clone sends which materials to another clone. A displayed fraction means only that share is transferred; without a fraction, transfer the whole material.")
250  };
251  const waitForPlanner={
252    target:"#systemLookup",waitFor:"planner",awaitsInput:true,cardPlacement:"right",
253    title:guidedTourCopy("Введите систему или задайте планеты","Enter a system or set planets"),
254    text:guidedTourCopy("Введите название системы и нажмите «Найти» либо задайте количество планет вручную. Когда появится список материалов P0–P4, обучение само перейдёт к настройке клонов.","Enter a system name and click Find, or set planet counts manually. When the P0–P4 material list appears, the tour automatically continues to clone setup."),
255    actionLabel:guidedTourCopy("Ожидаю ввод…","Waiting for input…")
256  };
257  const waitForPlan={
258    target:"#materialColumns",waitFor:"plan",awaitsInput:true,
259    title:guidedTourCopy("Выберите желаемый материал","Choose the desired material"),
260    text:guidedTourCopy("Обучение временно закроется. Выберите один или несколько доступных материалов — как только программа построит план, обучение продолжится возле готового результата и PI-шаблонов.","The tour pauses temporarily. Choose one or more available materials; as soon as the planner builds a plan, the tour resumes at the finished result and PI templates."),
261    actionLabel:guidedTourCopy("Выбрать материал","Choose material")
262  };
263  const marketPlanChoice={
264    target:"#marketSettings",choice:"marketPlan",
265    title:guidedTourCopy("Как построить план?","How should the plan be built?"),
266    text:guidedTourCopy("Можно поручить программе найти самый выгодный доступный материал с учётом настроек и цен либо выбрать желаемый материал самостоятельно.","Let the planner find the most profitable available material using your settings and prices, or choose the desired material yourself."),
267    secondaryLabel:guidedTourCopy("Желаемый материал","Desired material"),
268    primaryLabel:guidedTourCopy("Самый выгодный","Most profitable")
269  };
270  const marketCostsStep={
271    target:"#marketSettings .marketSettingsGrid",
272    awaitsInput:true,
273    title:guidedTourCopy("Укажите расходы","Set your costs"),
274    text:guidedTourCopy("Проверьте торговый хаб, налог с продаж, комиссию брокера, ставку таможни и стоимость перевозки за м³. Эти значения напрямую влияют на расчёт итоговой прибыли и выбор самого выгодного материала.","Check the trade hub, sales tax, broker fee, customs rate, and hauling cost per m³. These values directly affect net profit and the most profitable material selection.")
275  };
276  const resultVisible=state?!!state.resultVisible:guidedTourElementVisible($("result"));
277  const planVisible=state?!!state.planVisible:guidedTourElementVisible($("planPanel"));
278  const transfersVisible=state?!!state.transfersVisible:!!document.querySelector?.("#assignments .transferSummary");
279  const finishedPlanSteps=planVisible?[planStep,...(transfersVisible?[transferStep]:[])]:[];
280  if(phase==="setup")return resultVisible?setupSteps:[...setupSteps,waitForPlanner];
281  if(phase==="planner")return resultVisible?[...plannerSteps,...finishedPlanSteps]:[waitForPlanner];
282  if(phase==="materialChoice")return planVisible?finishedPlanSteps:[waitForPlan];
283  if(phase==="marketChoice")return planVisible?finishedPlanSteps:[marketCostsStep,marketPlanChoice];
284  if(phase==="plan")return planVisible?finishedPlanSteps:[waitForPlan];
285  if(!resultVisible)return[...setupSteps,waitForPlanner];
286  return[...setupSteps,...plannerSteps,...finishedPlanSteps];
287}
288function guidedTourTarget(step){
289  const target=document.querySelector?.(step?.target||"");
290  return target&&typeof target.getBoundingClientRect==="function"?target:null;
291}
292function positionGuidedTour(){
293  const tour=$("guidedTour"),spotlight=$("guidedTourSpotlight"),pointer=$("guidedTourPointer"),card=$("guidedTourCard");
294  if(!tour||tour.classList.contains("hidden")||!spotlight||!pointer||!card)return;
295  const step=guidedTourStepsCache[guidedTourIndex],target=guidedTourTarget(step);
296  if(!target){pointer.classList.add("hidden");return}
297  const viewportWidth=Math.max(320,window.innerWidth||document.documentElement?.clientWidth||320);
298  const viewportHeight=Math.max(320,window.innerHeight||document.documentElement?.clientHeight||320);
299  const rect=target.getBoundingClientRect(),padding=9,edge=8;
300  const left=Math.max(edge,rect.left-padding),top=Math.max(edge,rect.top-padding);
301  const right=Math.min(viewportWidth-edge,rect.right+padding),bottom=Math.min(viewportHeight-edge,rect.bottom+padding);
302  Object.assign(spotlight.style,{left:`${left}px`,top:`${top}px`,width:`${Math.max(24,right-left)}px`,height:`${Math.max(24,bottom-top)}px`});
303  const pointerWidth=38,pointerHeight=52,pointerX=Math.max(edge,Math.min(viewportWidth-pointerWidth-edge,left+(right-left-pointerWidth)/2));
304  const pointerAbove=top>=pointerHeight+12;
305  pointer.classList.remove("hidden");pointer.classList.toggle("pointsUp",!pointerAbove);
306  Object.assign(pointer.style,{left:`${pointerX}px`,top:`${pointerAbove?top-pointerHeight-7:bottom+7}px`});
307  card.style.visibility="hidden";
308  card.style.left=`${edge}px`;card.style.top=`${edge}px`;
309  const cardRect=card.getBoundingClientRect(),cardWidth=Math.min(cardRect.width||430,viewportWidth-edge*2),cardHeight=cardRect.height||250;
310  const below=bottom+14,above=top-cardHeight-14;
311  let cardLeft=Math.max(edge,Math.min(viewportWidth-cardWidth-edge,rect.left+rect.width/2-cardWidth/2));
312  let cardTop=below+cardHeight<=viewportHeight-edge?below:(above>=edge?above:Math.max(edge,viewportHeight-cardHeight-edge));
313  if(step.cardPlacement==="right"){
314    if(right+14+cardWidth<=viewportWidth-edge){cardLeft=right+14;cardTop=Math.max(edge,Math.min(viewportHeight-cardHeight-edge,top))}
315    else if(left-cardWidth-14>=edge){cardLeft=left-cardWidth-14;cardTop=Math.max(edge,Math.min(viewportHeight-cardHeight-edge,top))}
316  }
317  Object.assign(card.style,{left:`${cardLeft}px`,top:`${cardTop}px`,visibility:"visible"});
318}
319function renderGuidedTour(){
320  if(!guidedTourStepsCache.length)guidedTourStepsCache=guidedTourSteps(guidedTourPhase);
321  guidedTourIndex=Math.max(0,Math.min(guidedTourStepsCache.length-1,guidedTourIndex));
322  const step=guidedTourStepsCache[guidedTourIndex];if(!step)return;
323  if(step.choice==="marketEvaluation"&&guidedTourMarketEvaluationEnabled()){
324    switchGuidedTourPhase("marketChoice");return;
325  }
326  $("guidedTourTitle").textContent=step.title;$("guidedTourText").textContent=step.text;
327  $("guidedTourStepLabel").textContent=guidedTourCopy(`ОБУЧЕНИЕ · ШАГ ${guidedTourIndex+1} ИЗ ${guidedTourStepsCache.length}`,`TOUR · STEP ${guidedTourIndex+1} OF ${guidedTourStepsCache.length}`);
328  $("guidedTourPrev").textContent=guidedTourCopy("Назад","Back");
329  $("guidedTourSkip").textContent=guidedTourCopy("Пропустить","Skip");
330  $("guidedTourNext").textContent=step.actionLabel||(guidedTourIndex===guidedTourStepsCache.length-1?guidedTourCopy("Готово","Done"):guidedTourCopy("Далее","Next"));
331  $("guidedTourPrev").disabled=guidedTourIndex===0;
332  const hasChoices=!!step.choice;
333  const waitingForInput=!!step.waitFor;
334  $("guidedTourChoices").classList.toggle("hidden",!hasChoices);
335  $("guidedTourNext").classList.toggle("hidden",hasChoices||waitingForInput);
336  $("guidedTourSpotlight").classList.toggle("awaitingInput",!!step.awaitsInput);
337  if(waitingForInput)setGuidedTourResumePhase(step.waitFor);
338  if(hasChoices){
339    $("guidedTourChoiceSecondary").textContent=step.secondaryLabel||guidedTourCopy("Нет","No");
340    $("guidedTourChoicePrimary").textContent=step.primaryLabel||guidedTourCopy("Да","Yes");
341  }
342  $("guidedTourProgressBar").style.width=`${((guidedTourIndex+1)/guidedTourStepsCache.length)*100}%`;
343  const target=guidedTourTarget(step);
344  guidedTourAwaitTarget?.classList.remove("guidedTourAwaitTarget");
345  guidedTourAwaitTarget=step.awaitsInput?target:null;
346  guidedTourAwaitTarget?.classList.add("guidedTourAwaitTarget");
347  target?.scrollIntoView?.({behavior:"smooth",block:"center",inline:"nearest"});
348  const schedule=typeof requestAnimationFrame==="function"?requestAnimationFrame:callback=>setTimeout(callback,0);
349  schedule(positionGuidedTour);setTimeout(positionGuidedTour,260);
350}
351function closeGuidedTour(remember=false,goal="guided_tour_closed"){
352  const tour=$("guidedTour");if(!tour)return;
353  tour.classList.add("hidden");tour.setAttribute("aria-hidden","true");
354  document.body.classList.remove("guidedTourOpen");
355  guidedTourAwaitTarget?.classList.remove("guidedTourAwaitTarget");guidedTourAwaitTarget=null;
356  if(remember)try{localStorage.setItem(GUIDED_TOUR_STORAGE_KEY,"completed");localStorage.removeItem(GUIDED_TOUR_RESUME_KEY)}catch(_error){}
357  metrikaGoal(goal,{steps:guidedTourStepsCache.length,automatic:guidedTourAutomatic?1:0});
358  guidedTourReturnFocus?.focus?.();guidedTourReturnFocus=null;guidedTourAutomatic=false;guidedTourPhase="all";
359}
360function guidedTourResumePhase(){try{return localStorage.getItem(GUIDED_TOUR_RESUME_KEY)||""}catch(_error){return""}}
361function setGuidedTourResumePhase(phase){try{if(phase)localStorage.setItem(GUIDED_TOUR_RESUME_KEY,phase);else localStorage.removeItem(GUIDED_TOUR_RESUME_KEY)}catch(_error){}}
362function openGuidedTour(automatic=false,phase="all"){
363  const tour=$("guidedTour");if(!tour)return;
364  guidedTourReturnFocus=automatic?null:document.activeElement;
365  guidedTourAutomatic=automatic;guidedTourPhase=phase;guidedTourIndex=0;guidedTourStepsCache=guidedTourSteps(phase);
366  tour.classList.remove("hidden");tour.setAttribute("aria-hidden","false");document.body.classList.add("guidedTourOpen");
367  metrikaGoal("guided_tour_started",{steps:guidedTourStepsCache.length,automatic:automatic?1:0});
368  renderGuidedTour();$("guidedTourNext")?.focus?.();
369}
370function guidedTourMarketEvaluationEnabled(){
371  const control=$("marketEvaluation");
372  return control&&typeof control.checked==="boolean"?control.checked:!!marketSettings.enabled;
373}
374function shouldSkipGuidedTourMarketQuestion(direction,phase,enabled,nextStep){
375  return direction>0&&(phase==="planner"||phase==="all")&&!!enabled&&nextStep?.choice==="marketEvaluation";
376}
377function moveGuidedTour(direction){
378  const step=guidedTourStepsCache[guidedTourIndex];
379  if(direction>0&&step?.waitFor)return;
380  if(direction<0&&step?.waitFor)setGuidedTourResumePhase("");
381  if(shouldSkipGuidedTourMarketQuestion(direction,guidedTourPhase,guidedTourMarketEvaluationEnabled(),guidedTourStepsCache[guidedTourIndex+1])){
382    switchGuidedTourPhase("marketChoice");return;
383  }
384  if(direction>0&&guidedTourIndex===guidedTourStepsCache.length-1){closeGuidedTour(true,"guided_tour_completed");return}
385  guidedTourIndex=Math.max(0,Math.min(guidedTourStepsCache.length-1,guidedTourIndex+direction));renderGuidedTour();
386}
387function switchGuidedTourPhase(phase){
388  guidedTourPhase=phase;guidedTourIndex=0;guidedTourStepsCache=guidedTourSteps(phase);renderGuidedTour();
389}
390async function handleGuidedTourChoice(primary){
391  const step=guidedTourStepsCache[guidedTourIndex];
392  if(!step?.choice)return;
393  const buttons=[$("guidedTourChoiceSecondary"),$("guidedTourChoicePrimary")];
394  buttons.forEach(button=>button.disabled=true);
395  try{
396    if(step.choice==="marketEvaluation"){
397      await setMarketEvaluation(primary);
398      switchGuidedTourPhase(primary?"marketChoice":"materialChoice");
399    }else if(step.choice==="marketPlan"){
400      if(!primary){switchGuidedTourPhase("materialChoice");return}
401      setGuidedTourResumePhase("plan");
402      closeGuidedTour(false,"guided_tour_auto_profit");
403      await buildMaximumProfitPlan();
404    }
405  }finally{buttons.forEach(button=>button.disabled=false)}
406}
407function skipGuidedTour(){closeGuidedTour(true,"guided_tour_skipped")}
408function maybeResumeGuidedTour(phase){
409  const tour=$("guidedTour"),active=!tour?.classList.contains("hidden");
410  const currentStep=guidedTourStepsCache[guidedTourIndex],waitingHere=active&&currentStep?.waitFor===phase;
411  if(!waitingHere&&guidedTourResumePhase()!==phase)return;
412  const ready=phase==="planner"?guidedTourElementVisible($("result")):phase==="plan"?guidedTourElementVisible($("planPanel")):false;
413  if(!ready)return;
414  setGuidedTourResumePhase("");
415  setTimeout(()=>{
416    if(!$("guidedTour")?.classList.contains("hidden")&&guidedTourStepsCache[guidedTourIndex]?.waitFor===phase)switchGuidedTourPhase(phase);
417    else if($("guidedTour")?.classList.contains("hidden"))openGuidedTour(true,phase);
418  },320);
419}
420
421function planetIcon(pt){
422  const safe=String(pt).replace(/[^A-Za-z]/g,"");
423  return `<span class="planetIcon planetIcon${safe}" aria-hidden="true"></span>`;
424}
425
426function clampPlanetCount(v){return Math.max(0,Math.min(99,Math.round(Number(v)||0)))}
427function clearSelectedSystem(){
428  selectedSystemInfo=null;
429  const input=$("systemInput");
430  if(input)input.value="";
431}
432function renderPlanetCountForm(){
433  $("planetCountForm").innerHTML=D.meta.planetTypes.map(pt=>`<label class="planetCountRow">
434    <span class="planetTypeLabel">${planetIcon(pt)}${localizedPlanetType(pt)}</span>
435    <input type="number" min="0" max="99" step="1" data-pt="${pt}" value="${planetCounts[pt]||0}" aria-label="Количество планет: ${localizedPlanetType(pt)}">
436  </label>`).join("");
437  document.querySelectorAll(".planetCountRow input").forEach(input=>input.oninput=e=>{
438    clearSelectedSystem();
439    const pt=e.target.dataset.pt;
440    planetCounts[pt]=clampPlanetCount(e.target.value);
441    planetCountsDirty=true;
442    allocationMemo=new Map();
443    invalidateProfitabilityRanking();
444    renderPotential();
445    if(current)renderProfitabilityRanking();
446    if(planetAutoApplyTimer){clearTimeout(planetAutoApplyTimer);planetAutoApplyTimer=null}
447    if(Object.values(planetCounts).some(n=>n>0)){
448      planetAutoApplyTimer=setTimeout(()=>{
449        planetAutoApplyTimer=null;
450        run();
451      },650);
452    }
453  });
454}
455function manualSystem(){
456  return {
457    id:null,
458    name:"Ручной состав",
459    security:0,
460    planets:D.meta.planetTypes.map(pt=>planetCounts[pt]||0)
461  };
462}
463function normalizeSystemName(value){return String(value||"").trim().toLocaleLowerCase("en-US")}
464function systemSuggestions(query,limit=10){
465  const normalized=normalizeSystemName(query);
466  if(!normalized)return[];
467  return D.systems
468    .filter(system=>normalizeSystemName(system.name).includes(normalized))
469    .sort((a,b)=>{
470      const aPrefix=normalizeSystemName(a.name).startsWith(normalized)?0:1;
471      const bPrefix=normalizeSystemName(b.name).startsWith(normalized)?0:1;
472      return aPrefix-bPrefix||a.name.length-b.name.length||a.name.localeCompare(b.name);
473    })
474    .slice(0,limit);
475}
476function localSystemByName(name){
477  const normalized=normalizeSystemName(name);
478  return D.systems.find(system=>normalizeSystemName(system.name)===normalized)||null;
479}
480function emptyPlanetCounts(){return Object.fromEntries(D.meta.planetTypes.map(type=>[type,0]))}
481function countsFromPlanetTypeIds(typeIds){
482  const counts=emptyPlanetCounts();
483  for(const typeId of typeIds){
484    const type=PLANET_TYPE_BY_ID[Number(typeId)];
485    if(!type)throw new Error(`Неизвестный тип планеты ESI: ${typeId}`);
486    counts[type]++;
487  }
488  return counts;
489}
490function countsFromSystemRecord(system){
491  return Object.fromEntries(D.meta.planetTypes.map((type,index)=>[type,clampPlanetCount(system?.planets?.[index])]))
492}
493function systemCacheKey(systemId){return `planetHubSystem:v2:${systemId}`}
494function readSystemCache(systemId){
495  try{
496    const cached=JSON.parse(localStorage.getItem(systemCacheKey(systemId))||"null");
497    if(cached&&Date.now()-Number(cached.savedAt)<SYSTEM_CACHE_TTL&&cached.counts&&Array.isArray(cached.planets))return cached;
498  }catch(_){}
499  return null;
500}
501function writeSystemCache(system,counts,planets){
502  try{localStorage.setItem(systemCacheKey(system.id),JSON.stringify({savedAt:Date.now(),name:system.name,counts,planets}))}catch(_){}
503}
504function romanToNumber(value){
505  const values={I:1,V:5,X:10,L:50,C:100,D:500,M:1000};
506  let total=0,previous=0;
507  for(const char of String(value||"").toUpperCase().split("").reverse()){
508    const current=values[char]||0;
509    total+=current<previous?-current:current;
510    previous=Math.max(previous,current);
511  }
512  return total;
513}
514function planetOrderFromName(name,fallback){
515  const match=String(name||"").match(/\s([IVXLCDM]+)(?=\s|\(|$)/i);
516  return match?romanToNumber(match[1]):fallback;
517}
518function planetRadiusKmById(id){
519  const values=window.EVE_PLANET_RADII,target=Number(id);
520  if(!Array.isArray(values)||!Number.isInteger(target)||target<=0)return 0;
521  let low=0,high=Math.floor(values.length/2)-1;
522  while(low<=high){
523    const middle=(low+high)>>1,index=middle*2,current=values[index];
524    if(current===target)return Math.max(0,Number(values[index+1])||0);
525    if(current<target)low=middle+1;else high=middle-1;
526  }
527  return 0;
528}
529function normalizedPlanetDetails(planets){
530  if(!Array.isArray(planets))return[];
531  return planets.map((planet,index)=>({
532    id:Number(planet.id??planet.planet_id)||null,
533    name:String(planet.name||"").trim(),
534    pt:PLANET_TYPE_BY_ID[Number(planet.type_id)]||String(planet.pt||""),
535    order:Math.max(1,Math.round(Number(planet.order)||planetOrderFromName(planet.name,index+1))),
536    radiusKm:Math.max(0,Number(planet.radiusKm??planet.radius_km??planet.radius)||planetRadiusKmById(planet.id??planet.planet_id))
537  })).filter(planet=>planet.name&&D.meta.planetTypes.includes(planet.pt)).sort((a,b)=>a.order-b.order||a.name.localeCompare(b.name));
538}
539async function resolveSystemByName(name){
540  const local=localSystemByName(name);
541  if(local)return local;
542  const response=await fetch("https://esi.evetech.net/latest/universe/ids/?datasource=tranquility",{
543    method:"POST",headers:{Accept:"application/json","Content-Type":"application/json"},body:JSON.stringify([String(name).trim()])
544  });
545  if(!response.ok)throw new Error(`ESI ${response.status}`);
546  const data=await response.json();
547  const systems=data.systems||data.solar_systems||[];
548  const match=systems.find(system=>normalizeSystemName(system.name)===normalizeSystemName(name));
549  if(!match){const error=new Error("Система не найдена");error.code="SYSTEM_NOT_FOUND";throw error}
550  return{id:Number(match.id),name:match.name,security:null,planets:null};
551}
552async function fetchSystemPlanetCounts(system,force=false){
553  if(!force){
554    const cached=readSystemCache(system.id);
555    if(cached)return{counts:Object.fromEntries(D.meta.planetTypes.map(type=>[type,clampPlanetCount(cached.counts[type])])),planets:normalizedPlanetDetails(cached.planets),source:"cache"};
556  }
557  const systemData=await fetchJson(`https://esi.evetech.net/latest/universe/systems/${system.id}/?datasource=tranquility&language=en`);
558  const planetRefs=Array.isArray(systemData.planets)?systemData.planets:[];
559  if(!planetRefs.length)throw new Error("ESI не вернул список планет");
560  const details=await Promise.all(planetRefs.map(planet=>fetchJson(`https://esi.evetech.net/latest/universe/planets/${planet.planet_id}/?datasource=tranquility`)));
561  const planets=normalizedPlanetDetails(details.map((planet,index)=>({...planet,id:planetRefs[index].planet_id,order:planetOrderFromName(planet.name,index+1)})));
562  const counts=countsFromPlanetTypeIds(details.map(planet=>planet.type_id));
563  writeSystemCache(system,counts,planets);
564  return{counts,planets,source:"esi"};
565}
566function hideSystemSuggestions(){
567  $("systemSuggestions").classList.add("hidden");
568  $("systemInput").setAttribute("aria-expanded","false");
569}
570function renderSystemSuggestions(query){
571  const suggestions=systemSuggestions(query);
572  const box=$("systemSuggestions");
573  if(!query.trim()||!suggestions.length){hideSystemSuggestions();return suggestions}
574  box.innerHTML=suggestions.map(system=>`<button type="button" class="systemSuggestion" role="option" data-system-id="${system.id}">${esc(system.name)}</button>`).join("");
575  box.classList.remove("hidden");
576  $("systemInput").setAttribute("aria-expanded","true");
577  box.querySelectorAll(".systemSuggestion").forEach(button=>button.onmousedown=event=>{
578    event.preventDefault();
579    const system=D.systems.find(item=>item.id===Number(button.dataset.systemId));
580    if(system)searchAndApplySystem(system);
581  });
582  return suggestions;
583}
584function showSystemMessage(message,isError=false,markInput=false){
585  const box=$("systemSearchMessage"),input=$("systemInput");
586  if(systemMessageTimer)clearTimeout(systemMessageTimer);
587  box.className=`systemSearchMessage${isError?" error":""}`;
588  box.textContent=translateSiteText(message);
589  void box.offsetWidth;
590  box.classList.add("visible");
591  input.classList.toggle("systemSearchError",!!markInput);
592  systemMessageTimer=setTimeout(()=>{
593    box.classList.remove("visible");
594    input.classList.remove("systemSearchError");
595    systemMessageTimer=null;
596  },3000);
597}
598function setSystemSearchBusy(busy){
599  $("systemFindBtn").disabled=busy;
600  $("systemFindBtn").textContent=translateSiteText(busy?"Поиск…":"Найти");
601  $("systemLookup").setAttribute("aria-busy",String(busy));
602}
603async function searchAndApplySystem(systemCandidate=null){
604  const query=String(systemCandidate?.name||$("systemInput").value).trim();
605  if(!query){showSystemMessage("Введите название системы",true,true);return false}
606  const token=++systemLookupToken;
607  hideSystemSuggestions();setSystemSearchBusy(true);
608  try{
609    const system=systemCandidate||await resolveSystemByName(query);
610    let result;
611    try{result=await fetchSystemPlanetCounts(system)}
612    catch(error){
613      if(!Array.isArray(system.planets))throw error;
614      result={counts:countsFromSystemRecord(system),planets:[],source:"sde"};
615    }
616    if(token!==systemLookupToken)return false;
617    planetCounts={...result.counts};
618    selectedSystemInfo={id:system.id,name:system.name,security:system.security??null,planets:D.meta.planetTypes.map(type=>planetCounts[type]||0),planetDetails:normalizedPlanetDetails(result.planets),source:result.source};
619    renderPlanetCountForm();
620    $("systemInput").value=system.name;
621    run();
622    showSystemMessage(result.source==="sde"?"Система найдена. ESI недоступен — использованы резервные данные.":"Система найдена, состав планет заполнен");
623    return true;
624  }catch(error){
625    if(token!==systemLookupToken)return false;
626    if(error.code==="SYSTEM_NOT_FOUND")showSystemMessage("Система не найдена",true,true);
627    else showSystemMessage("Не удалось получить данные системы из ESI",true,false);
628    return false;
629  }finally{
630    if(token===systemLookupToken)setSystemSearchBusy(false);
631  }
632}
633function initSystemLookup(){
634  const input=$("systemInput");
635  input.oninput=()=>{
636    input.classList.remove("systemSearchError");
637    if(selectedSystemInfo&&normalizeSystemName(input.value)!==normalizeSystemName(selectedSystemInfo.name))selectedSystemInfo=null;
638    renderSystemSuggestions(input.value);
639  };
640  input.onkeydown=event=>{
641    if(event.key==="Enter"){event.preventDefault();searchAndApplySystem()}
642    else if(event.key==="Escape")hideSystemSuggestions();
643  };
644  input.onblur=()=>setTimeout(hideSystemSuggestions,150);
645  input.onfocus=()=>{if(input.value.trim())renderSystemSuggestions(input.value)};
646  $("systemFindBtn").onclick=()=>searchAndApplySystem();
647}
648initSystemLookup();
649renderPlanetCountForm();
650
651function toast(msg){
652  const old=document.querySelector(".toast"); if(old)old.remove();
653  const x=document.createElement("div");x.className="toast";x.textContent=translateSiteText(msg);document.body.appendChild(x);
654  setTimeout(()=>x.remove(),2600);
655}
656function metrikaGoal(goal,params={}){
657  try{
658    if(typeof window.ym==="function")window.ym(METRIKA_COUNTER_ID,"reachGoal",goal,params);
659  }catch(err){
660    console.warn("PLANET HUB: событие Метрики не отправлено",goal,err);
661  }
662}
663function planAnalyticsSignature(finalPlan){
664  return JSON.stringify({
665    selected:[...selected].sort((a,b)=>a-b),
666    planetCounts:D.meta.planetTypes.map(type=>planetCounts[type]||0),
667    clones:clones.map(clone=>clone.limit),
668    maxUtilization:!!$("maxUtilization").checked,
669    globalP0P1:!!globalPlanetSettings.p0p1,
670    productionSettings:{
671      separateP0:!!productionSettings.separateP0,
672      sharedP0Limit:productionSettings.sharedP0Limit,
673      separateFactories:!!productionSettings.separateFactories,
674      factoryLimit:clampFactoryLimit(productionSettings.factoryLimit)
675    },
676    colonies:finalPlan.map(colony=>({
677      type:colony.pt,
678      p0:[...colony.p0].sort((a,b)=>a-b),
679      tasks:colony.tasks.map(task=>[task.productId,task.kind,Number(task.factoryCount)||1])
680    }))
681  });
682}
683function trackCreatedPlan(finalPlan){
684  if(!finalPlan.length||!selected.length)return false;
685  const signature=planAnalyticsSignature(finalPlan);
686  if(trackedPlanSignatures.has(signature))return false;
687  trackedPlanSignatures.add(signature);
688  metrikaGoal("plan_created",{
689    selected_count:selected.length,
690    used_planets:finalPlan.length,
691    free_planets:Math.max(0,cap()-finalPlan.length)
692  });
693  return true;
694}
695function cap(){return clones.reduce((a,c)=>a+c.limit,0)}
696function cloneCommandCenterLevel(index){return Math.max(1,Math.min(6,Math.round(Number(clones[index]?.ccLevel)||6)))}
697function fix(v){v=Math.round(Number(v)||1);return Math.max(1,Math.min(6,v))}
698function esc(s){return String(s??"").replace(/[&<>"']/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[m]))}
699function ensureTargetColor(id){
700  if(!targetColors[id]){
701    const used=new Set(Object.values(targetColors));
702    targetColors[id]=TARGET_PALETTE.find(c=>!used.has(c))||TARGET_PALETTE[Object.keys(targetColors).length%TARGET_PALETTE.length];
703  }
704  return targetColors[id];
705}
706function targetsForProduct(productId){
707  return selected.filter(targetId=>dependencyProducts([targetId]).some(p=>p.id===productId));
708}
709function colorDots(targetIds){
710  if(selected.length<2)return"";
711  return `<span class="targetDots" aria-hidden="true">${targetIds.map(id=>`<span class="targetDot" style="--target-color:${ensureTargetColor(id)}"></span>`).join("")}</span>`;
712}
713function clampPercent(v){return Math.max(0,Math.min(100,Number(v)||0))}
714function clampMoney(v){return Math.max(0,Number(v)||0)}
715function clampFactoryLimit(value){return Math.max(1,Math.min(10,Math.round(Number(value)||1)))}
716function migratedFactoryLimit(settings){
717  if(settings?.factoryLimit!=null)return clampFactoryLimit(settings.factoryLimit);
718  const limits={1:1,2:1,3:1,4:1,...settings?.factoryLimits};
719  let limit=Math.max(...[1,2,3,4].map(tier=>clampFactoryLimit(limits[tier])));
720  const mixed=settings?.mixedStages||{};
721  if(mixed[12])limit=Math.max(limit,clampFactoryLimit(limits[1])+clampFactoryLimit(limits[2]));
722  if(mixed[23])limit=Math.max(limit,clampFactoryLimit(limits[2])+clampFactoryLimit(limits[3]));
723  if(mixed[34])limit=Math.max(limit,clampFactoryLimit(limits[3])+clampFactoryLimit(limits[4]));
724  return clampFactoryLimit(limit);
725}
726function normalizedProductionSettings(settings={}){
727  return{
728    separateP0:settings.separateP0!==false,
729    sharedP0Limit:Math.max(1,Math.min(10,Math.round(Number(settings.sharedP0Limit)||1))),
730    separateFactories:!!settings.separateFactories,
731    factoryLimit:migratedFactoryLimit(settings)
732  };
733}
734function localDate(value){return value?new Date(value+"T00:00:00Z").toLocaleDateString(siteLocale(),{timeZone:"UTC"}):"—"}
735function requiredUnitsPerTarget(productId){
736  const required=new Map([[productId,1]]);
737  const chain=dependencyProducts([productId]).filter(p=>p.t>=1).sort((a,b)=>b.t-a.t);
738  for(const product of chain){
739    const outputUnits=required.get(product.id)||0;
740    if(!outputUnits)continue;
741    const cycles=outputUnits/Math.max(1,Number(product.q)||1);
742    for(const [inputId,quantity] of product.i||[]){
743      const input=products.get(inputId);
744      if(!input||input.t===0)continue;
745      required.set(inputId,(required.get(inputId)||0)+cycles*Math.max(0,Number(quantity)||0));
746    }
747  }
748  return required;
749}
750function allocationOutputPerDay(allocation,productId){
751  if(!allocation?.ok)return 0;
752  const factoryCounts=new Map();
753  for(const placement of allocation.cover?.stagePlacements||[]){
754    factoryCounts.set(placement.productId,(factoryCounts.get(placement.productId)||0)+1);
755  }
756  const required=requiredUnitsPerTarget(productId);
757  let targetPerHour=Infinity;
758  for(const [id,unitsPerTarget] of required){
759    const product=products.get(id),count=factoryCounts.get(id)||0;
760    if(!product||!count||!(unitsPerTarget>0))return 0;
761    const capacity=count*(Number(product.q)||1)*3600/Math.max(1,Number(product.c)||3600);
762    targetPerHour=Math.min(targetPerHour,capacity/unitsPerTarget);
763  }
764  return Number.isFinite(targetPerHour)?targetPerHour*24:0;
765}
766function allocationOutputsPerDay(allocation,targetIds){
767  const capacities=new Map();
768  for(const placement of allocation?.cover?.stagePlacements||[]){
769    const product=products.get(placement.productId);
770    if(!product)continue;
771    const hourly=(Number(product.q)||1)*3600/Math.max(1,Number(product.c)||3600);
772    capacities.set(product.id,(capacities.get(product.id)||0)+hourly);
773  }
774  const targets=[...new Set((targetIds||[]).map(Number))].filter(id=>products.get(id)?.t>0);
775  const requestedCopies=targetCopyMap(targets,allocation?.targetCopies||1);
776  const nominal=new Map(targets.map(id=>{
777    const product=products.get(id);
778    const perFactory=(Number(product?.q)||1)*3600/Math.max(1,Number(product?.c)||3600);
779    return[id,Math.min(capacities.get(id)||0,(requestedCopies.get(id)||1)*perFactory)];
780  }));
781  const requirements=new Map(targets.map(id=>[id,requiredUnitsPerTarget(id)]));
782  const sharedFactors=new Map();
783  for(const id of new Set(targets.flatMap(targetId=>[...requirements.get(targetId).keys()]))){
784    const demand=targets.reduce((sum,targetId)=>sum+(requirements.get(targetId).get(id)||0)*(nominal.get(targetId)||0),0);
785    const capacity=capacities.get(id)||0;
786    sharedFactors.set(id,demand>0?Math.min(1,capacity/demand):1);
787  }
788  return new Map(targets.map(targetId=>{
789    const needed=requirements.get(targetId),scale=[...needed.keys()].reduce((value,id)=>Math.min(value,sharedFactors.get(id)??1),1);
790    return[targetId,(nominal.get(targetId)||0)*scale*24];
791  }));
792}
793function allocationRateFactorsForTargets(allocation,targetIds){
794  const outputs=allocationOutputsPerDay(allocation,targetIds),factoryCounts=new Map();
795  for(const placement of allocation?.cover?.stagePlacements||[])factoryCounts.set(placement.productId,(factoryCounts.get(placement.productId)||0)+1);
796  const factors=new Map();
797  for(const productId of new Set([...outputs.keys()].flatMap(targetId=>[...requiredUnitsPerTarget(targetId).keys()]))){
798    const product=products.get(productId),count=factoryCounts.get(productId)||0;
799    const capacity=count*(Number(product?.q)||1)*3600/Math.max(1,Number(product?.c)||3600);
800    const requiredPerHour=[...outputs].reduce((sum,[targetId,perDay])=>sum+(requiredUnitsPerTarget(targetId).get(productId)||0)*perDay/24,0);
801    factors.set(productId,capacity>0?Math.min(1,requiredPerHour/capacity):0);
802  }
803  return factors;
804}
805function productionLineEconomics(product){
806  const average30=marketPrices.get(product.id),trend=marketTrends.get(product.id);
807  if(average30==null)return null;
808  const averageDailyVolume=Number.isFinite(trend?.averageDailyVolume)?trend.averageDailyVolume:null;
809  return {average30,calculationPrice:average30,averageDailyVolume};
810}
811function maximumPackedAllocation(productId){
812  const limit=clampFactoryLimit(productionSettings.factoryLimit);
813  const maximumAttempts=Math.max(1,cap()*limit);
814  let best=null,copies=0;
815  for(let candidateCopies=1;candidateCopies<=maximumAttempts;candidateCopies++){
816    const candidate=calculateAllocation([productId],new Map(),candidateCopies,true);
817    if(!candidate.ok)break;
818    best=candidate;copies=candidateCopies;
819  }
820  return best?{allocation:best,copies}:null;
821}
822function reserveCover(reserved,cover){
823  cover.colonies.forEach(colony=>reserved.set(colony.colonyKey,colony));
824}
825function productionTotalsForBuiltPlan(built,targetIds){
826  const totals=new Map();
827  for(const segment of built?.segments||[]){
828    const segmentTargets=segment.targetProductId==null?targetIds:[segment.targetProductId];
829    const outputs=allocationOutputsPerDay(segment.allocation,segmentTargets);
830    for(const [targetId,producedPerDay] of outputs){
831      if(!(producedPerDay>0))continue;
832      const targetLines=(segment.allocation.cover?.stagePlacements||[]).filter(x=>x.productId===targetId).length;
833      const row=totals.get(targetId)||{producedPerDay:0,targetLines:0};
834      row.producedPerDay+=producedPerDay;row.targetLines+=targetLines;totals.set(targetId,row);
835    }
836  }
837  return totals;
838}
839function netSaleBreakdown(product,quantity,price){
840  const gross=Math.max(0,Number(quantity)||0)*Math.max(0,Number(price)||0);
841  const salesTax=gross*marketSettings.tax/100;
842  const broker=gross*marketSettings.broker/100;
843  const finalExport=customsExportFee(product,quantity);
844  const hauling=(PRODUCT_VOLUME[product.t]||0)*quantity*marketSettings.hauling;
845  return{gross,salesTax,broker,finalExport,hauling,net:gross-salesTax-broker-finalExport-hauling};
846}
847function evaluateBuiltPlanEconomics(built,targetIds){
848  const totals=productionTotalsForBuiltPlan(built,targetIds);
849  const p0Requirements=p0RequirementsForPlan(built.plan);
850  const lines=[];
851  for(const [id,total] of totals){
852    const output=products.get(id),economics=productionLineEconomics(output);
853    if(!output||!economics)continue;
854    const saleablePerDay=economics.averageDailyVolume==null?total.producedPerDay:Math.min(total.producedPerDay,economics.averageDailyVolume);
855    const sale=netSaleBreakdown(output,saleablePerDay*30,economics.calculationPrice);
856    lines.push({id,name:output.n,tier:output.t,count:total.targetLines,producedPerDay:total.producedPerDay,saleablePerDay,net30Days:sale.net,average30:economics.average30,calculationPrice:economics.calculationPrice,averageDailyVolume:economics.averageDailyVolume,...sale});
857  }
858  if(!lines.length)return null;
859  const customsTransfer=interplanetaryCustomsFees(built.plan,30*24);
860  const sum=key=>lines.reduce((value,line)=>value+(line[key]||0),0);
861  const gross30Days=sum("gross"),salesTax30Days=sum("salesTax"),broker30Days=sum("broker"),finalExport30Days=sum("finalExport"),hauling30Days=sum("hauling");
862  const total30Days=sum("net")-customsTransfer.total;
863  lines.sort((a,b)=>b.net30Days-a.net30Days);
864  return{total30Days,gross30Days,salesTax30Days,broker30Days,finalExport30Days,hauling30Days,customsTransfer30Days:customsTransfer.total,customsTransferExport30Days:customsTransfer.exportFee,customsTransferImport30Days:customsTransfer.importFee,customsTransferLines:customsTransfer.byProduct,p0Requirements,usedPlanets:built.plan.length,freePlanets:Math.max(0,cap()-built.plan.length),lines};
865}
866function simulateProductionConfiguration(product){
867  // The selected product must first have a feasible minimum chain. Maximum
868  // utilization is an expansion strategy and must never invalidate that base.
869  const first=calculateAllocation([product.id],new Map(),1,false);
870  if(!first.ok||!first.cover.count)return null;
871  const built=buildPlanetInstructionsFor([product.id],first,!!$("maxUtilization")?.checked);
872  const economics=evaluateBuiltPlanEconomics(built,[product.id]);
873  if(!economics)return null;
874  const mainLine=economics.lines.find(x=>x.id===product.id);
875  return{...economics,product,targetLines:mainLine?.count||0,mainLine};
876}
877
878function autoProfitConfigurations(){
879  const configurations=[];
880  const separateFactoryOptions=productionSettings.separateFactories?[true]:[false,true];
881  for(const separateP0 of [true,false])for(const p0p1 of [true,false])for(const separateFactories of separateFactoryOptions)for(const maxUtilization of [true,false]){
882    configurations.push({separateP0,p0p1,separateFactories,maxUtilization});
883  }
884  return configurations;
885}
886function applyAutoProfitConfiguration(configuration){
887  productionSettings={...productionSettings,separateP0:configuration.separateP0,separateFactories:configuration.separateFactories};
888  globalPlanetSettings={...globalPlanetSettings,p0p1:configuration.p0p1};
889  $("maxUtilization").checked=configuration.maxUtilization;
890  allocationMemo=new Map();
891}
892async function buildMaximumProfitPlan(){
893  if(autoProfitRunning)return;
894  if(!current){toast("Сначала укажите количество планет");return}
895  if(!marketSettings.enabled){toast("Сначала включите оценку стоимости материалов");return}
896  if(marketLoading||!marketPrices.size){toast("Дождитесь загрузки рыночных цен");return}
897  const button=$("autoProfitBtn"),status=$("autoProfitStatus"),token=++autoProfitToken;
898  const saved={productionSettings:JSON.parse(JSON.stringify(productionSettings)),globalPlanetSettings:{...globalPlanetSettings},maxUtilization:!!$("maxUtilization").checked,selected:[...selected]};
899  const configurations=autoProfitConfigurations(),candidates=D.products.filter(product=>product.t>0&&marketPrices.get(product.id)!=null);
900  const total=Math.max(1,configurations.length*candidates.length);let completed=0,best=null,applied=false;
901  autoProfitRunning=true;button.disabled=true;invalidateProfitabilityRanking();
902  $("calculationProgress").classList.remove("hidden");setCalculationProgress(1,"Подготовка автоподбора максимальной прибыли…");
903  try{
904    for(const configuration of configurations){
905      if(token!==autoProfitToken)return;
906      applyAutoProfitConfiguration(configuration);
907      for(const product of candidates){
908        const result=simulateProductionConfiguration(product);completed++;
909        if(result&&(!best||result.total30Days>best.result.total30Days||(result.total30Days===best.result.total30Days&&result.usedPlanets<best.result.usedPlanets)))best={configuration:JSON.parse(JSON.stringify(configuration)),product,result};
910        if(completed%6===0){
911          const percent=Math.max(1,Math.min(99,Math.round(completed/total*100)));
912          setCalculationProgress(percent,`Перебираем производственные конфигурации… ${completed.toLocaleString(siteLocale())} / ${total.toLocaleString(siteLocale())}`);
913          await new Promise(resolve=>setTimeout(resolve,0));
914        }
915      }
916    }
917    if(!best||!(best.result.total30Days>0)){
918      status.textContent="Прибыльный вариант не найден.";toast("Прибыльный вариант не найден.");return;
919    }
920    applyAutoProfitConfiguration(best.configuration);selected=[best.product.id];ensureTargetColor(best.product.id);renderProductionControls();
921    status.textContent=`Оптимальный план построен: ${best.product.n} — ${Math.round(best.result.total30Days).toLocaleString(siteLocale())} ISK / 30 дней`;
922    metrikaGoal("auto_profit_plan",{material_id:best.product.id,material_name:best.product.n,tier:best.product.t});autosave();applied=true;
923  }catch(error){console.error(error);toast("Ошибка автоподбора: "+error.message)}
924  finally{
925    if(!applied){productionSettings=saved.productionSettings;globalPlanetSettings=saved.globalPlanetSettings;$("maxUtilization").checked=saved.maxUtilization;selected=saved.selected;renderProductionControls()}
926    autoProfitRunning=false;button.disabled=false;$("calculationProgress").classList.add("hidden");
927  }
928  if(applied){invalidateProfitabilityRanking();recalc()}
929}
930
931function p0RequirementsForPlan(plan){
932  const perHour=new Map(),extractFallback=new Map();
933  for(const planet of plan||[])for(const task of planet.tasks||[]){
934    const product=products.get(task.productId);
935    const factories=Math.max(1,Number(task.factoryCount)||1);
936    const rateFactor=Number.isFinite(Number(task.rateFactor))?Number(task.rateFactor):1;
937    if(task.kind==="assembly"&&product?.t===1){
938      const cycles=factories*rateFactor*3600/Math.max(1,Number(product.c)||3600);
939      for(const [inputId,quantity] of product.i||[])perHour.set(inputId,(perHour.get(inputId)||0)+cycles*Math.max(0,Number(quantity)||0));
940    }else if(task.kind==="extract"&&product?.t===0){
941      const p1=directP1ForP0(product.id);
942      const inputQuantity=p1?.i?.find(([id])=>id===product.id)?.[1]||0;
943      const quantity=factories*rateFactor*inputQuantity*3600/Math.max(1,Number(p1?.c)||3600);
944      extractFallback.set(product.id,(extractFallback.get(product.id)||0)+quantity);
945    }
946  }
947  for(const [id,quantity] of extractFallback)if(!perHour.has(id))perHour.set(id,quantity);
948  return [...perHour].map(([id,hour])=>({id,name:products.get(id)?.n||String(id),hour,day:hour*24,days30:hour*24*30})).sort((a,b)=>a.name.localeCompare(b.name));
949}
950function profitabilitySignature(){
951  if(!current||!marketSettings.enabled||marketLoading)return"";
952  return JSON.stringify({
953    planets:planetCounts,clones:clones.map(c=>c.limit),globalPlanetSettings,productionSettings,
954    maxUtilization:!!$("maxUtilization")?.checked,hub:marketSettings.hub,tax:marketSettings.tax,broker:marketSettings.broker,customsTax:marketSettings.customsTax,
955    hauling:marketSettings.hauling,prices:[...marketPrices].sort((a,b)=>a[0]-b[0]),trends:[...marketTrends].sort((a,b)=>a[0]-b[0]),date:marketPriceDate
956  });
957}
958function invalidateProfitabilityRanking(){
959  profitabilityStateRevision++;
960  profitabilityRankingToken++;
961  profitabilityRankingCache=[];
962  profitabilityRankingSignature="";
963  profitabilityRankingVerifiedSignature="";
964  profitabilityRankingStage="";
965}
966function profitabilityRunIsCurrent(signature,revision,token){
967  return token===profitabilityRankingToken&&revision===profitabilityStateRevision&&signature===profitabilitySignature();
968}
969function profitabilityResultsMatch(first,second){
970  if(!first||!second||first.length!==second.length)return false;
971  const right=new Map(second.map(item=>[item.product.id,item]));
972  return first.every(item=>{
973    const other=right.get(item.product.id);
974    if(!other||item.usedPlanets!==other.usedPlanets||item.targetLines!==other.targetLines||item.lines.length!==other.lines.length)return false;
975    const tolerance=Math.max(1,Math.abs(item.total30Days)*1e-9);
976    return Math.abs(item.total30Days-other.total30Days)<=tolerance;
977  });
978}
979async function calculateProfitabilityPass(candidates,signature,revision,token){
980  const ranked=[];
981  for(const product of candidates){
982    if(!profitabilityRunIsCurrent(signature,revision,token))return null;
983    const result=simulateProductionConfiguration(product);
984    if(result)ranked.push(result);
985    await new Promise(resolve=>setTimeout(resolve,0));
986    if(!profitabilityRunIsCurrent(signature,revision,token))return null;
987  }
988  ranked.sort((a,b)=>b.total30Days-a.total30Days||a.usedPlanets-b.usedPlanets||a.product.n.localeCompare(b.product.n));
989  return ranked;
990}
991async function ensureProfitabilityRanking(){
992  const signature=profitabilitySignature();
993  if(!signature)return[];
994  if(signature===profitabilityRankingVerifiedSignature&&signature===profitabilityRankingSignature&&profitabilityRankingCache.length)return profitabilityRankingCache;
995  if(profitabilityRankingPromise&&profitabilityRankingPromiseSignature===signature&&profitabilityRankingPromiseRevision===profitabilityStateRevision)return profitabilityRankingPromise;
996  const token=++profitabilityRankingToken;
997  const revision=profitabilityStateRevision;
998  const candidates=D.products.filter(product=>product.t>0&&marketPrices.get(product.id)!=null);
999  profitabilityRankingStage="Сравниваем все доступные производственные конфигурации…";
1000  const run=(async()=>{
1001    const first=await calculateProfitabilityPass(candidates,signature,revision,token);
1002    if(!first)return[];
1003    profitabilityRankingStage="Проверяем актуальность результата повторным расчётом…";
1004    renderProfitabilityRanking();
1005    const verified=await calculateProfitabilityPass(candidates,signature,revision,token);
1006    if(!verified||!profitabilityRunIsCurrent(signature,revision,token))return[];
1007    if(!profitabilityResultsMatch(first,verified)){
1008      profitabilityRankingStage="Результаты изменились — выполняем новый расчёт…";
1009      return[];
1010    }
1011    profitabilityRankingCache=verified;
1012    profitabilityRankingSignature=signature;
1013    profitabilityRankingVerifiedSignature=signature;
1014    profitabilityRankingStage="";
1015    return verified;
1016  })();
1017  profitabilityRankingPromise=run;
1018  profitabilityRankingPromiseSignature=signature;
1019  profitabilityRankingPromiseRevision=revision;
1020  try{return await run}
1021  finally{
1022    if(profitabilityRankingPromise===run){
1023      profitabilityRankingPromise=null;
1024      profitabilityRankingPromiseSignature="";
1025      profitabilityRankingPromiseRevision=-1;
1026    }
1027  }
1028}
1029function compactPrice(value){
1030  if(value>=1e9)return `${(value/1e9).toLocaleString(siteLocale(),{maximumFractionDigits:1})} ${siteLanguage==="en"?"bn":"млрд"}`;
1031  if(value>=1e6)return `${(value/1e6).toLocaleString(siteLocale(),{maximumFractionDigits:1})} ${siteLanguage==="en"?"M":"млн"}`;
1032  if(value>=1e3)return `${(value/1e3).toLocaleString(siteLocale(),{maximumFractionDigits:value>=1e5?0:1})} ${siteLanguage==="en"?"K":"тыс."}`;
1033  return value.toLocaleString(siteLocale(),{maximumFractionDigits:2});
1034}
1035function marketIndicator(id){
1036  if(!marketSettings.enabled)return"";
1037  const price=marketPrices.get(id);
1038  const trend=marketTrends.get(id);
1039  if(price==null)return marketLoading?`<span class="priceTrend pending" title="Цена загружается">…</span>`:`<span class="priceTrend flat" title="Недостаточно рыночных данных за 30 дней">—</span>`;
1040  const reference=Number.isFinite(trend?.change)?` · Последний завершённый день относительно средней: ${trend.change>=0?"+":""}${trend.change.toFixed(1)}%`:"";
1041  return `<span class="priceTrend marketPrice" title="Средневзвешенная цена сделок за ${trend?.sampleDays||30} дней: ${price.toLocaleString(siteLocale(),{maximumFractionDigits:2})} ISK за единицу${reference}">${compactPrice(price)}</span>`;
1042}
1043function marketHistorySummary(days){
1044  if(!days.length)return null;
1045  const totalVolume=days.reduce((sum,x)=>sum+(Number(x.volume)||0),0);
1046  const weightedValue=days.reduce((sum,x)=>sum+(Number(x.average)||0)*(Number(x.volume)||0),0);
1047  const average30=totalVolume>0?weightedValue/totalVolume:days.reduce((sum,x)=>sum+Number(x.average),0)/days.length;
1048  return{average30,averageDailyVolume:totalVolume/days.length};
1049}
1050function marketCacheKey(kind){
1051  const today=new Date().toISOString().slice(0,10);
1052  return `evePiMarket:v5:${kind}:${marketSettings.hub}:${today}`;
1053}
1054function restoreTrendCache(){
1055  marketTrends=new Map();marketPriceDate=null;
1056  try{
1057    const cached=JSON.parse(localStorage.getItem(marketCacheKey("trends"))||"null");
1058    if(cached?.items){
1059      marketTrends=new Map(cached.items.map(x=>[x.id,x]));
1060      marketPrices=new Map(cached.items.filter(x=>Number.isFinite(x.average30)).map(x=>[x.id,x.average30]));
1061      marketPriceDate=cached.date||null;return marketPrices.size>0;
1062    }
1063  }catch(_){}
1064  return false;
1065}
1066async function fetchJson(url){
1067  const response=await fetch(url,{headers:{Accept:"application/json"}});
1068  if(!response.ok)throw new Error(`ESI ${response.status}`);
1069  return response.json();
1070}
1071async function mapConcurrent(items,limit,worker,onProgress){
1072  let cursor=0,done=0;
1073  async function runner(){
1074    while(cursor<items.length){
1075      const index=cursor++,item=items[index];
1076      try{await worker(item,index)}catch(err){console.warn("Market request failed",item.id||item,err)}
1077      done++;onProgress?.(done,items.length);
1078    }
1079  }
1080  await Promise.all(Array.from({length:Math.min(limit,items.length)},runner));
1081}
1082function updateMarketStatus(text){$("marketStatus").textContent=translateSiteText(text)}
1083function refreshMarketViews(){
1084  renderPotential();
1085  if(current)mats();
1086}
1087async function loadMarketTrends(force=false){
1088  if(!marketSettings.enabled)return;
1089  const token=++marketLoadToken;
1090  if(!force&&restoreTrendCache()){
1091    marketLoading=false;$("marketLoadTrack").classList.add("hidden");
1092    updateMarketStatus(`Цены актуальны на ${localDate(marketPriceDate)} · средневзвешенная цена сделок за 30 дней`);
1093    refreshMarketViews();if(selected.length)plan();return;
1094  }
1095  marketTrends=new Map();marketPrices=new Map();marketPriceDate=null;marketLoading=true;
1096  $("marketLoadTrack").classList.remove("hidden");$("marketLoadBar").style.width="0%";
1097  updateMarketStatus("Загрузка суточных индексов: 0%…");refreshMarketViews();
1098  const hub=MARKET_HUBS[marketSettings.hub],loaded=[];
1099  await mapConcurrent(D.products,6,async p=>{
1100    const history=await fetchJson(`https://esi.evetech.net/latest/markets/${hub.region}/history/?datasource=tranquility&type_id=${p.id}`);
1101    const today=new Date().toISOString().slice(0,10);
1102    const days=history.filter(x=>x.average>0&&x.date<today).sort((a,b)=>a.date.localeCompare(b.date)).slice(-30);
1103    if(days.length>=2){
1104      const current=days[days.length-1].average;
1105      const {average30,averageDailyVolume}=marketHistorySummary(days);
1106      const item={id:p.id,current,average30,change:average30?((current-average30)/average30)*100:0,date:days[days.length-1].date,sampleDays:days.length,averageDailyVolume};
1107      marketTrends.set(p.id,item);marketPrices.set(p.id,average30);loaded.push(item);
1108      if(!marketPriceDate||item.date>marketPriceDate)marketPriceDate=item.date;
1109    }
1110  },(done,total)=>{
1111    if(token!==marketLoadToken)return;
1112    const percent=Math.round(done/total*100);$("marketLoadBar").style.width=`${percent}%`;
1113    updateMarketStatus(`Загрузка суточных индексов: ${percent}%…`);
1114    if(done%10===0)refreshMarketViews();
1115  });
1116  if(token!==marketLoadToken)return;
1117  marketLoading=false;
1118  $("marketLoadTrack").classList.add("hidden");
1119  if(loaded.length){
1120    try{localStorage.setItem(marketCacheKey("trends"),JSON.stringify({date:marketPriceDate,items:loaded}))}catch(_){}
1121    updateMarketStatus(`Цены актуальны на ${localDate(marketPriceDate)} · средневзвешенная цена сделок за 30 дней`);
1122  }else updateMarketStatus("Не удалось получить цены. Проверьте подключение к интернету");
1123  refreshMarketViews();if(selected.length)plan();
1124}
1125function renderMarketControls(){
1126  $("marketEvaluation").checked=marketSettings.enabled;
1127  $("marketSettings").classList.toggle("hidden",!marketSettings.enabled);
1128  $("marketHub").value=marketSettings.hub;
1129  $("marketTax").value=marketSettings.tax;
1130  $("marketBroker").value=marketSettings.broker;
1131  $("marketBroker").disabled=false;
1132  $("marketBroker").title="Оставьте 0, если комиссия не взимается";
1133  $("customsTax").value=marketSettings.customsTax;
1134  $("marketHauling").value=marketSettings.hauling;
1135}
1136function showP0ExtractorLimit(){
1137  return true;
1138}
1139function renderProductionControls(){
1140  $("separateP0").checked=productionSettings.separateP0;
1141  $("sharedP0Limit").value=String(productionSettings.sharedP0Limit||1);
1142  $("separateFactories").checked=productionSettings.separateFactories;
1143  $("globalP0P1").checked=globalPlanetSettings.p0p1;
1144  $("factoryLimit").value=String(clampFactoryLimit(productionSettings.factoryLimit));
1145  renderMarketControls();
1146}
1147
1148function localizedCloneName(name){
1149  const match=/^(?:Клон|Clone)\s+(\d+)$/.exec(String(name||""));
1150  return match?`${siteLanguage==="en"?"Clone":"Клон"} ${match[1]}`:String(name||"");
1151}
1152function renderClones(){
1153  const cloneField=c=>authorizedClones.length?`<select class="cn" aria-label="Клон EVE"><option value="">${esc(localizedCloneName(c.name))}</option>${authorizedClones.map(item=>`<option value="${item.character_id}" ${Number(c.characterId)===Number(item.character_id)?"selected":""}>${esc(item.character_name)}</option>`).join("")}</select>`:`<span class="cloneAutoName">${esc(localizedCloneName(c.name))}</span>`;
1154  $("cloneList").innerHTML=clones.map((c,i)=>`<div class="cloneRow" data-i="${i}">
1155    ${cloneField(c)}
1156    <div class="limit">Планет: <input class="cl" type="number" min="1" max="6" value="${c.limit}"> из 6</div>
1157    <div class="limit">Уровень КЦ: <input class="cc" type="number" min="1" max="6" value="${cloneCommandCenterLevel(i)}"> из 6</div>
1158    <button class="remove">Удалить</button>
1159  </div>`).join("");
1160  document.querySelectorAll(".cloneRow").forEach(r=>{
1161    let i=+r.dataset.i;
1162    const nameField=r.querySelector(".cn");
1163    if(nameField?.tagName==="SELECT")nameField.onchange=e=>{const found=authorizedClones.find(item=>String(item.character_id)===e.target.value);clones[i].characterId=found?.character_id||null;clones[i].name=found?.character_name||`Клон ${i+1}`;if(found)clones[i].ccLevel=found.command_center_level||6;renderClones();allocationMemo=new Map();invalidateProfitabilityRanking();recalc();autosave()};
1164    r.querySelector(".cl").onchange=e=>{clones[i].limit=fix(e.target.value);e.target.value=clones[i].limit;totals();invalidateProfitabilityRanking();recalc();autosave()};
1165    r.querySelector(".cc").onchange=e=>{clones[i].ccLevel=Math.max(1,Math.min(6,Math.round(Number(e.target.value)||6)));e.target.value=clones[i].ccLevel;allocationMemo=new Map();invalidateProfitabilityRanking();recalc();autosave()};
1166    r.querySelector(".remove").onclick=()=>{
1167      clones.splice(i,1);
1168      if(!clones.length)clones=[{name:"Клон 1",limit:6,ccLevel:6}];
1169      renderClones();invalidateProfitabilityRanking();recalc();autosave();
1170    };
1171  });
1172  totals();
1173}
1174function totals(){$("cloneCount").textContent=clones.length;$("planetCapacity").textContent=cap()}
1175$("addCloneBtn").onclick=()=>{clones.push({name:`Клон ${clones.length+1}`,limit:6,ccLevel:6});renderClones();invalidateProfitabilityRanking();recalc();autosave()};
1176$("maxUtilization").onchange=()=>{invalidateProfitabilityRanking();recalc();autosave()};
1177$("separateP0").onchange=e=>{
1178  productionSettings.separateP0=e.target.checked;
1179  allocationMemo=new Map();
1180  invalidateProfitabilityRanking();recalc();autosave();
1181};
1182$("sharedP0Limit").onchange=e=>{
1183  productionSettings.sharedP0Limit=Math.max(1,Math.min(10,Math.round(Number(e.target.value)||1)));
1184  e.target.value=String(productionSettings.sharedP0Limit);allocationMemo=new Map();invalidateProfitabilityRanking();recalc();autosave();
1185};
1186$("separateFactories").onchange=e=>{
1187  productionSettings.separateFactories=e.target.checked;
1188  allocationMemo=new Map();
1189  if(selected.length){
1190    const test=calculateAllocation(selected,new Map(),1,balancedPlanningEnabled()&&selected.length>1);
1191    if(!test.ok)alert(translateSiteText(`Эта конфигурация производства невозможна.\n\n${test.reason||"Не хватает доступных планетарных слотов."}\n\nВыбранные материалы сохранены — измените настройки, количество планет или клонов.`));
1192  }
1193  invalidateProfitabilityRanking();recalc();autosave();
1194};
1195$("globalP0P1").onchange=e=>{
1196  globalPlanetSettings.p0p1=e.target.checked;
1197  invalidateProfitabilityRanking();recalc();autosave();
1198};
1199async function setMarketEvaluation(enabled){
1200  marketSettings.enabled=!!enabled;
1200invalidateProfitabilityRanking();renderMarketControls();autosave();
1201  if(marketSettings.enabled)metrikaGoal("market_enabled");
1202  if(marketSettings.enabled)await loadMarketTrends(false);else{marketLoadToken++;marketTrends=new Map();marketPrices=new Map();refreshMarketViews();if(selected.length)plan()}
1203}
1204$("marketEvaluation").onchange=e=>{void setMarketEvaluation(e.target.checked)};
1205$("marketHub").onchange=e=>{
1206  marketSettings.hub=e.target.value;invalidateProfitabilityRanking();marketTrends=new Map();marketPrices=new Map();marketPriceDate=null;autosave();loadMarketTrends(false);
1207};
1208$("marketTax").onchange=e=>{marketSettings.tax=clampPercent(e.target.value);e.target.value=marketSettings.tax;invalidateProfitabilityRanking();autosave();refreshMarketViews();if(selected.length)plan()};
1209$("marketBroker").onchange=e=>{marketSettings.broker=clampPercent(e.target.value);e.target.value=marketSettings.broker;invalidateProfitabilityRanking();autosave();refreshMarketViews();if(selected.length)plan()};
1210$("customsTax").onchange=e=>{marketSettings.customsTax=clampPercent(e.target.value);e.target.value=marketSettings.customsTax;invalidateProfitabilityRanking();autosave();refreshMarketViews();if(selected.length)plan()};
1211$("marketHauling").onchange=e=>{marketSettings.hauling=clampMoney(e.target.value);e.target.value=marketSettings.hauling;invalidateProfitabilityRanking();autosave();refreshMarketViews();if(selected.length)plan()};
1212$("refreshMarketBtn").onclick=()=>{invalidateProfitabilityRanking();loadMarketTrends(true)};
1213$("autoProfitBtn").onclick=()=>buildMaximumProfitPlan();
1214$("factoryLimit").onchange=e=>{
1215  productionSettings.factoryLimit=clampFactoryLimit(e.target.value);
1216  e.target.value=productionSettings.factoryLimit;
1217  allocationMemo=new Map();invalidateProfitabilityRanking();recalc();autosave();
1218};
1219renderProductionControls();
1220renderClones();
1221
1222function sysCounts(s){let x={};s.planets.forEach((n,i)=>x[D.meta.planetTypes[i]]=n||0);return x}
1223function p0set(pt){return new Set(D.p0ByPlanet[pt]||[])}
1224function localizedPlanetType(pt){
1225  const m={Barren:"Пустошь",Gas:"Газ",Ice:"Лёд",Lava:"Лава",Oceanic:"Океан",Plasma:"Плазма",Storm:"Буря",Temperate:"Умеренная"};
1226  return siteLanguage==="en"?pt:(m[pt]||pt);
1227}
1228function commandCenterName(pt){return `${pt} Command Center`}
1229function commandCenterIcon(pt){
1230  const safe=String(pt).replace(/[^A-Za-z]/g,"").toLowerCase();
1231  return `<img class="commandCenterIcon" src="assets/command-centers/${safe}.png" alt="" aria-hidden="true">`;
1232}
1233function commandCenterCounts(planets){
1234  const counts=new Map();
1235  (planets||[]).forEach(planet=>counts.set(planet.pt,(counts.get(planet.pt)||0)+1));
1236  return D.meta.planetTypes
1237    .map(pt=>({pt,count:counts.get(pt)||0}))
1238    .filter(item=>item.count>0);
1239}
1240function commandCenterCopyText(rows){
1241  return rows.map(({pt,count})=>`${commandCenterName(pt)} ${count}`).join("\n");
1242}
1243function renderCommandCenterSummary(planets){
1244  const box=$("commandCenterSummary");
1245  const rows=commandCenterCounts(planets);
1246  if(!rows.length){box.innerHTML="";box.classList.add("hidden");return}
1247  box.classList.remove("hidden");
1248  const centerLabel=siteLanguage==="en"?"Command Center":"Командный центр";
1249  const planetLabel=siteLanguage==="en"?"planet":"планета";
1250  const unitLabel=siteLanguage==="en"?"units":"шт.";
1251  box.innerHTML=`<div class="commandCenterSummaryHead"><span>НЕОБХОДИМЫЕ КОМАНДНЫЕ ЦЕНТРЫ</span>
1252      <button id="copyCommandCenters" class="copyCommandCenters" type="button" title="Скопировать список командных центров" aria-label="Скопировать список командных центров">⧉</button>
1253    </div>
1254    <div class="commandCenterList">${rows.map(({pt,count})=>`<div class="commandCenterRow">
1255      <span class="commandCenterType">${commandCenterIcon(pt)}${centerLabel}: ${esc(localizedPlanetType(pt))} ${planetLabel}</span>
1256      <b>${count} ${unitLabel}</b>
1257    </div>`).join("")}</div>`;
1258  $("copyCommandCenters").onclick=()=>copyText(commandCenterCopyText(rows),"Список командных центров скопирован");
1259}
1260function numberedPlanetLabels(system){
1261  const exact=normalizedPlanetDetails(system?.planetDetails);
1262  if(exact.length){
1263    const counts=sysCounts(system),requestedTotal=Object.values(counts).reduce((sum,count)=>sum+count,0),labels=[];
1264    D.meta.planetTypes.forEach((pt,typeIndex)=>{
1265      const known=exact.filter(planet=>planet.pt===pt),requested=requestedTotal?counts[pt]:known.length;
1266      known.slice(0,requested).forEach((planet,index)=>labels.push({
1267        pt:planet.pt,
1268        ordinal:planet.order,
1269        sortOrder:planet.order,
1270        key:planet.id?`planet:${planet.id}`:`planet-order:${planet.order}:${index}`,
1271        label:`${planet.name} - ${localizedPlanetType(planet.pt)}`,
1272        planetId:planet.id,
1273        planetName:planet.name,
1274        radiusKm:planet.radiusKm||0,
1275        radiusSource:planet.id&&planet.radiusKm>0?"automatic":"standard"
1276      }));
1277      for(let index=known.length;index<requested;index++)labels.push({
1278        pt,ordinal:index+1,sortOrder:1000+typeIndex*100+index,key:`manual-extra:${pt}:${index+1}`,
1279        label:`${localizedPlanetType(pt)} ${index+1}`,planetId:null,planetName:null,radiusKm:0,radiusSource:"standard"
1280      });
1281    });
1282    return labels.sort((a,b)=>a.sortOrder-b.sortOrder||a.label.localeCompare(b.label));
1283  }
1284  const labels=[],counts=sysCounts(system);
1285  Object.keys(counts).forEach(pt=>{
1286    for(let i=1;i<=counts[pt];i++)labels.push({pt,ordinal:i,sortOrder:D.meta.planetTypes.indexOf(pt)*100+i,key:`${pt}:${i}`,label:`${localizedPlanetType(pt)} ${i}`,planetId:null,planetName:null,radiusKm:0,radiusSource:"standard"});
1287  });
1288  return labels;
1289}
1290
1291function effectivePlanetSetting(key){
1292  return {...globalPlanetSettings};
1293}
1294function ensurePlanetSettings(){
1295  planetSettings={};
1296}
1297
1298function roots(id,m=new Map()){
1299  if(m.has(id))return new Set(m.get(id));
1300  let p=products.get(id),r=new Set;
1301  if(!p)return r;
1302  if(p.t===0)r.add(id); else p.i.forEach(([q])=>roots(q,m).forEach(x=>r.add(x)));
1303  m.set(id,[...r]);return r;
1304}
1305function dependencyProducts(ids){
1306  let seen=new Set();
1307  function walk(id){if(seen.has(id))return;seen.add(id);let p=products.get(id);if(!p)return;p.i.forEach(([iid])=>walk(iid))}
1308  ids.forEach(walk);
1309  return [...seen].map(id=>products.get(id)).filter(Boolean).sort((a,b)=>a.t-b.t||a.n.localeCompare(b.n));
1310}
1311function theoreticalProductsByTier(){
1312  const availableP0=new Set;
1313  D.meta.planetTypes.forEach(pt=>{
1314    if((planetCounts[pt]||0)>0)(D.p0ByPlanet[pt]||[]).forEach(id=>availableP0.add(id));
1315  });
1316  const possible=new Set(availableP0);
1317  for(let tier=1;tier<=4;tier++){
1318    D.products.filter(p=>p.t===tier).forEach(p=>{
1319      if(p.i.every(([id])=>possible.has(id)))possible.add(p.id);
1320    });
1321  }
1322  return [0,1,2,3,4].map(t=>D.products.filter(p=>p.t===t&&possible.has(p.id)).sort((a,b)=>a.n.localeCompare(b.n)));
1323}
1324function materialIcon(p,extraClass=""){
1325  return `<img class="materialIcon ${extraClass}" src="https://images.evetech.net/types/${p.id}/icon?size=64" alt="" width="24" height="24" loading="lazy">`;
1326}
1327function materialInline(p,extraClass=""){
1328  if(!p)return"";
1329  return `<span class="inlineMaterial ${extraClass}">${materialIcon(p,"inlineMaterialIcon")}<span>${esc(p.n)}</span></span>`;
1330}
1331async function copyText(text,successMessage){
1332  if(!text){toast("Нет доступных компонентов для копирования");return}
1333  try{
1334    const area=document.createElement("textarea");
1335    area.value=text;area.setAttribute("readonly","");
1336    area.style.position="fixed";area.style.left="-9999px";area.style.top="0";
1337    document.body.appendChild(area);area.focus();area.select();area.setSelectionRange(0,area.value.length);
1338    let copied=false;
1339    try{copied=!!document.execCommand("copy")}catch(_){}
1340    area.remove();
1341    if(!copied&&navigator.clipboard?.writeText){await navigator.clipboard.writeText(text);copied=true}
1342    if(!copied){
1343      window.prompt(translateSiteText("Автоматическое копирование заблокировано браузером. Нажмите Ctrl+C, затем Enter:"),text);
1344      return;
1345    }
1346    toast(successMessage);
1347  }catch(_){toast("Не удалось скопировать")}
1348}
1349function renderPotential(){
1350  const byTier=theoreticalProductsByTier();
1351  if(marketSettings.enabled)byTier.forEach(items=>items.sort((a,b)=>(marketPrices.get(b.id)??-1)-(marketPrices.get(a.id)??-1)||a.n.localeCompare(b.n)));
1352  $("potentialColumns").innerHTML=byTier.map((items,t)=>`<section class="potentialTier">
1353    <div class="potentialHead"><b>P${t}</b><button class="copyTier" type="button" data-tier="${t}" title="Скопировать доступные компоненты P${t}" aria-label="Скопировать доступные компоненты P${t}">⧉</button></div>
1354    <div class="potentialList">${items.map(p=>`<span>${materialIcon(p,"potentialMaterialIcon")}<span class="potentialMaterialName">${esc(p.n)}</span>${marketIndicator(p.id)}</span>`).join("")||`<em>Нет доступных</em>`}</div>
1355  </section>`).join("");
1356  document.querySelectorAll(".copyTier").forEach(btn=>btn.onclick=()=>{
1357    const t=Number(btn.dataset.tier),items=byTier[t]||[],text=items.map(p=>p.n).join("\n");
1358    if(!text){toast(siteLanguage==="en"?`P${t} has no available components`:`В P${t} нет доступных компонентов`);return}
1359    copyText(text,`P${t} скопирован`);
1360  });
1361}
1362renderPotential();
1363function directP1ForP0(id){return D.products.find(p=>p.t===1&&p.i.some(([iid])=>iid===id))}
1364function stageKey(tier){return tier===1?"p0p1":tier===2?"p1p2":tier===3?"p2p3":"p3p4"}
1365function supportsFactoryTier(planetType,tier){return tier<4||planetType==="Barren"||planetType==="Temperate"}
1366function balancedPlanningEnabled(){return !!$("maxUtilization")?.checked}
1367
1368
1369function physicalPlanets(){
1370  if(!current)return[];
1371  ensurePlanetSettings();
1372  return numberedPlanetLabels(current.system).map(p=>({...p,setting:effectivePlanetSetting(p.key)}));
1373}
1374
1375/*
1376  ВАЖНО:
1377  одна и та же физическая планета может быть освоена РАЗНЫМИ клонами.
1378  Ограничение уникальности действует только внутри одного клона:
1379  один клон не может занять одну и ту же физическую планету дважды.
1380*/
1381function colonyCandidates(){
1382  const pp=physicalPlanets();
1383  const out=[];
1384  clones.forEach((c,ci)=>{
1385    pp.forEach(p=>out.push({
1386      colonyKey:`c${ci}:${p.key}`,
1387      cloneIndex:ci,
1388      cloneName:c.name,
1389      planetKey:p.key,
1390      pt:p.pt,
1391      ordinal:p.ordinal,
1392      sortOrder:p.sortOrder,
1393      label:p.label,
1394      planetId:p.planetId??null,
1395      planetName:p.planetName||null,
1396      radiusKm:Math.max(0,Number(p.radiusKm)||0),
1397      radiusSource:p.radiusSource||((p.planetId&&p.radiusKm>0)?"automatic":"standard"),
1398      setting:{...p.setting}
1399    }));
1400  });
1401  return out;
1402}
1403function usedCountByClone(used){
1404  const counts=Array(clones.length).fill(0);
1405  used.forEach((_,key)=>{
1406    const m=/^c(\d+):/.exec(key);
1407    if(m)counts[+m[1]]++;
1408  });
1409  return counts;
1410}
1411function canOpenColony(candidate,used){
1412  if(used.has(candidate.colonyKey))return true;
1413  const counts=usedCountByClone(used);
1414  return counts[candidate.cloneIndex] < clones[candidate.cloneIndex].limit;
1415}
1416
1417/*
1418  Минимальный набор "освоений планет" для нужных P0.
1419  Ключ слота включает клона, поэтому, например, B6-XE8 VI может одновременно
1420  использоваться Клоном 1 и Клоном 2.
1421*/
1422function targetCopyMap(ids,targetCopies=1){
1423  if(targetCopies instanceof Map)return new Map(ids.map(id=>[id,Math.max(1,Math.round(Number(targetCopies.get(id))||1))]));
1424  if(targetCopies&&typeof targetCopies==="object")return new Map(ids.map(id=>[id,Math.max(1,Math.round(Number(targetCopies[id])||1))]));
1425  const copies=Math.max(1,Math.round(Number(targetCopies)||1));
1426  return new Map(ids.map(id=>[id,copies]));
1427}
1428function factoryRequirements(ids,targetCopies=1){
1429  const requirements=new Map(targetCopyMap(ids,targetCopies));
1430  for(let tier=4;tier>=2;tier--){
1431    const inputRates=new Map();
1432    for(const product of D.products.filter(p=>p.t===tier&&requirements.has(p.id))){
1433      const factoryCount=requirements.get(product.id)||0;
1434      const cyclesPerHour=3600/Math.max(1,Number(product.c)||3600);
1435      for(const [inputId,quantity] of product.i||[]){
1436        const input=products.get(inputId);
1437        if(input?.t>=1)inputRates.set(inputId,(inputRates.get(inputId)||0)+factoryCount*cyclesPerHour*quantity);
1438      }
1439    }
1440    for(const [inputId,unitsPerHour] of inputRates){
1441      const input=products.get(inputId);
1442      const outputPerHour=(Number(input.q)||1)*3600/Math.max(1,Number(input.c)||3600);
1443      requirements.set(inputId,(requirements.get(inputId)||0)+Math.ceil(unitsPerHour/outputPerHour-1e-9));
1444    }
1445  }
1446  return requirements;
1447}
1448function minimumFactoryRequirements(ids){
1449  return new Map(dependencyProducts(ids).filter(product=>product.t>=1).map(product=>[product.id,1]));
1450}
1451function targetFactoryRequirements(ids,targetCopies=1,balanced=true){
1452  const copies=targetCopyMap(ids,targetCopies),byTarget=new Map();
1453  for(const targetId of ids){
1454    const count=copies.get(targetId)||1;
1455    byTarget.set(targetId,balanced?factoryRequirements([targetId],count):minimumFactoryRequirements([targetId]));
1456  }
1457  return byTarget;
1458}
1459function allocationFactoryRequirements(ids,targetCopies=1,balanced=true){
1460  const requirements=balanced?factoryRequirements(ids,targetCopies):minimumFactoryRequirements(ids);
1461  if(!productionSettings.separateP0||ids.length<2)return requirements;
1462
1463  // Shared recipes may occur in several selected chains (for example a
1464  // separately selected Biofuels line and Biofuels required by Biocells).
1465  // In separate-P0 mode each target keeps its own P0 -> P1 line instead of
1466  // silently reusing the first target's extractor and basic factory.
1467  const p1Totals=new Map();
1468  for(const targetRequirements of targetFactoryRequirements(ids,targetCopies,balanced).values()){
1469    for(const [productId,count] of targetRequirements){
1470      if(products.get(productId)?.t===1)p1Totals.set(productId,(p1Totals.get(productId)||0)+count);
1471    }
1472  }
1473  for(const product of D.products.filter(p=>p.t===1))requirements.delete(product.id);
1474  for(const [productId,count] of p1Totals)requirements.set(productId,count);
1475  return requirements;
1476}
1477function extractionDemands(ids,targetCopies=1,balanced=true){
1478  const copies=targetCopyMap(ids,targetCopies),requirements=allocationFactoryRequirements(ids,targetCopies,balanced),demands=[];
1479  for(const id of ids){
1480    if(products.get(id)?.t!==0)continue;
1481    for(let instance=1;instance<=(copies.get(id)||1);instance++)demands.push({id,targetId:id,productId:null,instance,token:`${id}>P0@${id}:${instance}`});
1482  }
1483  if(productionSettings.separateP0){
1484    for(const [targetId,targetRequirements] of targetFactoryRequirements(ids,targetCopies,balanced)){
1485      for(const product of D.products.filter(p=>p.t===1&&targetRequirements.has(p.id))){
1486        const count=targetRequirements.get(product.id)||0,inputId=product.i?.[0]?.[0];
1487        if(inputId==null)continue;
1488        for(let instance=1;instance<=count;instance++)demands.push({id:inputId,targetId,productId:product.id,instance,token:`${inputId}>${product.id}@${targetId}:${instance}`});
1489      }
1490    }
1491    return demands;
1492  }
1493  for(const product of D.products.filter(p=>p.t===1&&requirements.has(p.id))){
1494    const count=requirements.get(product.id)||0,inputId=product.i?.[0]?.[0];
1495    if(inputId==null)continue;
1496    const owner=ids.find(targetId=>dependencyProducts([targetId]).some(p=>p.id===product.id))||ids[0]||null;
1497    for(let instance=1;instance<=count;instance++)demands.push({id:inputId,targetId:owner,productId:product.id,instance,token:`${inputId}>${product.id}@${owner}:${instance}`});
1498  }
1499  return demands;
1500}
1501
1502function mergedUsed(reserved,used){return new Map([...(reserved||new Map()),...used])}
1503function extractionCapacity(candidate){
1504  return Math.min(
1505    candidate.setting.maxP0,
1506    productionSettings.sharedP0Limit||1,
1507    globalPlanetSettings.p0p1?clampFactoryLimit(productionSettings.factoryLimit):99
1508  );
1509}
1510function minExtractionCover(demands,reserved=new Map()){
1511  if(!demands.length)return{ok:true,count:0,colonies:[],planets:[],p0Assignment:{},p0AssignmentDetails:{}};
1512
1513  const pool=colonyCandidates().filter(x=>!reserved.has(x.colonyKey));
1514  const byKey=new Map(pool.map(x=>[x.colonyKey,x]));
1515  const candidatesByP0=new Map();
1516  const demandByToken=new Map(demands.map(d=>[d.token,d]));
1517  const missing=new Set();
1518  for(const d of demands){
1519    const c=pool.filter(x=>p0set(x.pt).has(d.id));
1520    if(!c.length){missing.add(d.id);continue}
1521    candidatesByP0.set(d.token,c.map(x=>x.colonyKey));
1522  }
1523  if(missing.size)return{ok:false,missing:[...missing]};
1524
1525  const ordered=demands.map(d=>d.token).sort((a,b)=>candidatesByP0.get(a).length-candidatesByP0.get(b).length);
1526  const used=new Map(); // colonyKey -> Set(demand token)
1527  for(const token of ordered){
1528    const demand=demandByToken.get(token);
1529    const valid=key=>{
1530      const cand=byKey.get(key),currentSet=used.get(key)||new Set();
1531      if(!canOpenColony(cand,mergedUsed(reserved,used))||currentSet.size>=extractionCapacity(cand))return false;
1532      if(productionSettings.separateP0&&[...used.entries()].some(([usedKey,tokens])=>
1533        byKey.get(usedKey).planetKey===cand.planetKey&&[...tokens].some(x=>{
1534          const existing=demandByToken.get(x);
1535          return existing.id===demand.id&&existing.targetId!==demand.targetId;
1536        })
1537      ))return false;
1538      return true;
1539    };
1540    const allKeys=candidatesByP0.get(token);
1541    const key=allKeys.filter(k=>used.has(k)&&valid(k)).sort((a,b)=>used.get(b).size-used.get(a).size)[0]
1542      ||allKeys.find(k=>!used.has(k)&&valid(k));
1543    if(!key)return{ok:false,capacity:true};
1544    if(!used.has(key))used.set(key,new Set());
1545    used.get(key).add(token);
1546  }
1547  return{
1548    ok:true,count:used.size,
1549    colonies:[...used.keys()].map(k=>byKey.get(k)),
1550    planets:[...used.keys()].map(k=>byKey.get(k)),
1551    p0Assignment:Object.fromEntries([...used.entries()].map(([k,v])=>[k,[...v].map(token=>demandByToken.get(token).id)])),
1552    p0AssignmentDetails:Object.fromEntries([...used.entries()].map(([k,v])=>[k,[...v].map(token=>({...demandByToken.get(token)}))]))
1553  };
1554}
1555
1556function requiredStages(ids,targetCopies=1,balanced=true){
1557  const requirements=allocationFactoryRequirements(ids,targetCopies,balanced),stages=[];
1558  for(const product of dependencyProducts(ids).filter(p=>p.t>=1)){
1559    const count=requirements.get(product.id)||0;
1560    for(let instance=1;instance<=count;instance++)stages.push({product,key:stageKey(product.t),instance,count});
1561  }
1562  return stages;
1563}
1564
1565/*
1566  Добавляем места для переработки.
1567
1568  Галочки p0p1/p1p2/p2p3/p3p4 означают возможность совместить очередной
1569  этап с изготовлением его непосредственных компонентов на одной колонии.
1570  Снятая галочка НЕ запрещает этап: он переносится на отдельную сборочную
1571  колонию.
1572*/
1573function addProcessingPlanets(extraction,ids,reserved=new Map(),targetCopies=1,balanced=true){
1574  const pool=colonyCandidates().filter(x=>!reserved.has(x.colonyKey));
1575  const used=new Map(extraction.colonies.map(x=>[x.colonyKey,x]));
1576  const factoryKeys=new Set();
1577  const stageTasks=requiredStages(ids,targetCopies,balanced);
1578  const stageHomesByProduct=new Map(),stagePlacements=[];
1579  const extractionKeys=new Set(extraction.colonies.map(x=>x.colonyKey));
1580  const factoryCounts=new Map();
1581  const totalFactories=key=>Object.values(factoryCounts.get(key)||{}).reduce((sum,count)=>sum+count,0);
1582  const hasStageCapacity=key=>totalFactories(key)<clampFactoryLimit(productionSettings.factoryLimit);
1583  const addStage=(key,tier)=>{
1584    const counts=factoryCounts.get(key)||{};
1585    counts[tier]=(counts[tier]||0)+1;
1586    factoryCounts.set(key,counts);
1587    factoryKeys.add(key);
1588  };
1589
1590  // P0 already has a home: the colony that extracts it.
1591  for(const [colonyKey,p0ids] of Object.entries(extraction.p0Assignment)){
1592    p0ids.forEach(id=>{
1593      if(!stageHomesByProduct.has(id))stageHomesByProduct.set(id,[]);
1594      stageHomesByProduct.get(id).push(colonyKey);
1595    });
1596  }
1597
1598  for(const st of stageTasks){
1599    const inputHomes=new Set(st.product.i.flatMap(([id])=>stageHomesByProduct.get(id)||[]));
1600
1601    // Сначала размещаем этап рядом с его непосредственными компонентами:
1602    // это уменьшает межпланетные перевозки и таможенные сборы. P0 → P1
1603    // попадает на добывающую планету только при включённой переработке на месте.
1604    let home=[...used.values()].find(x=>{
1605      const coLocationAllowed=st.product.t===1?globalPlanetSettings.p0p1:x.setting[st.key];
1606      const assemblyAllowed=st.product.t===1||!productionSettings.separateFactories||!extractionKeys.has(x.colonyKey);
1607      return supportsFactoryTier(x.pt,st.product.t)&&coLocationAllowed&&assemblyAllowed&&inputHomes.has(x.colonyKey)&&hasStageCapacity(x.colonyKey);
1608    });
1609
1610    // Иначе используем уже открытую сборочную планету с общим свободным
1611    // местом. Одинаковые и разные уровни заводов расходуют один и тот же лимит.
1612    if(!home)home=[...used.values()].find(x=>{
1613      const extractionReuseAllowed=!extractionKeys.has(x.colonyKey)||(st.product.t>1&&!productionSettings.separateFactories);
1614      return supportsFactoryTier(x.pt,st.product.t)&&factoryKeys.has(x.colonyKey)&&extractionReuseAllowed&&!inputHomes.has(x.colonyKey)&&hasStageCapacity(x.c
1614olonyKey);
1615    });
1616
1617    // Otherwise open a separate factory colony. Its checkbox controls future
1618    // co-location; it does not prohibit production on this colony.
1619    if(!home){
1620      const free=pool.find(x=>supportsFactoryTier(x.pt,st.product.t)&&!used.has(x.colonyKey)&&canOpenColony(x,mergedUsed(reserved,used)));
1621      if(free){
1622        used.set(free.colonyKey,free);
1623        factoryKeys.add(free.colonyKey);
1624        home=free;
1625      }
1626    }
1627
1628    if(!home){
1629      const stageLabel=st.product.t===1?"P0 → P1":`P${st.product.t-1} → P${st.product.t}`;
1630      if(st.product.t===4)return{ok:false,reason:"Для производства P4 нужна свободная планета типа «Пустошь» или «Умеренная»"};
1631      return{ok:false,reason:`Для отдельного этапа ${stageLabel} не хватает свободного планетарного слота у клонов`};
1632    }
1633
1634    if(!stageHomesByProduct.has(st.product.id))stageHomesByProduct.set(st.product.id,[]);
1635    stageHomesByProduct.get(st.product.id).push(home.colonyKey);
1636    stagePlacements.push({productId:st.product.id,tier:st.product.t,instance:st.instance,homeKey:home.colonyKey});
1637    addStage(home.colonyKey,st.product.t);
1638  }
1639  const stageHomes=Object.fromEntries([...stageHomesByProduct].filter(([id])=>products.get(+id)?.t>=1).map(([id,homes])=>[id,homes]));
1640
1641  return{
1642    ok:true,
1643    colonies:[...used.values()],
1644    planets:[...used.values()],
1645    count:used.size,
1646    p0Assignment:extraction.p0Assignment,
1647    p0AssignmentDetails:extraction.p0AssignmentDetails,
1648    stageHomes,
1649    stagePlacements,
1650    factoryCounts:Object.fromEntries(factoryCounts),
1651    factoryColonies:[...factoryKeys]
1652  };
1653}
1654
1655function allocationIntegrity(result,ids,targetCopies,balanced){
1656  const errors=[];
1657  if(!result?.ok||!result.cover)return{ok:true,mode:balanced?"balanced":"minimum",checks:0};
1658  const cover=result.cover,colonies=cover.colonies||[],colonyKeys=new Set(colonies.map(x=>x.colonyKey));
1659  if(colonyKeys.size!==colonies.length)errors.push("duplicate colony");
1660  if(cover.count!==colonies.length)errors.push("colony count mismatch");
1661  if(colonies.length+(result.reservedCount||0)>cap())errors.push("global colony limit exceeded");
1662  const cloneCounts=Array(clones.length).fill(0);
1663  colonies.forEach(colony=>{
1664    if(colony.cloneIndex<0||colony.cloneIndex>=clones.length)errors.push("unknown clone");
1665    else cloneCounts[colony.cloneIndex]++;
1666  });
1667  cloneCounts.forEach((count,index)=>{if(count>(clones[index]?.limit||0))errors.push(`clone ${index+1} colony limit exceeded`)});
1668
1669  const expected=allocationFactoryRequirements(ids,targetCopies,balanced),actual=new Map();
1670  for(const placement of cover.stagePlacements||[]){
1671    actual.set(placement.productId,(actual.get(placement.productId)||0)+1);
1672    const home=colonies.find(x=>x.colonyKey===placement.homeKey);
1673    if(!home)errors.push(`factory ${placement.productId} has no colony`);
1674    if(placement.tier===4&&home&&!supportsFactoryTier(home.pt,4))errors.push(`P4 factory on ${home.pt}`);
1675  }
1676  for(const [productId,count] of expected)if(products.get(productId)?.t>=1&&(actual.get(productId)||0)!==count)errors.push(`factory count ${productId}: ${actual.get(productId)||0}/${count}`);
1677  for(const [productId,count] of actual)if(!expected.has(productId)&&count)errors.push(`unexpected factory ${productId}`);
1678
1679  const factoriesByColony=new Map();
1680  for(const placement of cover.stagePlacements||[])factoriesByColony.set(placement.homeKey,(factoriesByColony.get(placement.homeKey)||0)+1);
1681  for(const [key,count] of factoriesByColony)if(count>clampFactoryLimit(productionSettings.factoryLimit))errors.push(`factory limit exceeded on ${key}`);
1682
1683  for(const [key,p0ids] of Object.entries(cover.p0Assignment||{})){
1684    const colony=colonies.find(x=>x.colonyKey===key);
1685    if(!colony){errors.push(`P0 assignment has no colony ${key}`);continue}
1686    for(const id of p0ids)if(!p0set(colony.pt).has(id))errors.push(`P0 ${id} unavailable on ${colony.pt}`);
1687  }
1688  const expectedDemands=new Set(extractionDemands(ids,targetCopies,balanced).map(d=>d.token));
1689  const actualDetails=Object.values(cover.p0AssignmentDetails||{}).flat();
1690  const actualDemands=new Set(actualDetails.map(d=>d.token));
1691  for(const token of expectedDemands)if(!actualDemands.has(token))errors.push(`missing P0 demand ${token}`);
1692  for(const token of actualDemands)if(!expectedDemands.has(token))errors.push(`unexpected P0 demand ${token}`);
1693  for(const [key,details] of Object.entries(cover.p0AssignmentDetails||{})){
1694    const colony=colonies.find(x=>x.colonyKey===key);
1695    if(colony&&details.length>extractionCapacity(colony))errors.push(`P0 line limit exceeded on ${key}`);
1696  }
1697  if(productionSettings.separateP0){
1698    const targetsByPhysicalPlanet=new Map();
1699    for(const [key,details] of Object.entries(cover.p0AssignmentDetails||{})){
1700      const colony=colonies.find(x=>x.colonyKey===key);if(!colony)continue;
1701      if(!targetsByPhysicalPlanet.has(colony.planetKey))targetsByPhysicalPlanet.set(colony.planetKey,new Map());
1702      const resources=targetsByPhysicalPlanet.get(colony.planetKey);
1703      for(const detail of details){
1704        if(!resources.has(detail.id))resources.set(detail.id,new Set());
1705        resources.get(detail.id).add(detail.targetId);
1706      }
1707    }
1708    for(const [planetKey,resources] of targetsByPhysicalPlanet)for(const [p0id,targetIds] of resources){
1709      if(targetIds.size>1)errors.push(`shared P0 ${p0id} between targets on ${planetKey}`);
1710    }
1711  }
1712  const outputs=allocationOutputsPerDay(result,ids);
1713  ids.forEach(id=>{if((products.get(id)?.t||0)>0&&!((outputs.get(id)||0)>0))errors.push(`no target output ${id}`)});
1714  return{ok:errors.length===0,mode:balanced?"balanced":"minimum",checks:13,errors};
1715}
1716
1717function calculateAllocation(ids,reserved=new Map(),targetCopies=1,balanced=true){
1718  const requestedCopies=targetCopyMap(ids,targetCopies);
1719  let need=new Set;
1720  ids.forEach(id=>roots(id).forEach(x=>need.add(x)));
1721  const extraction=minExtractionCover(extractionDemands(ids,targetCopies,balanced),reserved);
1722  if(!extraction.ok)return{ok:false,cover:extraction,need,balanced,selfCheck:{ok:true,mode:balanced?"balanced":"minimum",checks:1},reason:extraction.missing?"В системе отсутствует необходимый P0":productionSettings.separateP0?"Не хватает планетарных слотов для раздельной добычи P0":"Не хватает планетарных слотов у клонов с учётом ограничений добычи P0"};
1723  const processing=addProcessingPlanets(extraction,ids,reserved,targetCopies,balanced);
1724  if(!processing.ok)return{ok:false,cover:extraction,need,balanced,selfCheck:{ok:true,mode:balanced?"balanced":"minimum",checks:1},reason:processing.reason};
1725  const result={ok:true,cover:processing,need,balanced,reservedCount:reserved.size,free:cap()-reserved.size-processing.count,targetIds:[...ids],targetCopies:Object.fromEntries(requestedCopies)};
1726  result.selfCheck=allocationIntegrity(result,ids,targetCopies,balanced);
1727  if(!result.selfCheck.ok){
1728    console.error("PI allocation self-check failed",result.selfCheck.errors);
1729    return{...result,ok:false,internalError:true,reason:"Внутренняя проверка обнаружила ошибку построения плана. Измените настройки и отправьте отладочный файл разработчику."};
1730  }
1731  return result;
1732}
1733function allocation(ids){
1734  // A single selected target always starts from its feasible minimum chain.
1735  // Maximum utilization is then applied by buildAllocationSegments: a full
1736  // balanced copy if possible, otherwise useful P3→P0 component chains.
1737  // Multiple simultaneous targets retain the stricter balanced reservation so
1738  // an added material cannot consume the output promised to earlier targets.
1739  const balanced=balancedPlanningEnabled()&&ids.length>1;
1740  const memoKey=`${balanced?"balanced-multi":"minimum-base"}|${[...ids].map(Number).sort((a,b)=>a-b).join(",")}`;
1741  if(allocationMemo.has(memoKey))return allocationMemo.get(memoKey);
1742  const result=calculateAllocation(ids,new Map(),1,balanced);
1743  if(result.balanced!==balanced){
1744    console.error("PI base allocation mode mismatch",{expected:balanced,actual:result.balanced});
1745    return{...result,ok:false,internalError:true,reason:"Внутренняя проверка обнаружила ошибку базового расчёта производства."};
1746  }
1747  allocationMemo.set(memoKey,result);
1748  return result;
1749}
1750function avail(id){
1751  if(selected.includes(id))return{ok:true};
1752  let a=allocation([...selected,id]);
1753  if(a.ok)return{ok:true,a};
1754  if(a.cover?.missing)return{ok:false,reason:"Нет P0: "+a.cover.missing.map(x=>products.get(x)?.n||x).join(", ")};
1755  return{ok:false,reason:a.reason||(siteLanguage==="en"?`More available planets are required (limit ${cap()})`:`Нужно больше доступных планет (лимит ${cap()})`)};
1756}
1757
1758function materialDisplaySet(tier,showUnavailable){
1759  const all=D.products.filter(p=>p.t===tier);
1760  if(marketSettings.enabled)all.sort((a,b)=>(marketPrices.get(b.id)??-1)-(marketPrices.get(a.id)??-1)||a.n.localeCompare(b.n));
1761  const available=all.filter(p=>avail(p.id).ok);
1762  return{all,available,displayed:showUnavailable?all:available};
1763}
1764function mats(){
1765  let show=$("showUnavailable").checked;
1766  const displayedByTier=[];
1767  $("materialColumns").innerHTML=[0,1,2,3,4].map(t=>{
1768    const {all:ps,available,displayed}=materialDisplaySet(t,show);
1769    displayedByTier[t]=displayed;
1770    let rows=ps.map(p=>{
1771      let a=avail(p.id),sel=selected.includes(p.id);
1772      if(!show&&!a.ok)return"";
1773      let meta=sel?"Выбрано":a.ok?`Необходимо планет ${allocation([...selected,p.id]).cover.count}`:"";
1774      return`<label class="mat ${a.ok?"":"disabled"} ${sel?"selected":""}" title="${esc(a.reason||"")}">
1775        <input type="checkbox" data-id="${p.id}" ${sel?"checked":""} ${a.ok?"":"disabled"}>
1776        ${materialIcon(p,"materialListIcon")}
1777        <div><div class="matName"><span>${esc(p.n)}</span>${marketIndicator(p.id)}</div><div class="meta">${esc(meta)}</div>${a.ok?"":`<div class="reason">${esc(a.reason)}</div>`}</div>
1778      </label>`;
1779    }).join("");
1780    let ok=available.length;
1781    return`<div class="matCol"><div class="matHead"><b>P${t}</b><div class="matHeadActions"><span>${ok}/${ps.length}</span><button class="copyMaterialsTier" type="button" data-tier="${t}" title="Скопировать показанные материалы P${t}" aria-label="Скопировать показанные материалы P${t}">⧉</button></div></div><div class="matList">${rows}</div></div>`;
1782  }).join("");
1783  document.querySelectorAll(".copyMaterialsTier").forEach(btn=>btn.onclick=()=>{
1784    const t=Number(btn.dataset.tier),items=displayedByTier[t]||[],text=items.map(p=>p.n).join("\n");
1785    copyText(text,`Показанные материалы P${t} скопированы`);
1786  });
1787  renderProfitabilityRanking();
1788  document.querySelectorAll(".mat input").forEach(cb=>cb.onchange=e=>{
1789    let id=+e.target.dataset.id;
1790    if(e.target.checked){
1791      if(avail(id).ok&&!selected.includes(id)){
1792        ensureTargetColor(id);selected.push(id);
1793        const product=products.get(id);
1794        metrikaGoal("material_selected",{material_id:id,material_name:product?.n||String(id),tier:product?.t});
1795      }
1796    }
1797    else selected=selected.filter(x=>x!==id);
1798    recalc();autosave();
1799  });
1800}
1801
1802function renderProfitabilityRanking(){
1803  const box=$("profitabilityRanking");
1804  if(!box)return;
1805  if(!marketSettings.enabled){box.classList.add("hidden");box.innerHTML="";return}
1806  box.classList.remove("hidden");
1807  if(planetCountsDirty){
1808    box.innerHTML=`<div class="profitabilityRankingHead">САМЫЙ ВЫГОДНЫЙ ДОСТУПНЫЙ ВАРИАНТ</div><span class="profitabilityRankingEmpty">Количество планет изменено. Ожидается автоматический перерасчёт…</span>`;
1809    return;
1810  }
1811  const signature=profitabilitySignature(),ready=signature&&signature===profitabilityRankingSignature&&signature===profitabilityRankingVerifiedSignature&&profitabilityRankingCache.length;
1812  if(!ready){
1813    box.innerHTML=`<div class="profitabilityRankingHead">САМЫЙ ВЫГОДНЫЙ ДОСТУПНЫЙ ВАРИАНТ</div><span class="profitabilityRankingEmpty">${marketLoading?"Загружаем текущие цены…":profitabilityRankingStage||"Подготавливаем проверку производственных конфигураций…"}</span>`;
1814    if(!marketLoading)ensureProfitabilityRanking().then(()=>renderProfitabilityRanking());
1815    return;
1816  }
1817  const ranked=profitabilityRankingCache.slice(0,8),best=ranked[0];
1818  const bestExtra=best.lines.filter(x=>x.id!==best.product.id).map(x=>x.name).join(", ");
1819  const p0Forecast=best.p0Requirements?.length?`<section class="p0Forecast"><div class="p0ForecastHead"><b>P0 ДЛЯ НЕПРЕРЫВНОЙ РАБОТЫ 24/7</b><span>Прогноз достижим при стабильной добыче не ниже указанных объёмов.</span></div><div class="p0ForecastTable"><div class="p0ForecastRow p0ForecastLabels"><span>Ресурс P0</span><span>В час</span><span>В сутки</span><span>За 30 дней</span></div>${best.p0Requirements.map(row=>`<div class="p0ForecastRow">${materialIcon(products.get(row.id),"p0ForecastIcon")}<span>${esc(row.name)}</span><b>${Math.round(row.hour).toLocaleString(siteLocale())}</b><b>${Math.round(row.day).toLocaleString(siteLocale())}</b><b>${Math.round(row.days30).toLocaleString(siteLocale())}</b></div>`).join("")}</div><small>Фактическая добыча зависит от залежей, программы экстрактора и частоты её перезапуска. При меньшей добыче заводы будут простаивать, а реальная прибыль окажется ниже прогноза.</small></section>`:"";
1820  box.innerHTML=`<div class="profitabilityRankingHead">САМЫЙ ВЫГОДНЫЙ ДОСТУПНЫЙ ВАРИАНТ</div>
1821    <div class="profitabilityBest">${materialIcon(best.product,"profitabilityBestIcon")}<span><b>${esc(best.product.n)}</b><small>P${best.product.t} · заводов конечного продукта: ${best.targetLines} · выпуск: ${Math.round((best.mainLine?.producedPerDay||0)*30).toLocaleStr
1821ing(siteLocale())} шт. / 30 дней · занято ${best.usedPlanets} ${siteLanguage==="en"?"of":"из"} ${cap()} ${siteLanguage==="en"?"planets":"планет"}${bestExtra?` · дополнительно: ${esc(bestExtra)}`:""}</small></span><strong>${Math.round(best.total30Days).toLocaleString(siteLocale())} ISK / 30 дней</strong></div>
1822    ${p0Forecast}
1823    <div class="profitabilityRankingList">${ranked.map((x,index)=>`<div class="profitabilityRank ${selected.includes(x.product.id)?"selectedRank":"positive"}"><b>${index+1}</b>${materialIcon(x.product,"profitabilityIcon")}<span>P${x.product.t} · ${esc(x.product.n)}<small>${x.targetLines} завод. цели · ${x.usedPlanets} план. · ${siteLanguage==="en"?"output":"выпуск"}: ${Math.round((x.mainLine?.producedPerDay||0)*30).toLocaleString(siteLocale())} ${siteLanguage==="en"?"units / 30 days":"шт. / 30 дн."}</small></span><strong>${Math.round(x.total30Days).toLocaleString(siteLocale())} ISK / 30 дн.</strong></div>`).join("")}</div>
1824    <small>Сравнивается ориентировочная чистая продажа за 30 дней. Выпуск ограничивается реальным узким местом всей рецептурной цепочки и средней ликвидностью рынка. Расчётная цена — средневзвешенная цена фактических сделок за последние 30 завершённых дней. Учтены налог с продаж, брокерская комиссия, экспорт и импорт через таможню, а также перевозка; добыча P0 остаётся ориентировочной без заданного дебита экстракторов.</small>`;
1825}
1826
1827
1828function instructionsForAllocation(a,planIds,ownerTargetIds=null,rateFactors=new Map()){
1829  const used=a.cover.colonies.map(x=>({
1830    ...x,
1831    tasks:[],
1832    p0:[...(a.cover.p0Assignment[x.colonyKey]||[])],
1833    p0Details:[...(a.cover.p0AssignmentDetails?.[x.colonyKey]||[])].map(d=>({...d}))
1834  }));
1835  const usedByKey=new Map(used.map(x=>[x.colonyKey,x]));
1836  const homeCursor={};
1837  const nextStageHome=productId=>{
1838    const homes=a.cover.stageHomes?.[productId]||[];
1839    const index=homeCursor[productId]||0;homeCursor[productId]=index+1;
1840    return homes[index]||homes[homes.length-1];
1841  };
1842  const taskOwnerIds=(productId,fallbackTargetId=null)=>{
1843    if(productionSettings.separateP0&&fallbackTargetId!=null)return[fallbackTargetId];
1844    const owners=targetOwnersFor(ownerTargetIds||[],productId);
1845    return owners.length?owners:(fallbackTargetId!=null?[fallbackTargetId]:null);
1846  };
1847
1848  // Добыча + P0->P1.
1849  for(const c of used){
1850    const details=c.p0Details.length?c.p0Details:c.p0.map(id=>({id,targetId:null}));
1851    for(const detail of details){
1852      const id=detail.id;
1853      const p0=products.get(id);
1854      if(detail.productId==null){
1855        c.tasks.push({kind:"extract",productId:id,targetIds:taskOwnerIds(id,detail.targetId),rateFactor:1,text:`Добыча P0: ${p0.n}`});
1856        continue;
1857      }
1858      const p1=products.get(detail.productId)||directP1ForP0(id);
1859      if(p1){
1860        const homeKey=nextStageHome(p1.id);
1861        const home=usedByKey.get(homeKey);
1862        const rateFactor=rateFactors.get(p1.id)??1;
1863        const task={kind:"assembly",productId:p1.id,targetIds:taskOwnerIds(p1.id,detail.targetId),rateFactor,text:`P0 → P1: ${p0.n} → ${p1.n}`};
1864        if(globalPlanetSettings.p0p1){
1865          c.tasks.push(task);
1866        }else{
1867          c.tasks.push({kind:"extract",productId:id,targetIds:taskOwnerIds(id,detail.targetId),rateFactor,text:`Добыча P0: ${p0.n}`});
1868          if(homeKey===c.colonyKey){
1869          c.tasks.push(task);
1870          }else if(home){
1871          home.tasks.push({...task,text:`P0 → P1: ${p0.n} → ${p1.n} (сырьё: ${c.cloneName} / ${c.label})`});
1872          }
1873        }
1874      }
1875    }
1876  }
1877
1878  // P1 -> P2 -> P3 -> P4.
1879  for(const placement of (a.cover.stagePlacements||[]).filter(x=>x.tier>=2)){
1880    const p=products.get(placement.productId),home=usedByKey.get(placement.homeKey);
1881    if(home){
1882      const inputs=p.i.map(([id])=>products.get(id)?.n||id).join(" + ");
1883      home.tasks.push({kind:"assembly",productId:p.id,targetIds:taskOwnerIds(p.id),rateFactor:rateFactors.get(p.id)??1,text:`P${p.t-1} → P${p.t}: ${inputs} = ${p.n}`});
1884    }
1885  }
1886
1887  // Одинаковые заводы одного рецепта на одной планете показываем одной строкой.
1888  for(const colony of used){
1889    const grouped=new Map();
1890    for(const task of colony.tasks){
1891      const key=`${task.kind}|${task.productId}|${task.text}|${(task.targetIds||[]).join(",")}`;
1892      if(!grouped.has(key))grouped.set(key,{...task,factoryCount:0});
1893      grouped.get(key).factoryCount++;
1894    }
1895    colony.tasks=[...grouped.values()].map(task=>({...task,text:task.factoryCount>1?`${task.factoryCount}× ${task.text}`:task.text}));
1896  }
1897
1898  return used;
1899}
1900
1901function targetOwnersFor(ids,productId){
1902  return ids.filter(targetId=>dependencyProducts([targetId]).some(p=>p.id===productId));
1903}
1904function buildAllocationSegments(ids,a,maxUtilization){
1905  let baseAllocation=a;
1906  if(maxUtilization&&ids.length===1){
1907    const packed=maximumPackedAllocation(ids[0]);
1908    if(packed?.allocation?.ok)baseAllocation=packed.allocation;
1909  }
1910  const segments=[{allocation:baseAllocation,targetProductId:ids.length===1?ids[0]:null,ownerTargetIds:[...ids]}];
1911  if(!maxUtilization)return segments;
1912
1913  const reserved=new Map();reserveCover(reserved,baseAllocation.cover);
1914  const byTier={0:[],1:[],2:[],3:[],4:[]};
1915  dependencyProducts(ids).forEach(p=>byTier[p.t].push(p.id));
1916
1917  // Полные копии целевой цепочки уже сбалансированы в baseAllocation.
1918  // Если слотов на следующую полную копию не хватило, заполняем остаток
1919  // полезными промежуточными цепочками: P3, затем P2, P1 и P0.
1920  // calculateAllocation для каждого такого продукта снова считает все нижние уровни,
1921  // поэтому необеспеченные «лишние» заводы не появляются.
1922  const priorityGroups=[[...ids],byTier[3],byTier[2],byTier[1],byTier[0]];
1923  for(const group of priorityGroups){
1924    if(!group.length)continue;
1925    let placedInPass=true;
1926    while(placedInPass){
1927      placedInPass=false;
1928      for(const productId of group){
1929        const extra=calculateAllocation([productId],reserved,1,true);
1930        if(!extra.ok||!extra.cover.count)continue;
1931        reserveCover(reserved,extra.cover);
1932        segments.push({allocation:extra,targetProductId:productId,ownerTargetIds:targetOwnersFor(ids,productId)});
1933        placedInPass=true;
1934      }
1935    }
1936  }
1937  return segments;
1938}
1939function buildPlanetInstructionsFor(ids,a,maxUtilization){
1940  const segments=buildAllocationSegments(ids,a,maxUtilization),plan=[];
1941  for(const segment of segments){
1942    const segmentTargets=segment.targetProductId==null?ids:[segment.targetProductId];
1943    const rateFactors=allocationRateFactorsForTargets(segment.allocation,segmentTargets);
1944    instructionsForAllocation(segment.allocation,[segment.targetProductId].filter(x=>x!=null),segment.ownerTargetIds,rateFactors).forEach(colony=>plan.push(colony));
1945  }
1946  return{plan,segments};
1947}
1948function buildPlanetInstructions(a){
1949  return buildPlanetInstructionsFor(selected,a,!!$("maxUtilization").checked).plan;
1950}
1951
1952function assignToClones(colonies){
1953  return clones.map((c,ci)=>({
1954    cloneIndex:ci,
1955    name:c.name,
1956    limit:c.limit,
1957    planets:colonies.filter(x=>x.cloneIndex===ci).sort((a,b)=>(a.sortOrder??a.ordinal??0)-(b.sortOrder??b.ordinal??0)||String(a.label).localeCompare(String(b.label)))
1958  }));
1959}
1960function taskTargets(task){
1961  return task.targetIds?.length?task.targetIds:targetsForProduct(task.productId);
1962}
1963function targetsOverlap(a,b){
1964  const right=new Set(taskTargets(b));
1965  return taskTargets(a).some(id=>right.has(id));
1966}
1967function annotateCloneTransfers(planets){
1968  const balances=new Map(),producerLines=new Map(),producedTotals=new Map(),processorClonesByInput=new Map();
1969  const balanceKey=(cloneIndex,productId)=>`${cloneIndex}:${productId}`;
1970  const addBalance=(cloneIndex,productId,quantity)=>{
1971    const key=balanceKey(cloneIndex,productId);
1972    balances.set(key,(balances.get(key)||0)+quantity);
1973  };
1974  const registerProducer=(planet,task,outputPerHour,factories)=>{
1975    const key=balanceKey(planet.cloneIndex,task.productId);
1976    const lineCount=task.kind==="assembly"?Math.max(1,Number(factories)||1):1;
1977    const outputPerLine=outputPerHour/lineCount;
1978    task.lineOutputPerHour=outputPerLine;
1979    task.lineTransfers=Array.from({length:lineCount},()=>[]);
1980    if(!producerLines.has(key))producerLines.set(key,[]);
1981    for(let lineIndex=0;lineIndex<lineCount;lineIndex++)producerLines.get(key).push({planet,task,lineIndex,remaining:outputPerLine});
1982    producedTotals.set(key,(producedTotals.get(key)||0)+outputPerHour);
1983  };
1984  planets.forEach(planet=>planet.tasks.forEach(task=>{
1985    task.transferTo=[];
1986    task.lineTransfers=[];
1987    const product=products.get(task.productId);
1988    if(!product)return;
1989    const factories=Math.max(1,Number(task.factoryCount)||1);
1990    const rateFactor=Number.isFinite(Number(task.rateFactor))?Number(task.rateFactor):1;
1991    if(task.kind==="extract"&&product.t===0){
1992      const p1=directP1ForP0(product.id);
1993      const inputQuantity=p1?.i?.find(([id])=>id===product.id)?.[1]||0;
1994      const outputPerHour=factories*rateFactor*inputQuantity*3600/Math.max(1,Number(p1?.c)||3600);
1995      addBalance(planet.cloneIndex,product.id,outputPerHour);
1996      registerProducer(planet,task,outputPerHour,factories);
1997      return;
1998    }
1999    if(task.kind!=="assembly"||product.t===0)return;
2000    const cyclesPerHour=factories*rateFactor*3600/Math.max(1,Number(product.c)||3600);
2001    const outputPerHour=cyclesPerHour*Math.max(1,Number(product.q)||1);
2002    addBalance(planet.cloneIndex,product.id,outputPerHour);
2003    registerProducer(planet,task,outputPerHour,factories);
2004    for(const [inputId,quantity] of product.i||[]){
2005      if(!processorClonesByInput.has(inputId))processorClonesByInput.set(inputId,new Set());
2006      processorClonesByInput.get(inputId).add(planet.cloneIndex);
2007      const inputTier=products.get(inputId)?.t;
2008      if(inputTier>0||(inputTier===0&&!globalPlanetSettings.p0p1))addBalance(planet.cloneIndex,inputId,-cyclesPerHour*Math.max(0,Number(quantity)||0));
2009    }
2010  }));
2011
2012  const productIds=[...new Set([...balances.keys()].map(key=>Number(key.split(":")[1])))];
2013  const routes=[];
2014  for(const productId of productIds){
2015    const suppliers=clones.map((clone,cloneIndex)=>({clone,cloneIndex,quantity:balances.get(balanceKey(cloneIndex,productId))||0})).filter(x=>x.quantity>TRANSFER_EPSILON);
2016    const consumers=clones.map((clone,cloneIndex)=>({clone,cloneIndex,quantity:-(balances.get(balanceKey(cloneIndex,productId))||0)})).filter(x=>x.quantity>TRANSFER_EPSILON);
2017    const productRoutes=[];
2018    const addRoute=(supplier,toIndex,toName,quantity)=>{
2019      if(!(quantity>TRANSFER_EPSILON)||supplier.cloneIndex===toIndex)return;
2020      const route={productId,quantityPerHour:quantity,fromIndex:supplier.cloneIndex,fromName:supplier.clone.name,toIndex,toName};
2021      routes.push(route);productRoutes.push(route);
2022    };
2023    for(const consumer of consumers){
2024      let needed=consumer.quantity;
2025      for(const supplier of suppliers){
2026        if(needed<=TRANSFER_EPSILON)break;
2027        if(supplier.cloneIndex===consumer.cloneIndex||supplier.quantity<=TRANSFER_EPSILON)continue;
2028        const quantity=Math.min(needed,supplier.quantity);
2029        if(quantity<=TRANSFER_EPSILON)continue;
2030        addRoute(supplier,consumer.cloneIndex,consumer.clone.name,quantity);
2031        supplier.quantity-=quantity;needed-=quantity;
2032      }
2033    }
2034
2035    // Полезный промежуточный материал не оставляем у клона, который не может
2036    // переработать его дальше. После закрытия текущих дефицитов весь остаток
2037    // поровну распределяется между клонами с подходящими заводами следующего
2038    // уровня. Уже назначенные передачи учитываются, поэтому дополнительный
2039    // объём сначала выравнивает доли получателей (10 + 5 превращается в 10 + 10).
2040    const processorIndexes=[...(processorClonesByInput.get(productId)||[])].sort((a,b)=>a-b);
2041    for(const supplier of suppliers){
2042      if(supplier.quantity<=TRANSFER_EPSILON)continue;
2043      // Если сам производитель способен использовать материал дальше, излишек
2044      // уже находится у подходящего клона и бессмысленная встречная перевозка не нужна.
2045      if(processorIndexes.includes(supplier.cloneIndex))continue;
2046      const recipients=processorIndexes.filter(index=>index!==supplier.cloneIndex);
2047      if(!recipients.length)continue;
2048      const assigned=new Map(recipients.map(index=>[index,productRoutes
2049        .filter(route=>route.fromIndex===supplier.cloneIndex&&route.toIndex===index)
2050        .reduce((sum,route)=>sum+route.quantityPerHour,0)]));
2051      let remaining=supplier.quantity;
2052      while(remaining>TRANSFER_EPSILON){
2053        const ordered=recipients.map(index=>({index,quantity:assigned.get(index)||0})).sort((a,b)=>a.quantity-b.quantity||a.index-b.index);
2054        const minimum=ordered[0].quantity;
2055        const lowest=ordered.filter(item=>Math.abs(item.quantity-minimum)<=TRANSFER_EPSILON);
2056        const next=ordered.find(item=>item.quantity>minimum+TRANSFER_EPSILON)?.quantity;
2057        const neededToNext=next==null?remaining:(next-minimum)*lowest.length;
2058        const used=Math.min(remaining,neededToNext);
2059        const each=used/lowest.length;
2060        lowest.forEach(item=>assigned.set(item.index,(assigned.get(item.index)||0)+each));
2061        remaining=Math.max(0,remaining-used);
2062      }
2063      for(const recipientIndex of recipients){
2064        const previous=productRoutes
2065          .filter(route=>route.fromIndex===supplier.cloneIndex&&route.toIndex===recipientIndex)
2066          .reduce((sum,route)=>sum+route.quantityPerHour,0);
2067        const extra=(assigned.get(recipientIndex)||0)-previous;
2068        if(extra>
2068TRANSFER_EPSILON)addRoute(supplier,recipientIndex,clones[recipientIndex].name,extra);
2069      }
2070      supplier.quantity=0;
2071    }
2072  }
2073
2074  for(const route of routes){
2075    let remaining=route.quantityPerHour;
2076    for(const producer of producerLines.get(balanceKey(route.fromIndex,route.productId))||[]){
2077      if(remaining<=TRANSFER_EPSILON)break;
2078      if(producer.remaining<=TRANSFER_EPSILON)continue;
2079      const quantity=Math.min(remaining,producer.remaining);
2080      if(quantity<=TRANSFER_EPSILON)continue;
2081      producer.task.lineTransfers[producer.lineIndex].push({cloneIndex:route.toIndex,cloneName:route.toName,quantityPerHour:quantity});
2082      producer.remaining=Math.max(0,producer.remaining-quantity);remaining=Math.max(0,remaining-quantity);
2083    }
2084  }
2085
2086  // Сохраняем общий список для диагностики, но интерфейс использует точные
2087  // передачи каждой отдельной заводской линии.
2088  planets.forEach(planet=>planet.tasks.forEach(task=>{
2089    const groupedRecipients=new Map();
2090    for(const transfers of task.lineTransfers||[])for(const transfer of transfers||[]){
2091      if(!(transfer.quantityPerHour>TRANSFER_EPSILON))continue;
2092      const current=groupedRecipients.get(transfer.cloneIndex)||{cloneIndex:transfer.cloneIndex,cloneName:transfer.cloneName,quantityPerHour:0};
2093      current.quantityPerHour+=transfer.quantityPerHour;groupedRecipients.set(transfer.cloneIndex,current);
2094    }
2095    task.transferTo=[...groupedRecipients.values()];
2096  }));
2097
2098  const grouped=new Map;
2099  const outgoingTotals=new Map(),outgoingDestinations=new Map();
2100  for(const route of routes){
2101    const key=balanceKey(route.fromIndex,route.productId);
2102    outgoingTotals.set(key,(outgoingTotals.get(key)||0)+route.quantityPerHour);
2103    if(!outgoingDestinations.has(key))outgoingDestinations.set(key,new Set());
2104    outgoingDestinations.get(key).add(route.toIndex);
2105  }
2106  for(const route of routes){
2107    const key=`${route.fromIndex}>${route.toIndex}`;
2108    if(!grouped.has(key))grouped.set(key,{fromIndex:route.fromIndex,fromName:route.fromName,toIndex:route.toIndex,toName:route.toName,products:new Map});
2109    const existing=grouped.get(key).products.get(route.productId)||{id:route.productId,name:products.get(route.productId)?.n||String(route.productId),quantityPerHour:0};
2110    existing.quantityPerHour+=route.quantityPerHour;
2111    const outgoingKey=balanceKey(route.fromIndex,route.productId);
2112    existing.totalOutgoingPerHour=outgoingTotals.get(outgoingKey)||existing.quantityPerHour;
2113    existing.totalProducedPerHour=producedTotals.get(outgoingKey)||existing.quantityPerHour;
2114    existing.destinationCount=outgoingDestinations.get(outgoingKey)?.size||1;
2115    grouped.get(key).products.set(route.productId,existing);
2116  }
2117  for(const transfer of grouped.values())for(const item of transfer.products.values()){
2118    item.sharePercent=item.totalOutgoingPerHour>0?item.quantityPerHour/item.totalOutgoingPerHour*100:100;
2119  }
2120  return [...grouped.values()].sort((a,b)=>a.fromIndex-b.fromIndex||a.toIndex-b.toIndex);
2121}
2122
2123function transferShareLabel(item){
2124  if(!(item?.destinationCount>1))return"";
2125  return ` (${shareRatioText((Number(item.sharePercent)||0)/100)})`;
2126}
2127function shareRatioText(value){
2128  const ratio=Math.max(0,Math.min(1,Number(value)||0));
2129  if(ratio<=TRANSFER_EPSILON)return"0";
2130  if(ratio>=1-TRANSFER_EPSILON)return"1/1";
2131  let best=null;
2132  for(let denominator=2;denominator<=60;denominator++){
2133    const numerator=Math.round(ratio*denominator);
2134    if(numerator<=0||numerator>=denominator)continue;
2135    const error=Math.abs(ratio-numerator/denominator);
2136    if(!best||error<best.error)best={numerator,denominator,error};
2137  }
2138  if(best&&best.error<=0.0015){
2139    const gcd=(a,b)=>b?gcd(b,a%b):a,divisor=gcd(best.numerator,best.denominator);
2140    return `${best.numerator/divisor}/${best.denominator/divisor}`;
2141  }
2142  const rounded=Math.round(ratio*1000)/10;
2143  return `${Number.isInteger(rounded)?rounded:rounded.toFixed(1)}%`;
2144}
2145function transferHintHtml(transfers,lineOutputPerHour=0){
2146  const valid=(transfers||[]).filter(item=>item.quantityPerHour>TRANSFER_EPSILON);
2147  if(!valid.length)return"";
2148  const verb=siteLanguage==="en"?"transfer":"передать",outputWord=siteLanguage==="en"?" of output":" выпуска";
2149  return ` <span class="transferHint">(${verb}: ${valid.map(item=>{
2150    const share=lineOutputPerHour>TRANSFER_EPSILON?item.quantityPerHour/lineOutputPerHour:1;
2151    return `${esc(item.cloneName)}
2151${share<1-TRANSFER_EPSILON?` — ${shareRatioText(share)}${outputWord}`:""}`;
2152  }).join(", ")})</span>`;
2153}
2154function transferSummaryProductHtml(item){
2155  const quantity=Math.max(0,Number(item?.quantityPerHour)||0),produced=Math.max(quantity,Number(item?.totalProducedPerHour)||0);
2156  const product=products.get(Number(item?.id)),label=product?materialInline(product,"transferMaterial"):`<span class="inlineMaterial transferMaterial"><span>${esc(item?.name||"")}</span></span>`;
2157  if(quantity>=produced-TRANSFER_EPSILON)return label;
2158  const transferWord=siteLanguage==="en"?"transfer":"передать";
2159  return `${label}<span class="transferFraction"> — ${transferWord} ${shareRatioText(quantity/produced)}</span>`;
2160}
2161function netSaleValue(product,quantity,price){
2162  return netSaleBreakdown(product,quantity,price).net;
2163}
2164
2165function customsExportFee(product,quantity){
2166  return Math.max(0,Number(quantity)||0)*(CUSTOMS_TAX_VALUE[product?.t]||0)*clampPercent(marketSettings.customsTax)/100;
2167}
2168
2169function customsImportFee(product,quantity){
2170  return customsExportFee(product,quantity)*0.5;
2171}
2172
2173// Intermediate products pay export at the source and import at the destination
2174// when their net output leaves one colony to cover a deficit on another.
2175// Production and consumption inside one colony cancel before fees are calculated.
2176function interplanetaryCustomsFees(plan,hours){
2177  if(!(marketSettings.customsTax>0)||!(hours>0)||!Array.isArray(plan))return{exportFee:0,importFee:0,total:0,byProduct:[]};
2178  const balances=[];
2179  for(const planet of plan){
2180    const balance=new Map();
2181    const add=(id,quantity)=>balance.set(id,(balance.get(id)||0)+quantity);
2182    for(const task of planet.tasks||[]){
2183      const product=products.get(task.productId);if(!product)continue;
2184      const factories=Math.max(1,Number(task.factoryCount)||1);
2185      const rateFactor=Number.isFinite(Number(task.rateFactor))?Number(task.rateFactor):1;
2186      if(task.kind==="extract"&&product.t===0){
2187        const p1=directP1ForP0(product.id);
2188        const quantity=p1?.i?.find(([id])=>id===product.id)?.[1]||0;
2189        add(product.id,factories*rateFactor*quantity*3600/Math.max(1,Number(p1?.c)||3600));
2190      }else if(task.kind==="assembly"&&product.t>0){
2191        const cycles=factories*rateFactor*3600/Math.max(1,Number(product.c)||3600);
2192        add(product.id,cycles*Math.max(1,Number(product.q)||1));
2193        for(const [inputId,quantity] of product.i||[])add(inputId,-cycles*Math.max(0,Number(quantity)||0));
2194      }
2195    }
2196    balances.push(balance);
2197  }
2198  const ids=new Set(balances.flatMap(balance=>[...balance.keys()]));
2199  let exportFee=0,importFee=0;const byProduct=[];
2200  for(const id of ids){
2201    let surplus=0,deficit=0;
2202    for(const balance of balances){const amount=balance.get(id)||0;if(amount>0)surplus+=amount;else deficit-=amount}
2203    const product=products.get(id),transferredPerHour=Math.min(surplus,deficit);
2204    const quantity=transferredPerHour*hours;
2205    const productExport=customsExportFee(product,quantity),productImport=customsImportFee(product,quantity);
2206    exportFee+=productExport;importFee+=productImport;
2207    if(quantity>0)byProduct.push({id,name:product?.n||String(id),tier:product?.t,quantity,exportFee:productExport,importFee:productImport,total:productExport+productImport});
2208  }
2209  return{exportFee,importFee,total:exportFee+importFee,byProduct};
2210}
2211function renderChain(){
2212  let deps=dependencyProducts(selected),byTier={0:[],1:[],2:[],3:[],4:[]};
2213  deps.forEach(p=>byTier[p.t].push(p));
2214  $("chainContent").innerHTML=`<div class="notice">Рецептурная цепочка построена из PI-схем SDE. Ограничения конкретных планет влияют на размещение во вкладке «По клонам».</div>
2215  <div class="chainGrid">${[0,1,2,3,4].map(t=>`<section class="chainTier"><h3>P${t}</h3>${byTier[t].map(p=>{
2216    const isTarget=selected.includes(p.id),inputs=p.i.map(([id])=>products.get(id)).filter(Boolean);
2217    return`<div class="chainNode ${isTarget?"target":""}"><b>${materialInline(p,"chainMaterial")}</b>${inputs.length?`<small>${inputs.map(input=>materialInline(input,"chainInputMaterial")).join('<span class="taskOperator"> + </span>')}</small>`:""}</div>`;
2218  }).join("")||"<small>—</small>"}</section>`).join("")}</div>`;
2219}
2220
2221function clampStorageCount(value){
2222  const number=Math.floor(Number(value)||0);
2223  return Math.max(0,Math.min(99,number));
2224}
2225function normalizedStorageSettings(value){
2226  const result={};
2227  if(!value||typeof value!=="object")return result;
2228  for(const [key,item] of Object.entries(value)){
2229    const launchpad=clampStorageCount(item?.launchpad),storage=clampStorageCount(item?.storage);
2230    if(launchpad||storage)result[key]={launchpad,storage};
2231  }
2232  return result;
2233}
2234function storageSettingKey(planet,task,taskIndex,lineIndex){
2235  return `${planet.colonyKey}|${task.kind}|${task.productId}|${taskIndex}|${lineIndex}`;
2236}
2237function storageLineSetting(key,defaults={launchpad:1,storage:0}){
2238  const saved=storageSettings[key]||{};
2239  return{
2240    launchpad:Math.max(1,clampStorageCount(saved.launchpad==null?defaults.launchpad:saved.launchpad)),
2241    storage:clampStorageCount(saved.storage==null?defaults.storage:saved.storage)
2242  };
2243}
2244function individualTaskText(task){
2245  return String(task?.text||"").replace(/^\d+×\s*/,"");
2246}
2247function taskInstructionHtml(task){
2248  const product=products.get(Number(task?.productId));
2249  if(!product)return esc(individualTaskText(task));
2250  if(task.kind==="extract")return `${siteLanguage==="en"?"P0 extraction":"Добыча P0"}: ${materialInline(product,"taskMaterial")}`;
2251  if(task.kind!=="assembly")return esc(individualTaskText(task));
2252  const inputs=(product.i||[]).map(([id])=>products.get(id)).filter(Boolean);
2253  const sourceNote=/\s*(\(сырьё:[^)]+\))\s*$/.exec(individualTaskText(task))?.[1]||"";
2254  const separator=product.t===1?'<span class="taskOperator"> → </span>':'<span class="taskOperator"> + </span>';
2255  const relation=product.t===1?"→":"=";
2256  return `P${Math.max(0,product.t-1)} → P${product.t}: ${inputs.map(input=>materialInline(input,"taskMaterial")).join(separator)} <span class="taskOperator">${relation}</span> ${materialInline(product,"taskMaterial taskOutputMaterial")}${sourceNote?` <span class="taskSourceNote">${esc(sourceNote)}</span>`:""}`;
2257}
2258function storageLoadPlan(task,setting={}){
2259  const product=products.get(Number(task?.productId));
2260  const inputs=(product?.i||[]).map(([id,quantity])=>({product:products.get(id),quantity:Math.max(0,Number(quantity)||0)})).filter(item=>item.product&&item.quantity>0);
2261  const launchpads=clampStorageCount(setting.launchpad),storages=clampStorageCount(setting.storage);
2262  const capacity=launchpads*STORAGE_CAPACITY.launchpad+storages*STORAGE_CAPACITY.storage;
2263  const recipeVolume=inputs.reduce((sum,item)=>sum+item.quantity*(PRODUCT_VOLUME[item.product.t]||0),0);
2264  const rowFor=(kind,index,rowCapacity)=>{
2265    const scale=rowCapacity>0&&recipeVolume>0?rowCapacity/recipeVolume:0;
2266    return{
2267      kind,index,capacity:rowCapacity,
2268      inputs:inputs.map(item=>({id:item.product.id,name:item.product.n,tier:item.product.t,product:item.product,volume:PRODUCT_VOLUME[item.product.t]||0,quantity:Math.floor(item.quantity*scale+1e-9)}))
2269    };
2270  };
2271  const rows=[
2272    ...Array.from({length:launchpads},(_unused,index)=>rowFor("launchpad",index,STORAGE_CAPACITY.launchpad)),
2273    ...Array.from({length:storages},(_unused,index)=>rowFor("storage",index,STORAGE_CAPACITY.storage))
2274  ];
2275  const totals=new Map(inputs.map(item=>[item.product.id,{id:item.product.id,name:item.product.n,tier:item.product.t,product:item.product,volume:PRODUCT_VOLUME[item.product.t]||0,quantity:0}]));
2276  rows.forEach(row=>row.inputs.forEach(item=>{totals.get(item.id).quantity+=item.quantity}));
2277  return{
2278    capacity,
2279    launchpads,
2280    storages,
2281    rows,
2282    inputs:[...totals.values()]
2283  };
2284}
2285function storageStructureControl(key,kind,count,planetType,defaultLaunchpads=1){
2286  const isLaunchpad=kind==="launchpad",name=isLaunchpad?"Космопорт":"Хранилище";
2287  const typeId=planetStructureTypeId(planetType,kind);
2288  const countLabel=isLaunchpad?"Количество космопортов":"Количество хранилищ";
2289  return `<div class="storageControl ${count?"active":"inactive"}">
2290    <button type="button" class="storageToggle ${count?"active":"inactive"} ${isLaunchpad?"requiredStructure":""}" data-storage-key="${esc(key)}" data-storage-kind="${kind}" data-storage-default-launchpads="${defaultLaunchpads}" aria-pressed="${count?"true":"false"}" ${isLaunchpad?'aria-disabled="true"':''} title="${isLaunchpad?"Космопорт обязателен":name}">
2291      <img src="https://images.evetech.net/types/${typeId}/icon?size=64" alt=""><span>${name}</span>
2292    </button>
2293    ${count?`<label title="${countLabel}"><input class="storageCountInput" type="number" min="1" max="99" step="1" value="${count}" data-storage-key="${esc(key)}" data-storage-kind="${kind}" data-storage-default-launchpads="${defaultLaunchpads}" aria-label="${countLabel}"></label>`:""}
2294  </div>`;
2295}
2296function templateStructureControl(setting,kind,planetType){
2297  const isLaunchpad=kind==="launchpad",name=isLaunchpad?"Космопорт":"Хранилище";
2298  const typeId=planetStructureTypeId(planetType,kind);
2299  const count=clampStorageCount(setting[kind]),countLabel=isLaunchpad?"Количество космопортов":"Количество хранилищ";
2300  return `<div class="storageControl piTemplateStructureControl ${count?"active":"inactive"}">
2301    <button type="button" class="storageToggle piTemplateInfraToggle ${count?"active":"inactive"} ${isLaunchpad?"requiredStructure":""}" data-template-key="${esc(setting.key)}" data-template-field="${kind}" data-template-default-launchpads="${setting.launchpad}" data-template-default-storages="${setting.storage}" aria-pressed="${count?"true":"false"}" ${isLaunchpad?'aria-disabled="true"':''} title="${isLaunchpad?"Космопорт обязателен":name}">
2302      <img src="https://images.evetech.net/types/${typeId}/icon?size=64" alt=""><span>${name}</span>
2303    </button>
2304    ${count?`<label title="${countLabel}"><input class="storageCountInput piTemplateInfraInput" type="number" min="1" max="6" step="1" value="${count}" data-template-key="${esc(setting.key)}" data-template-field="${kind}" data-template-default-launchpads="${setting.launchpad}" data-template-default-storages="${setting.storage}" aria-label="${countLabel}"></label>`:""}
2305  </div>`;
2306}
2307function groupedStorageSetting(planet,task,taskIndex,factoryCount){
2308  const keys=Array.from({length:Math.max(1,factoryCount)},(_unused,lineIndex)=>storageSettingKey(planet,task,taskIndex,lineIndex));
2309  const primaryKey=keys[0];
2310  if(keys.slice(1).some(key=>storageSettings[key])){
2311    const combined=keys.reduce((total,key)=>{
2312      const saved=storageSettings[key]||{};
2313      total.launchpad+=clampStorageCount(saved.launchpad);
2314      total.storage+=clampStorageCount(saved.storage);
2315      return total;
2316    },{launchpad:0,storage:0});
2317    combined.launchpad=Math.max(1,combined.launchpad);
2318    if(combined.launchpad||combined.storage)storageSettings[primaryKey]=combined;else delete storageSettings[primaryKey];
2319    keys.slice(1).forEach(key=>delete storageSettings[key]);
2320  }
2321  return{key:primaryKey,setting:storageLineSetting(primaryKey,{launchpad:Math.max(1,factoryCount),storage:0})};
2322}
2323function storageLoadItemsHtml(items){
2324  return items.map(item=>`${materialInline(item.product||products.get(item.id),"storageMaterial")} — <strong>${item.quantity.toLocaleString(siteLocale())}</strong>`).join('<span class="storageSeparator">; </span>');
2325}
2326function storageControlsHtml(planet,task,taskIndex,factoryCount){
2327  const {key,setting}=groupedStorageSetting(planet,task,taskIndex,factoryCount),load=storageLoadPlan(task,setting);
2328  const loadWord=siteLanguage==="en"?"Load":"Загрузить",totalWord=siteLanguage==="en"?"Total":"Всего";
2329  const summary=load.rows.length&&load.inputs.length
2330    ? `<div class="storageLoadSummary"><div class="storageLoadRows">${load.rows.map(row=>`<div class="storageLoadRow"><b>${loadWord} (${row.capacity.toLocaleString(siteLocale())} м³):</b> ${storageLoadItemsHtml(row.inputs)}</div>`).join("")}</div>${load.rows.length>1?`<div class="storageLoadTotal"><b>${totalWord}: (${load.capacity.toLocaleString(siteLocale())} м³):</b> ${storageLoadItemsHtml(load.inputs)}</div>`:""}</div>`
2331    : "";
2332  return `<div class="storagePlanner" data-storage-line="${esc(key)}">
2333    <div class="storageControls">${storageStructureControl(key,"launchpad",setting.launchpad,planet.pt,setting.launchpad)}${storageStructureControl(key,"storage",setting.storage,planet.pt,setting.launchpad)}</div>
2334    ${summary}
2335  </div>`;
2336}
2337function renderFactoryTaskLines(planet,task,taskIndex){
2338  const count=task.kind==="assembly"?Math.max(1,Number(task.factoryCount)||1):1;
2339  const textHtml=`${count>1?`<span class="factoryMultiplier">${count}×</span> `:""}${taskInstructionHtml(task)}`;
2340  const planner=planet.p0.length||task.kind!=="assembly"||!(products.get(task.productId)?.i?.length)?"":storageControlsHtml(planet,task,taskIndex,count);
2341  return `<div class="taskLineBlock groupedTaskLine"><div class="task ${task.kind} ${selected.length>1?"colorTagged":""}">${colorDots(task.targetIds||targetsForProduct(task.productId))}<span class="taskText">${textHtml}</span></div>${planner}</div>`;
2342}
2343function bindStorageControls(planets){
2344  const rerender=()=>{renderInstructions(null,planets);autosave()};
2345  document.querySelectorAll("#assignments .storageToggle").forEach(button=>button.onclick=()=>{
2346    const key=button.dataset.storageKey,kind=button.dataset.storageKind,defaultLaunchpads=Math.max(1,clampStorageCount(button.dataset.storageDefaultLaunchpads)),current=storageLineSetting(key,{launchpad:defaultLaunchpads,storage:0});
2347    if(kind==="launchpad"){current.launchpad=Math.max(1,current.launchpad);storageSettings[key]=current;return}
2348    current[kind]=current[kind]>0?0:1;
2349    if(current.launchpad||current.storage)storageSettings[key]=current;else delete storageSettings[key];
2350    rerender();
2351  });
2352  document.querySelectorAll("#assignments .storageCountInput").forEach(input=>input.onchange=()=>{
2353    const key=input.dataset.storageKey,kind=input.dataset.storageKind,defaultLaunchpads=Math.max(1,clampStorageCount(input.dataset.storageDefaultLaunchpads)),current=storageLineSetting(key,{launchpad:defaultLaunchpads,storage:0});
2354    current[kind]=Math.max(1,clampStorageCount(input.value));
2355    storageSettings[key]=current;rerender();
2356  });
2357}
2358
2359function clampTemplateRadius(value){
2360  const number=Math.round(Number(value)||0);
2361  return Math.max(0,Math.min(100000,number));
2362}
2363function normalizedPlanetTemplateSettings(value){
2364  const result={};
2365  if(!value||typeof value!=="object")return result;
2366  for(const [key,item] of Object.entries(value)){
2367    const launchpad=clampStorageCount(item?.launchpad),storage=clampStorageCount(item?.storage),radiusKm=clampTemplateRadius(item?.radiusKm);
2368    const normalized={};
2369    if(Object.prototype.hasOwnProperty.call(item||{},"launchpad")&&launchpad>0)normalized.launchpad=launchpad;
2370    if(Object.prototype.hasOwnProperty.call(item||{},"storage"))normalized.storage=storage;
2371    if(radiusKm){normalized.radiusKm=radiusKm;normalized.radiusSource="manual"}
2372    if(Object.keys(normalized).length)result[key]=normalized;
2373  }
2374  return result;
2375}
2376function planetTemplateSettingKey(planet){return `${planet.colonyKey}|pi-template`}
2377function plannedMiningLineCount(planet){
2378  const p1Factories=(planet.tasks||[]).filter(task=>task.kind==="assembly"&&products.get(Number(task.productId))?.t===1).reduce((sum,task)=>sum+Math.max(1,Number(task.factoryCount)||1),0);
2379  const extractionLines=Math.max((planet.p0Details||[]).length,(planet.p0||[]).length,(planet.tasks||[]).filter(task=>task.kind==="extract").reduce((sum,task)=>sum+Math.max(1,Number(task.factoryCount)||1),0));
2380  return Math.max(1,p1Factories||extractionLines||1);
2381}
2382function planetTemplateSetting(planet){
2383  const key=planetTemplateSettingKey(planet),saved=planetTemplateSettings[key]||{};
2384  const automaticRadiusKm=planet.planetId&&Number(planet.radiusKm)>0?clampTemplateRadius(planet.radiusKm):0;
2385  const manualRadiusKm=automaticRadiusKm?0:clampTemplateRadius(saved.radiusKm);
2386  const radiusKm=automaticRadiusKm||manualRadiusKm,radiusSource=automaticRadiusKm?"automatic":manualRadiusKm?"manual":"standard";
2387  if(planet.p0?.length){
2388    const plannedLines=plannedMiningLineCount(planet);
2389    return{key,launchpad:saved.launchpad>0?Math.max(1,clampStorageCount(saved.launchpad)):1,storage:Object.prototype.hasOwnProperty.call(saved,"storage")?clampStorageCount(saved.storage):plannedLines,radiusKm,radiusSource,radiusLocked:radiusSource==="automatic",plannedLines};
2390  }
2391  let launchpad=0,storage=0;
2392  (planet.tasks||[]).forEach((task,taskIndex)=>{
2393    if(task.kind!=="assembly")return;
2394    const grouped=groupedStorageSetting(planet,task,taskIndex,Math.max(1,Number(task.factoryCount)||1)).setting;
2395    launchpad+=grouped.launchpad;storage+=grouped.storage;
2396  });
2397  if(!launchpad&&!storage)launchpad=1;
2398  return{key,launchpad,storage,radiusKm,radiusSource,radiusLocked:radiusSource==="automatic"};
2399}
2400function templateRecipe(product,weight=1){
2401  return product?{id:product.id,name:product.n,tier:product.t,inputs:product.i,outputQuantity:product.q,weight:Math.max(1,Number(weight)||1)}:null;
2402}
2403function uniqueTemplateRecipes(items){
2404  const byId=new Map();
2405  items.filter(Boolean).forEach(item=>{
2406    const current=byId.get(item.id);
2407    if(current)current.weight+=item.weight;else byId.set(item.id,{...item});
2408  });
2409  return[...byId.values()];
2410}
2411function buildPlanetTemplate(planet){
2412  const generator=window.PLANET_HUB_TEMPLATES;
2413  if(!generator)return{ok:false,error:"Модуль шаблонов не загружен."};
2414  const setting=planetTemplateSetting(planet),exactRadiusKm=setting.radiusKm||0;
2415  const calculationRadiusKm=exactRadiusKm||generator.standardRadius(planet.pt);
2416  const assemblyTasks=(planet.tasks||[]).filter(task=>task.kind==="assembly");
2417  const assemblyRecipes=uniqueTemplateRecipes(assemblyTasks.map(task=>templateRecipe(products.get(Number(task.productId)),task.factoryCount)));
2418  const p1Recipes=assemblyRecipes.filter(recipe=>recipe.tier===1);
2419  const higherRecipes=assemblyRecipes.filter(recipe=>recipe.tier>=2);
2420  const ccLevel=cloneCommandCenterLevel(planet.cloneIndex),common={planetType:planet.pt,planetLabel:planet.label,launchpads:setting.launchpad,storages:setting.storage,exactRadiusKm,calculationRadiusKm,radiusSource:setting.radiusSource,commandCenterLevel:ccLevel-1};
2421  if(planet.p0?.length||p1Recipes.length){
2422    const recipes=p1Recipes.length?p1Recipes:uniqueTemplateRecipes((planet.p0||[]).map(id=>templateRecipe(directP1ForP0(id),1)));
2423    const result=generator.generateMiningTemplate({...common,recipes,higherRecipes,includeEcu:!!planet.p0?.length,processP0P1:p1Recipes.length>0});
2424    const requiredHigherFactories=higherRecipes.reduce((sum,recipe)=>sum+Math.max(1,Number(recipe.weight)||1),0);
2425    const requiredFactories=assemblyRecipes.reduce((sum,recipe)=>sum+Math.max(1,Number(recipe.weight)||1),0);
2426    if(result.ok&&Number(result.actualFactories||0)<requiredFactories)return{ok:false,error:`Уровень командного центра ${ccLevel}/6 недостаточен: требуется заводов — ${requiredFactories}, помещается — ${Number(result.actualFactories)||0}.`};
2427    if(result.ok&&Number(result.higherFactoryCount||0)!==requiredHigherFactories)return{ok:false,error:`Уровень командного центра ${ccLevel}/6 не позволяет разместить все назначенные заводы.`};
2428    return result;
2429  }
2430  const result=generator.generateFactoryTemplate({...common,recipes:higherRecipes});
2431  const requiredFactories=higherRecipes.reduce((sum,recipe)=>sum+Math.max(1,Number(recipe.weight)||1),0);
2432  if(result.ok&&Number(result.actualFactories||0)<requiredFactories)return{ok:false,error:`Уровень командного центра ${ccLevel}/6 недостаточен: требуется заводов — ${requiredFactories}, помещается — ${Number(result.actualFactories)||0}.`};
2433  return result;
2434}
2435function templateUsageText(result){
2436  if(!result?.usage)return"";
2437  const cpu=Math.ceil(result.usage.cpu).toLocaleString(siteLocale()),pg=Math.ceil(result.usage.pg).toLocaleString(siteLocale());
2438  return `CPU ${cpu} / ${result.usage.capacity.cpu.toLocaleString(siteLocale())} · Power Grid ${pg} / ${result.usage.capacity.pg.toLocaleString(siteLocale())}`;
2439}
2440const PI_PREVIEW_MIN_CENTER_GAP=100;
2441// One schematic lattice for every composition. Exported P/L/R are read-only:
2442// screen cells are deliberately independent of geographical marker compression.
2443function templatePreviewLattice(kinds){
2444  const groups={launchpad:[],storage:[],factory:[],ecu:[],unknown:[]};
2445  kinds.forEach((kind,index)=>groups[["basic","advanced","highTech"].includes(kind)?"factory":Object.hasOwn(groups,kind)?kind:"unknown"].push(index));
2446  const cx=groups.launchpad.length&&groups.launchpad.length%2===0?.5:0;
2447  const occupied=new Set(),result=Array(kinds.length),key=c=>`${c.q}:${c.r}`;
2448  const xy=c=>({x:c.q+c.r/2-cx,y:c.r*Math.sqrt(3)/2});
2449  const radius=c=>{const p=xy(c);return Math.hypot(p.x,p.y)};
2450  const extent=Math.max(4,Math.ceil(Math.sqrt(kinds.length))*2+2),cells=[];
2451  for(let r=-extent;r<=extent;r++)for(let q=-extent;q<=extent;q++)cells.push({q,r});
2452  const mirrorOrbit=c=>[c,{q:2*cx-c.q-c.r,r:c.r},{q:c.q+c.r,r:-c.r},{q:2*cx-c.q,r:-c.r}].filter((v,i,a)=>a.findIndex(w=>key(w)===key(v))===i);
2453  function place(indexes,exterior=false,quartets=false){
2454    let cursor=0;
2455    const boundary=exterior?Math.max(0,...result.filter(Boolean).map(radius))+.5:0;
2456    while(cursor<indexes.length){
2457      const remaining=indexes.length-cursor,candidates=[];
2458      for(const cell of cells){
2459        if(radius(cell)+1e-8<boundary)continue;
2460        let orbit=mirrorOrbit(cell);
2461        // Odd groups put their unpaired marker on the horizontal axis;
2462        // the remaining markers always occupy complete reflected groups.
2463        if(remaining%2){if(cell.r!==0)continue;orbit=[cell]}
2464        if(orbit.length>remaining||orbit.some(c=>occupied.has(key(c))))continue;
2465        candidates.push({orbit,distance:Math.max(Math.abs(cell.r),Math.abs(cell.q-cx),Math.abs(cell.q+cell.r-cx)),width:Math.abs(xy(cell).x),preferred:quartets&&remaining%4===0&&orbit.length===4?0:1});
2466      }
2467      // A group divisible by four must use complete mirrored quartets first.
2468      // If distance wins here, pairs consume the nearby cells and the last
2469      // factories are pushed onto the horizontal axis beside the ECU.
2470      candidates.sort((a,b)=>a.preferred-b.preferred||a.distance-b.distance||a.width-b.width||b.orbit.length-a.orbit.length||a.orbit[0].r-b.orbit[0].r||a.orbit[0].q-b.orbit[0].q);
2471      const chosen=candidates[0];
2472      if(!chosen)throw new Error("PI preview lattice has no free symmetric cells");
2473      chosen.orbit.forEach(cell=>{occupied.add(key(cell));result[indexes[cursor++]]=cell});
2474    }
2475  }
2476  place(groups.launchpad);
2477  place(groups.storage);
2478  // Keep ECUs attached to the central logistics group. Putting them outside
2479  // the factory radius made an ECU drift farther away whenever more factories
2480  // were added and visually pulled the whole composition to one side.
2481  place(groups.ecu);
2482  place(groups.factory,false,true);
2483  place(groups.unknown);
2484  return result.map(xy);
2485}
2486function templatePreviewProjection(template,width=1000,height=720,padding=52,kinds=[],layoutCells=[]){
2487  const pins=Array.isArray(template?.P)?template.P:[];
2488  if(!pins.length)return[];
2489  const fit=points=>{
2490    const minX=Math.min(...points.map(point=>point.x)),maxX=Math.max(...points.map(point=>point.x));
2491    const minY=Math.min(...points.map(point=>point.y)),maxY=Math.max(...points.map(point=>point.y));
2492    const spanX=maxX-minX,spanY=maxY-minY;
2493    const scale=points.length===1?1:Math.min(Math.max(1,width-2*padding)/Math.max(spanX,1e-9),Math.max(1,height-2*padding)/Math.max(spanY,1e-9));
2494    const centerX=(minX+maxX)/2,centerY=(minY+maxY)/2;
2495    return points.map(point=>({x:width/2+(point.x-centerX)*scale,y:height/2+(point.y-centerY)*scale}));
2496  };
2497  if(!pins.every(pin=>Number.isFinite(Number(pin.La))&&Number.isFinite(Number(pin.Lo)))){
2498    return fit(templatePreviewLattice(Array.from({length:pins.length},(_unused,index)=>kinds[index]||"unknown")));
2499  }
2500  // Project the exact coordinates exported to EVE. PI template La/Lo offsets
2501  // are used directly by the client layout; applying geographic cos(latitude)
2502  // compression would distort the imported shape near the coordinate pole.
2503  const latitudes=pins.map(pin=>Number(pin.La)),longitudes=pins.map(pin=>Number(pin.Lo));
2504  const centerLat=latitudes.reduce((sum,value)=>sum+value,0)/latitudes.length;
2505  const centerLon=Math.atan2(longitudes.reduce((sum,value)=>sum+Math.sin(value),0),longitudes.reduce((sum,value)=>sum+Math.cos(value),0));
2506  const wrap=value=>Math.atan2(Math.sin(value),Math.cos(value));
2507  const points=pins.map(pin=>({x:wrap(Number(pin.Lo)-centerLon),y:-(Number(pin.La)-centerLat)}));
2508  return fit(points);
2509}
2510function templatePreviewMarkerGeometry(points){
2511  let minDistance=Infinity;
2512  for(let first=0;first<points.length;first++)for(let second=first+1;second<points.length;second++){
2513    const distance=Math.hypot(points[first].x-points[second].x,points[first].y-points[second].y);
2514    if(distance<minDistance)minDistance=distance;
2515  }
2516  const gap=Number.isFinite(minDistance)?Math.min(8,Math.max(2,minDistance*.12)):8;
2517  const strokeOverflow=4;
2518  const outer=Number.isFinite(minDistance)?Math.min(40,Math.max(1,(minDistance-gap)/2-strokeOverflow)):40;
2519  return{minDistance,gap,strokeOverflow,outer,segments:outer*.84,core:outer*.66,icon:outer*.49};
2520}
2521function templateStructureMeta(planetType,pin){
2522  const types=window.PLANET_HUB_TEMPLATES?.structureTypeIds(planetType)||{};
2523  const kind=pin.T===types.launchpad?"launchpad"
2524    : pin.T===types.storage?"storage"
2525      : pin.T===types.ecu?"ecu"
2526        : pin.T===types.basic?"basic"
2527          : pin.T===types.advanced?"advanced"
2528            : pin.T===types.highTech?"highTech":"unknown";
2529  const gameNames={
2530    launchpad:`${planetType} Launchpad`,storage:`${planetType} Storage Facility`,ecu:`${planetType} Extractor Control Unit`,
2531    basic:`${planetType} Basic Industry Facility`,advanced:`${planetType} Advanced Industry Facility`,highTech:`${planetType} High-Tech Production Plant`,unknown:`EVE Structure ${pin.T}`
2532  };
2533  const product=products.get(Number(pin.S));
2534  const detail=product?` — ${product.n}`:"";
2535  return{kind,gameName:gameNames[kind],label:`${gameNames[kind]}${detail}`,product};
2536}
2537function templatePreviewMaterialRows(pins){
2538  const resources=[...new Map(pins.filter(pin=>pin.kind==="ecu"&&pin.product).map(pin=>[pin.product.id,pin.product])).values()];
2539  const outputs=[...new Map(pins.filter(pin=>["basic","advanced","highTech"].includes(pin.kind)&&pin.product).map(pin=>[pin.product.id,pin.product])).values()].sort((a,b)=>a.t-b.t||a.n.localeCompare(b.n));
2540  const processedP0=new Set(outputs.filter(product=>product.t===1).flatMap(product=>(product.i||[]).map(([id])=>Number(id))));
2541  const extractionOnly=resources.filter(product=>!processedP0.has(Number(product.id))).map(product=>({kind:"extract",stage:siteLanguage==="en"?"P0 extraction":"Добыча P0",inputs:[],output:product}));
2542  const production=outputs.map(product=>({kind:"production",stage:`P${Math.max(0,product.t-1)} → P${product.t}`,inputs:(product.i||[]).map(([id])=>products.get(Number(id))).filter(Boolean),output:product}));
2543  return[...extractionOnly,...production];
2544}
2545function templatePreviewMaterialRowsHtml(rows){
2546  if(!rows.length)return `<div class="piTemplateMaterialEmpty">${siteLanguage==="en"?"No extraction or production is configured.":"Добыча или производство не настроены."}</div>`;
2547  return rows.map(row=>{
2548    const inputs=row.inputs.map((product,index)=>`${index?'<span class="piTemplateMaterialOperator"> + </span>':""}${materialInline(product,"piTemplateMaterial")}`).join("");
2549    const relation=row.kind==="extract"?"":`<span class="piTemplateMaterialOperator"> ${row.output.t===1?"→":"="} </span>`;
2550    return `<div class="piTemplateMaterialRow"><span class="piTemplateMaterialStage">${esc(row.stage)}:</span><span class="piTemplateMaterialFlow">${inputs}${relation}${materialInline(row.output,"piTemplateMaterial piTemplateMaterialOutput")}</span></div>`;
2551  }).join("");
2552}
2553function templatePreviewModel(planet,result=buildPlanetTemplate(planet)){
2554  if(!result?.ok||!result.template)return{ok:false,error:result?.error||"Не удалось построить шаблон"};
2555  const template=result.template,pinMeta=(template.P||[]).map(pin=>templateStructureMeta(planet.pt,pin)),points=templatePreviewProjection(template,1000,720,52,pinMeta.map(meta=>meta.kind),result.layoutCells),markerGeometry=templatePreviewMarkerGeometry(points);
2556  const pins=(template.P||[]).map((pin,index)=>({index,...pin,...points[index],...pinMeta[index],icon:`https://images.evetech.net/types/${Number(pin.T)}/icon?size=64`}));
2557  const links=(template.L||[]).map((link,index)=>({index,source:Number(link.S)-1,destination:Number(link.D)-1,level:Number(link.Lv)||0})).filter(link=>pins[link.source]&&pins[link.destination]);
2558  const count=kind=>pins.filter(pin=>pin.kind===kind).length;
2559  const counts={launchpads:count("launchpad"),storages:count("storage"),ecus:count("ecu"),basic:count("basic"),advanced:count("advanced"),highTech:count("highTech")};
2560  counts.factories=counts.basic+counts.advanced+counts.highTech;
2561  counts.heads=pins.reduce((sum,pin)=>sum+(pin.kind==="ecu"?Number(pin.H)||0:0),0);
2562  return{ok:true,planet,result,template,pins,links,counts,markerGeometry,materialRows:templatePreviewMaterialRows(pins)};
2563}
2564const PI_TEMPLATE_GLYPHS={
2565  launchpad:"M10.2 19V6.2L12 4l1.8 2.2V19h-3.6ZM4 19l4.6-7.5V19H4Zm5.5 0v-5.6h1V19h-1Zm4 0v-5.6h1V19h-1Zm1.9 0v-7.5L20 19h-4.6Z",
2566  factory:"M19.14 12.94c.04-.31.06-.63.06-.94s-.02-.63-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.31-.6-.22l-2.39.96c-.5-.38-1.04-.7-1.64-.94l-.36-2.55a.47.47 0 0 0-.46-.42h-3.84c-.24 0-.44.18-.47.42l-.36 2.55c-.6.24-1.15.57-1.65.95L5.2 5.34c-.22-.08-.47 0-.59.22L2.7 8.88c-.12.21-.08.47.12.61l2.03 1.58c-.05.31-.09.65-.09.94s.03.63.08.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.31.6.22l2.39-.96c.5.38 1.04.7 1.64.94l.36 2.54c.03.24.23.42.47.42h3.84c.24 0 .44-.18.47-.42l.36-2.54c.6-.24 1.15-.56 1.64-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.59ZM12 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7Z",
2567  storage:"m12 2 9 5v10l-9 5-9-5V7l9-5Zm0 2.4L5.2 8 12 11.8 18.8 8 12 4.4ZM5 10.1v5.7l6 3.4v-5.7l-6-3.4Zm14 0-6 3.4v5.7l6-3.4v-5.7Z",
2568  ecu:"M12 21V7m0 0-5 5m5-5 5 5M5 4h14"
2569};
2570function templatePreviewGlyphKind(kind){return["basic","advanced","highTech"].includes(kind)?"factory":kind}
2571function templatePreviewSvg(model){
2572  const geometry=model.markerGeometry||templatePreviewMarkerGeometry(model.pins),format=value=>Number(value).toFixed(2);
2573  const linkSvg=model.links.map(link=>{
2574    const source=model.pins[link.source],destination=model.pins[link.destination];
2575    return `<line class="piTemplatePreviewLink" x1="${source.x.toFixed(2)}" y1="${source.y.toFixed(2)}" x2="${destination.x.toFixed(2)}" y2="${destination.y.toFixed(2)}"></line>`;
2576  }).join("");
2577  const pinIcon=pin=>{
2578    const size=geometry.icon*2,offset=-geometry.icon;
2579    if(pin.kind==="launchpad")return `<use class="piTemplateMapGlyph piTemplateLaunchpadGlyph" href="#piTemplateLaunchpadGlyph" x="${format(
2579offset)}" y="${format(offset)}" width="${format(size)}" height="${format(size)}"></use>`;
2580    if(["basic","advanced","highTech"].includes(pin.kind))return `<use class="piTemplateMapGlyph piTemplateFactoryGlyph" href="#piTemplateFactoryGlyph" x="${format(offset)}" y="${format(offset)}" width="${format(size)}" height="${format(size)}"></use>`;
2581    if(pin.kind==="storage")return `<use class="piTemplateMapGlyph piTemplateStorageGlyph" href="#piTemplateStorageGlyph" x="${format(offset)}" y="${format(offset)}" width="${format(size)}" height="${format(size)}"></use>`;
2582    if(pin.kind==="ecu")return `<use class="piTemplateMapGlyph piTemplateEcuGlyph" href="#piTemplateEcuGlyph" x="${format(offset)}" y="${format(offset)}" width="${format(size)}" height="${format(size)}"></use>`;
2583    return `<image href="${pin.icon}" x="${format(offset)}" y="${format(offset)}" width="${format(size)}" height="${format(size)}" preserveAspectRatio="xMidYMid meet"></image>`;
2584  };
2585  const pinSvg=model.pins.map(pin=>`<g class="piTemplatePreviewPin kind-${pin.kind}" data-template-pin="${pin.index}" data-template-label="${esc(pin.label)}" transform="translate(${pin.x.toFixed(2)} ${pin.y.toFixed(2)})" tabindex="0" role="img" aria-label="${esc(pin.label)}">
2586    <circle class="piTemplatePreviewPinOuter" r="${format(geometry.outer)}"></circle>
2587    <circle class="piTemplatePreviewPinSegments" r="${format(geometry.segments)}"></circle>
2588    <circle class="piTemplatePreviewPinCore" r="${format(geometry.core)}"></circle>
2589    ${pinIcon(pin)}
2590  </g>`).join("");
2591  return `<svg class="piTemplatePreviewSvg" viewBox="0 0 1000 720" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${siteLanguage==="en"?"Schematic PI template layout":"Схематичное расположение PI-шаблона"}">
2592    <defs>
2593      <symbol id="piTemplateLaunchpadGlyph" viewBox="0 0 24 24"><path d="${PI_TEMPLATE_GLYPHS.launchpad}"></path></symbol>
2594      <symbol id="piTemplateFactoryGlyph" viewBox="0 0 24 24"><path d="${PI_TEMPLATE_GLYPHS.factory}"></path></symbol>
2595      <symbol id="piTemplateStorageGlyph" viewBox="0 0 24 24"><path d="${PI_TEMPLATE_GLYPHS.storage}"></path></symbol>
2596      <symbol id="piTemplateEcuGlyph" viewBox="0 0 24 24"><path d="${PI_TEMPLATE_GLYPHS.ecu}"></path></symbol>
2597    </defs><g>${linkSvg}</g><g>${pinSvg}</g></svg>`;
2598}
2599function templatePreviewCompositionHtml(model){
2600  const rows=[
2601    [siteLanguage==="en"?"Launchpads":"Космопорты",model.counts.launchpads],
2602    [siteLanguage==="en"?"Storage Facilities":"Хранилища",model.counts.storages],
2603    [siteLanguage==="en"?"Factories":"Заводы",model.counts.factories],
2604    ["ECU",model.counts.ecus],
2605    [siteLanguage==="en"?"Extractor heads":"Головки экстрактора",model.counts.heads],
2606    [siteLanguage==="en"?"Links":"Линии связи",model.links.length]
2607  ].filter(([_label,value],index)=>value>0||index<3);
2608  return rows.map(([label,value])=>`<div><span>${label}</span><b>${value}</b></div>`).join("");
2609}
2610function templatePreviewUsageHtml(result){
2611  if(!result?.usage)return"";
2612  const rows=[
2613    ["CPU",result.usage.cpu,result.usage.capacity.cpu],
2614    ["Power Grid",result.usage.pg,result.usage.capacity.pg]
2615  ];
2616  return rows.map(([label,used,capacity])=>{
2617    const percent=Math.max(0,Math.min(100,Number(used)/Math.max(1,Number(capacity))*100));
2618    return `<div class="piTemplatePreviewUsageRow"><div><span>${label}</span><b>${Math.ceil(used).toLocaleString(siteLocale())} / ${Number(capacity).toLocaleString(siteLocale())}</b></div><div class="piTemplatePreviewUsageTrack"><i style="width:${percent.toFixed(2)}%"></i></div></div>`;
2619  }).join("");
2620}
2621function templatePreviewLegendHtml(model){
2622  const groups=[
2623    ["launchpad",siteLanguage==="en"?"Launchpad":"Космопорт"],["storage",siteLanguage==="en"?"Storage Facility":"Хранилище"],
2624    ["ecu","ECU"],["basic",siteLanguage==="en"?"Basic Industry Facility":"Базовый завод"],
2625    ["advanced",siteLanguage==="en"?"Advanced Industry Facility":"Продвинутый завод"],["highTech",siteLanguage==="en"?"High-Tech Production Plant":"Высокотехнологичный завод"]
2626  ];
2627  const rows=groups.map(([kind,label])=>{
2628    const pin=model.pins.find(item=>item.kind===kind);if(!pin)return"";
2629    const glyphKind=templatePreviewGlyphKind(kind),glyphPath=PI_TEMPLATE_GLYPHS[glyphKind];
2630    return `<div><span class="piTemplateLegendIcon kind-${kind}"><svg class="piTemplateLegendGlyph piTemplate${glyphKind[0].toUpperCase()+glyphKind.slice(1)}Glyph" viewBox="0 0 24 24" aria-hidden="true"><path d="${glyphPath}"></path></svg></span><span>${label}</span></div>`;
2631  }).join("");
2632  return `${rows}<div><span class="piTemplateLegendLink"></span><span>${siteLanguage==="en"?"Link":"Линия связи"}</span></div>`;
2633}
2634let activeTemplatePreview=null;
2635function hideTemplateObjectTooltip(){
2636  const tooltip=$("piTemplateObjectTooltip");if(tooltip)tooltip.classList.remove("visible");
2637}
2638function showTemplateObjectTooltip(label,event,target){
2639  const tooltip=$("piTemplateObjectTooltip"),surface=$("piTemplatePreviewSurface");if(!tooltip||!surface)return;
2640  tooltip.textContent=label;tooltip.classList.add("visible");
2641  const bounds=surface.getBoundingClientRect?.()||{left:0,top:0,width:900,height:600};
2642  const targetBounds=target?.getBoundingClientRect?.();
2643  let x=event?.clientX!=null?event.clientX-bounds.left:(targetBounds?targetBounds.left+targetBounds.width/2-bounds.left:bounds.width/2);
2644  let y=event?.clientY!=null?event.clientY-bounds.top:(targetBounds?targetBounds.top-bounds.top:bounds.height/2);
2645  const width=tooltip.offsetWidth||260,height=tooltip.offsetHeight||40;
2646  x=Math.max(8,Math.min(bounds.width-width-8,x+14));y=Math.max(8,Math.min(bounds.height-height-8,y+14));
2647  tooltip.style.left=`${x}px`;tooltip.style.top=`${y}px`;
2648}
2649function bindTemplatePreviewPins(){
2650  const diagram=$("piTemplatePreviewDiagram");if(!diagram)return;
2651  diagram.querySelectorAll("[data-template-label]").forEach(pin=>{
2652    const show=event=>showTemplateObjectTooltip(pin.dataset.templateLabel,event,pin);
2653    pin.onpointerenter=show;pin.onpointermove=show;pin.onpointerleave=hideTemplateObjectTooltip;
2654    pin.onfocus=show;pin.onblur=hideTemplateObjectTooltip;
2655  });
2656}
2657function copyPlanetTemplateResult(planet,result){
2658  if(!result?.ok){toast(result?.error||"Не удалось построить шаблон");return}
2659  copyText(result.text,`Шаблон скопирован: ${result.launchpads} космопорт., ${result.actualFactories} завод.`);
2660  metrikaGoal("pi_template_copied",{planet_type:planet.pt,mode:result.mode,launchpads:result.launchpads,storages:result.storages,factories:result.actualFactories});
2661}
2662function openTemplatePreview(planet){
2663  const result=planet&&buildPlanetTemplate(planet),model=planet&&templatePreviewModel(planet,result);
2664  if(!model?.ok){toast(model?.error||"Не удалось построить шаблон");return}
2665  const radiusSource=planetTemplateSetting(planet).radiusSource;
2666  activeTemplatePreview={planet,result,model,returnFocus:document.activeElement};
2667  $("piTemplatePreviewTitle").textContent=planet.label;
2668  $("piTemplatePreviewSubtitle").textContent=`${localizedPlanetType(planet.pt)} · ${radiusSource==="automatic"?(siteLanguage==="en"?"exact radius":"точный радиус"):radiusSource==="manual"?(siteLanguage==="en"?"entered radius":"указанный радиус"):(siteLanguage==="en"?"standard radius":"стандартный радиус")}`;
2669  const surface=$("piTemplatePreviewSurface");surface.className=`piTemplatePreviewSurface planetScene${String(planet.pt).replace(/[^A-Za-z]/g,"")}`;
2670  $("piTemplatePreviewDiagram").innerHTML=templatePreviewSvg(model);
2671  $("piTemplatePreviewDescription").innerHTML=templatePreviewMaterialRowsHtml(model.materialRows);
2672  $("piTemplatePreviewComposition").innerHTML=templatePreviewCompositionHtml(model);
2673  $("piTemplatePreviewUsage").innerHTML=templatePreviewUsageHtml(result);
2674  $("piTemplatePreviewLegend").innerHTML=templatePreviewLegendHtml(model);
2675  bindTemplatePreviewPins();hideTemplateObjectTooltip();
2676  $("piTemplatePreviewModal").classList.remove("hidden");document.body.classList.add("piTemplatePreviewOpen");
2677  $("piTemplatePreviewClose")?.focus?.();
2678  metrikaGoal("pi_template_previewed",{planet_type:planet.pt,pins:model.pins.length,links:model.links.length});
2679}
2680function closeTemplatePreview(){
2681  const modal=$("piTemplatePreviewModal");if(!modal)return;
2682  modal.classList.add("hidden");document.body.classList.remove("piTemplatePreviewOpen");hideTemplateObjectTooltip();
2683  activeTemplatePreview?.returnFocus?.focus?.();activeTemplatePreview=null;
2684}
2685function planetTemplatePanelHtml(planet){
2686  const setting=planetTemplateSetting(planet),result=buildPlanetTemplate(planet),source=setting.radiusSource;
2687  const mode=source==="automatic"
2688    ? (siteLanguage==="en"?"Exact template":"Точный шаблон")
2689    : source==="manual"
2690      ? (siteLanguage==="en"?"Template by entered radius":"Шаблон по указанному радиусу")
2691      : (siteLanguage==="en"?"Standard template":"Стандартный шаблон");
2692  const tooltip=source==="automatic"
2693    ? (siteLanguage==="en"?`The exact ${setting.radiusKm.toLocaleStr
2693ing(siteLocale())} km radius was loaded automatically for this planet and cannot be changed.`:`Точный радиус ${setting.radiusKm.toLocaleString(siteLocale())} км получен автоматически для этой планеты и защищён от изменений.`)
2694    : source==="manual"
2695      ? (siteLanguage==="en"?`The entered ${setting.radiusKm.toLocaleString(siteLocale())} km radius is used to calculate link lengths.`:`Указанный вручную радиус ${setting.radiusKm.toLocaleString(siteLocale())} км используется для расчёта длины связей.`)
2696      : (siteLanguage==="en"?`The radius is unknown. A safe standard estimate for the ${localizedPlanetType(planet.pt)} planet type is used.`:`Радиус не указан. Используется безопасная стандартная оценка для типа планеты «${localizedPlanetType(planet.pt)}».`);
2697  const radiusBadge=source==="automatic"?(siteLanguage==="en"?"🔒 Auto":"🔒 Авто"):source==="manual"?(siteLanguage==="en"?"Manual":"Вручную"):(siteLanguage==="en"?"Standard":"Стандарт");
2698  const infra=result.ok
2699    ? (siteLanguage==="en"
2700      ? `<span>Launchpads - <b>${result.launchpads}</b>;</span><span>Storage Facilities - <b>${result.storages}</b>;</span><span>Factories - <b>${result.actualFactories}</b>;</span>`
2701      : `<span>Космопорт - <b>${result.launchpads}</b>;</span><span>Хранилище - <b>${result.storages}</b>;</span><span>Заводы - <b>${result.actualFactories}</b>;</span>`)
2702    : `<span>${esc(result.error)}</span>`;
2703  const warnings=result.ok&&result.warnings?.length?`<div class="piTemplateWarnings">${result.warnings.map(esc).join(" ")}</div>`:"";
2704  const extractionControls=planet.p0?.length?`<div class="piTemplateInfraInputs">${templateStructureControl(setting,"launchpad",planet.pt)}
2704${templateStructureControl(setting,"storage",planet.pt)}</div>`:"";
2705  return `<div class="piTemplatePanel ${result.ok?"ready":"invalid"}" data-template-planet="${esc(planet.colonyKey)}">
2706    <div class="piTemplateHead"><b>${mode}</b><span class="settingHelp piTemplateHelp" tabindex="0" role="button" aria-label="Описание шаблона" data-tooltip="${esc(tooltip)}">?</span></div>
2707    ${extractionControls}
2708    <div class="piTemplateRadius"><label>${siteLanguage==="en"?"Radius, km":"Радиус, км"} <input class="piTemplateRadiusInput ${setting.radiusLocked?"automaticRadius":""}" type="number" min="1" max="100000" step="10" value="${setting.radiusKm||""}" placeholder="${siteLanguage==="en"?"standard":"стандарт"}" data-template-key="${esc(setting.key)}" data-template-default-launchpads="${setting.launchpad}" data-template-default-storages="${setting.storage}" ${setting.radiusLocked?`disabled readonly aria-label="${siteLanguage==="en"?"Exact radius loaded automatically":"Точный радиус получен автоматически"}"`:''}><span class="piTemplateRadiusSource ${source}">${radiusBadge}</span></label><small class="piTemplateInfraSummary">${infra}</small></div>
2709    ${result.ok?`<div class="piTemplateUsage">${esc(templateUsageText(result))}</div>`:""}
2710    ${warnings}
2711    <div class="piTemplateActions">
2712      <button class="copyPiTemplate" type="button" data-colony-key="${esc(planet.colonyKey)}" ${result.ok?"":"disabled"}>⧉ ${siteLanguage==="en"?"Copy template":"Скопировать шаблон"}</button>
2713      <button class="previewPiTemplate" type="button" data-colony-key="${esc(planet.colonyKey)}" aria-label="${siteLanguage==="en"?"Preview template":"Посмотреть шаблон"}" title="${siteLanguage==="en"?"Preview template":"Посмотреть шаблон"}" ${result.ok?"":"disabled"}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.4 12s3.5-6 9.6-6 9.6 6 9.6 6-3.5 6-9.6 6-9.6-6-9.6-6Z"></path><circle cx="12" cy="12" r="2.8"></circle></svg></button>
2714    </div>
2715  </div>`;
2716}
2717function bindPlanetTemplateControls(planets){
2718  const byKey=new Map((planets||[]).map(planet=>[planet.colonyKey,planet]));
2719  const rerender=()=>{renderInstructions(null,planets);autosave()};
2720  document.querySelectorAll("#assignments .piTemplateInfraToggle").forEach(button=>button.onclick=()=>{
2721    const key=button.dataset.templateKey,field=button.dataset.templateField,current=planetTemplateSettings[key]||{launchpad:Math.max(1,clampStorageCount(button.dataset.templateDefaultLaunchpads)),storage:clampStorageCount(button.dataset.templateDefaultStorages),radiusKm:0};
2722    if(field==="launchpad"){current.launchpad=Math.max(1,clampStorageCount(current.launchpad));planetTemplateSettings[key]=current;return}
2723    const defaultCount=Math.max(1,clampStorageCount(button.dataset.templateDefaultStorages));
2724    current[field]=clampStorageCount(current[field])>0?0:defaultCount;planetTemplateSettings[key]=current;rerender();
2725  });
2726  document.querySelectorAll("#assignments .piTemplateInfraInput").forEach(input=>input.onchange=()=>{
2727    const key=input.dataset.templateKey,field=input.dataset.templateField,current=planetTemplateSettings[key]||{launchpad:Math.max(1,clampStorageCount(input.dataset.templateDefaultLaunchpads)),storage:clampStorageCount(input.dataset.templateDefaultStorages),radiusKm:0};
2728    current[field]=Math.max(1,clampStorageCount(input.value));planetTemplateSettings[key]=current;rerender();
2729  });
2730  document.querySelectorAll("#assignments .piTemplateRadiusInput").forEach(input=>input.onchange=()=>{
2731    if(input.disabled)return;
2732    const key=input.dataset.templateKey,current=planetTemplateSettings[key]||{launchpad:Math.max(1,clampStorageCount(input.dataset.templateDefaultLaunchpads)),storage:clampStorageCount(input.dataset.templateDefaultStorages),radiusKm:0};
2733    current.radiusKm=clampTemplateRadius(input.value);current.radiusSource=current.radiusKm?"manual":"standard";planetTemplateSettings[key]=current;rerender();
2734  });
2735  document.querySelectorAll("#assignments .copyPiTemplate").forEach(button=>button.onclick=()=>{
2736    const planet=byKey.get(button.dataset.colonyKey),result=planet&&buildPlanetTemplate(planet);
2737    copyPlanetTemplateResult(planet,result);
2738  });
2739  document.querySelectorAll("#assignments .previewPiTemplate").forEach(button=>button.onclick=()=>openTemplatePreview(byKey.get(button.dataset.colonyKey)));
2740}
2741
2742function renderInstructions(a,preparedPlan=null){
2743  const planets=preparedPlan||buildPlanetInstructions(a);
2744  const transfers=annotateCloneTransfers(planets);
2745  const assignments=assignToClones(planets);
2746
2747  let html="";
2748  assignments.forEach(c=>{
2749    html+=`<section class="clonePlan"><div class="clonePlanHead"><b>${esc(c.name)}</b><span class="cloneUsage">${c.planets.length}/${c.limit} планет задействовано</span></div>`;
2750    c.planets.forEach(p=>{
2751      const assemblyLabel=p.p0.length?"":" — сборочная";
2752      html+=`<div class="planetPlan planetScene planetScene${esc(p.pt)}"><div class="planetTitle"><b>${planetIcon(p.pt)}${esc(p.label)}${assemblyLabel}</b><label class="planetBuildToggle"><input type="checkbox" class="planetBuiltCheck" autocomplete="off" aria-label="${esc((siteLanguage==="en"?"Built: ":"Построено: ")+c.name+" — "+p.label)}">${siteLanguage==="en"?"Built":"Построено"}</label></div>
2753      ${p.tasks.map((t,taskIndex)=>renderFactoryTaskLines(p,t,taskIndex)).join("")}${planetTemplatePanelHtml(p)}</div>`;
2754    });
2755    const free=c.limit-c.planets.length;
2756    if(free>0)html+=`<div class="freePlanet">Свободно планет у клона: ${free}</div>`;
2757    html+="</section>";
2758  });
2759
2760  if(transfers.length){
2761    const transferTitle=siteLanguage==="en"?"TRANSFERS BETWEEN CLONES":"ПЕРЕДАЧИ МЕЖДУ КЛОНАМИ";
2762    const tooltip=siteLanguage==="en"
2763      ?"If no fraction is shown, transfer the entire material. ‘Transfer 1/2’ means half of the total amount of that material produced by the sending clone. Every fraction is calculated from the original total, not from the remainder after another transfer."
2764      :"Если возле материала нет дроби — передайте его целиком. «Передать 1/2» означает половину от всего количества этого материала, произведённого клоном-отправителем. Все доли считаются от исходного общего количества, а не от остатка после предыдущей передачи.";
2765    const helpLabel=siteLanguage==="en"?"How transfer fractions work":"Как обозначаются полная и частичная передачи";
2766    html+=`<section class="transferSummary"><div class="transferSummaryHead"><span>${transferTitle}</span><span class="settingHelp transferSummaryHelp" tabindex="0" role="button" aria-label="${esc(helpLabel)}" data-tooltip="${esc(tooltip)}">?</span></div>${transfers.map(t=>`<div class="transferRoute"><b>${esc(t.fromName)}
2766 → ${esc(t.toName)}</b><span class="transferProducts">${[...t.products.values()].map(transferSummaryProductHtml).join('<span class="transferSeparator">, </span>')}</span></div>`).join("")}</section>`;
2767  }
2768  const systemSuffix=String(selectedSystemInfo?.name||$("systemInput")?.value||"").trim();
2769  const defaultPlanName=`${siteLanguage==="en"?"Plan":"План"} ${savedPlanCount+1}${systemSuffix?` ${systemSuffix}`:""}`;
2770  html+=`<section class="planSaveBar"><label><span>${siteLanguage==="en"?"Plan name":"Название плана"}</span><input id="planSaveName" maxlength="80" value="${esc(defaultPlanName)}"></label><button id="saveAccountPlan" type="button">${siteLanguage==="en"?"Save plan":"Сохранить план"}</button><span id="planSaveStatus"></span></section>`;
2771
2772  $("assignments").innerHTML=`<p id="planetBuildProgress" class="planetBuildProgress" role="status" aria-live="polite"></p>`+html;
2773  bindPlanetBuildChecks();
2774  bindStorageControls(planets);
2775  bindPlanetTemplateControls(planets);
2776  bindPlanSaveControl();
2777}
2778
2779function bindPlanetBuildChecks(){
2780  // Temporary DOM-only checklist: never part of saved/exported plan state.
2781  const boxes=[...document.querySelectorAll("#assignments .planetBuiltCheck")];
2782  const update=()=>{
2783    const count=boxes.filter(box=>box.checked).length;
2784    const status=$("planetBuildProgress");
2785    if(status)status.textContent=siteLanguage==="en"?`Built ${count} of ${boxes.length}`:`Построено ${count} из ${boxes.length}`;
2786  };
2787  for(const box of boxes){
2788    box.checked=false;
2789    const card=box.closest(".planetPlan");
2790    if(card)card.classList.remove("planetBuilt");
2791    box.onchange=()=>{if(card)card.classList.toggle("planetBuilt",box.checked);update();};
2792  }
2793  update();
2794}
2795
2796function bindPlanSaveControl(){
2797  const button=$("saveAccountPlan");if(!button)return;
2798  button.onclick=async()=>{
2799    if(!accountCsrf){location.href="/account";return}
2800    const name=$("planSaveName").value.trim(),status=$("planSaveStatus");if(!name){status.textContent="Укажите название";return}
2801    button.disabled=true;status.textContent="Сохраняем…";
2802    try{const response=await fetch("/api/plans",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json","X-CSRF-Token":accountCsrf},body:JSON.stringify({name,project:serializeProject(false)})});const data=await response.json().catch(()=>({}));if(!response.ok)throw new Error(data.error||"Не удалось сохранить план");savedPlanCount++;status.textContent=`Сохранено: ${data.plan.name}`;toast("План сохранён в аккаунте")}
2803    catch(error){status.textContent=error.message}finally{button.disabled=false}
2804  };
2805}
2806
2807const initialPlannerLocation={pathname:location.pathname,search:location.search};
2808async function loadLocalAccountData(){
2809  try{
2810    const query=new URLSearchParams(initialPlannerLocation.search);
2811    const pathShare=initialPlannerLocation.pathname.match(/(?:^|\/)p\/([A-Za-z0-9_-]{20,64})\/?$/);
2812    const planId=query.get("plan"),shareToken=query.get("share")||(pathShare?.[1]||"");
2813    const planRequest=planId||shareToken?fetch(planId?`/api/plans/${encodeURIComponent(planId)}`:`/api/shared-plans/${encodeURIComponent(shareToken)}`,{credentials:"same-origin",cache:"no-store"}):null;
2814    const session=await fetch("/api/auth/session",{credentials:"same-origin",cache:"no-store"});if(!session.ok&&!planRequest)return;
2815    const data=session.ok?await session.json():{};accountCsrf=data.csrf||"";authorizedClones=Array.isArray(data.characters)?data.characters:[];
2816    if(query.get("eveClones")==="1"){
2817      let transfer=null;try{transfer=JSON.parse(localStorage.getItem("planetHubEvePlannerTransfer")||"null")}catch(_error){}
2818      const allowed=new Set(authorizedClones.map(item=>Number(item.character_id)));
2819      const imported=transfer?.version===1&&Date.now()-Number(transfer.savedAt)<600000&&Array.isArray(transfer.clones)?transfer.clones.filter(item=>allowed.has(Number(item.characterId))).map((item,index)=>({name:String(item.name||`Клон ${index+1}`),limit:fix(item.limit),ccLevel:Math.max(1,Math.min(6,Math.round(Number(item.ccLevel)||6))),characterId:Number(item.characterId)})):[];
2820      if(imported.length){clones=imported;allocationMemo=new Map();invalidateProfitabilityRanking();autosave();toast(`Клоны EVE добавлены: ${imported.length}`);}
2821      try{localStorage.removeItem("planetHubEvePlannerTransfer")}catch(_error){}
2822      query.delete("eveClones");history.replaceState(null,"",location.pathname+(query.toString()?`?${query}`:""));
2823    }
2824    renderClones();
2825    const plansRequest=data.user?fetch("/api/plans",{credentials:"same-origin",cache:"no-store"}):null;
2826    if(planRequest){
2827      const response=await planRequest;
2828      const payload=await response.json().catch(()=>({}));if(!response.ok)throw new Error(payload.error||"Не удалось загрузить план");
2829      const project=planId?payload.plan?.project:payload.project;
2830      if(!project||typeof project!=="object")throw new Error("Сервер вернул пустой план");
2831      const applied=await applyProject(project,{shared:!!shareToken});
2832      if(!applied)throw new Error("Не удалось применить загруженный план");
2833      history.replaceState(null,"",initialPlannerLocation.pathname);
2834      setTimeout(()=>document.getElementById("planPanel")?.scrollIntoView({behavior:"smooth",block:"start"}),450);
2835    }
2836    if(plansRequest){const plans=await plansRequest;if(plans.ok){savedPlanCount=(await plans.json()).plans?.length||0;const input=$("planSaveName"),systemSuffix=String(selectedSystemInfo?.name||$("systemInput")?.value||"").trim();if(input&&/^(План|Plan) 1(?:\s+.*)?$/.test(input.value))input.value=`${siteLanguage==="en"?"Plan":"План"} ${savedPlanCount+1}${systemSuffix?` ${systemSuffix}`:""}`;}}
2837  }catch(error){toast(error.message||"Не удалось загрузить данные аккаунта")}
2838}
2839
2840function plan(){
2841  if(!selected.length){$("planPanel").classList.add("hidden");renderCommandCenterSummary([]);return}
2842  $("planPanel").classList.remove("hidden");
2843  let a=allocation(selected);
2844  if(a.ok&&$("maxUtilization").checked&&selected.length===1){
2845    const packed=maximumPackedAllocation(selected[0]);
2846    if(packed?.allocation?.ok)a=packed.allocation;
2847  }
2848  const built=a.ok?buildPlanetInstructionsFor(selected,a,!!$("maxUtilization").checked):{plan:[],segments:[]};
2849  const finalPlan=built.plan;
2850  if(a.ok){
2851    const resourceError=finalPlan.map(planet=>({planet,result:buildPlanetTemplate(planet)})).find(item=>!item.result.ok);
2852    if(resourceError)a={...a,ok:false,reason:`${resourceError.planet.cloneName} · ${resourceError.planet.label}: ${resourceError.result.error}`};
2853  }
2854  const usedPlanetCount=finalPlan.length;
2855  const freePlanetCount=Math.max(0,cap()-usedPlanetCount);
2856  const currentEconomics=a.ok&&marketSettings.enabled&&!marketLoading?evaluateBuiltPlanEconomics(built,selected):null;
2857  $("planTitle").innerHTML=selected.map(id=>{
2858    const product=products.get(id);
2859    return `${selected.length>1?colorDots([id]):""}${materialIcon(product,"planTargetIcon")}<span class="planTargetName">${esc(product.n)}</span>`;
2860  }).join(`<span class="planTargetSeparator"> + </span>`);
2861  $("planStats").classList.toggle("hasPlanValue",marketSettings.enabled);
2862  $("planStats").innerHTML=`<div class="stat"><span>Выбрано</span><b>${selected.length}</b></div>
2863  <div class="stat"><span>${$("maxUtilization").checked?"Задействовано планет":"Минимум планет"}</span><b>${a.ok?usedPlanetCount:"—"}</b></div>
2864  <div class="stat"><span>Доступно клонам</span><b>${cap()}</b></div>
2865  <div class="stat"><span>Свободно планет</span><b>${a.ok?freePlanetCount:"—"}</b></div>
2866  ${marketSettings.enabled?`<div class="stat planValueStat"><span>Итоговая стоимость плана</span><b>${marketLoading?"Загрузка цен…":currentEconomics?`${Math.round(currentEconomics.total30Days).toLocaleString(siteLocale())} ISK / 30 дней`:"—"}</b></div>`:""}`;
2867  renderCommandCenterSummary(a.ok?finalPlan:[]);
2868  if(!a.ok){$("assignments").innerHTML=`<div class="assign"><b style="color:var(--bad)">План невыполним</b><div class="reason">${esc(a.reason||"")}</div></div>`;maybeResumeGuidedTour("plan");return}
2869  renderInstructions(a,finalPlan);renderChain();trackCreatedPlan(finalPlan);maybeResumeGuidedTour("plan");
2870}
2871let recalcToken=0;
2872const calculationPause=()=>new Promise(resolve=>setTimeout(resolve,24));
2873function setCalculationProgress(percent,stage){
2874  $("calculationPercent").textContent=`${percent}%`;
2875  $("calculationBar").style.width=`${percent}%`;
2876  $("calculationStage").textContent=translateSiteText(stage);
2877}
2878function recalc(){
2879  if(!current)return;
2880  const token=++recalcToken;
2881  $("calculationProgress").classList.remove("hidden");
2882  setCalculationProgress(5,"Подготовка данных…");
2883  (async()=>{
2884    try{
2885      await calculationPause();if(token!==recalcToken)return;
2886      allocationMemo=new Map();
2887      setCalculationProgress(25,"Проверка выбранной конфигурации…");
2888      const selectedPlan=selected.length?allocation(selected):null;
2889      if(selectedPlan&&!selectedPlan.ok){
2890        setCalculationProgress(45,"Конфигурация невозможна — формирование предупреждения…");
2891        await calculationPause();if(token!==recalcToken)return;
2892      }
2893      setCalculationProgress(55,"Обновление доступных материалов…");
2894      renderPotential();
2895      await calculationPause();if(token!==recalcToken)return;
2896      setCalculationProgress(72,"Пересчёт списков P0–P4…");
2897      mats();
2898      await calculationPause();if(token!==recalcToken)return;
2899      setCalculationProgress(90,"Формирование плана по клонам…");
2900      plan();
2901      await calculationPause();if(token!==recalcToken)return;
2902      setCalculationProgress(100,"Готово");
2903      await calculationPause();if(token!==recalcToken)return;
2904    }catch(err){
2905      console.error(err);toast("Ошибка пересчёта: "+err.message);
2906    }finally{
2907      if(token===recalcToken)$("calculationProgress").classList.add("hidden");
2908    }
2909  })();
2910}
2911
2912function run(){
2913  if(planetAutoApplyTimer){clearTimeout(planetAutoApplyTimer);planetAutoApplyTimer=null}
2914  document.querySelectorAll(".planetCountRow input").forEach(input=>{
2915    planetCounts[input.dataset.pt]=clampPlanetCount(input.value);
2916    input.value=planetCounts[input.dataset.pt];
2917  });
2918  if(!Object.values(planetCounts).some(n=>n>0)){
2919    alert(translateSiteText("Укажите хотя бы одну доступную планету"));return;
2920  }
2921  current={system:selectedSystemInfo?{...selectedSystemInfo,planets:D.meta.planetTypes.map(type=>planetCounts[type]||0)}:manualSystem()};
2922  planetCountsDirty=false;
2923  trackedPlanSignatures.clear();
2924  selected=[];
2925  targetColors={};
2926  planetSettings={};
2927  storageSettings={};
2928  planetTemplateSettings={};
2929  ensurePlanetSettings();
2930  $("empty").classList.add("hidden");
2931  $("result").classList.remove("hidden");
2932  metrikaGoal("planets_applied",{total_planets:physicalPlanets(),clone_capacity:cap()});
2933  invalidateProfitabilityRanking();recalc();autosave();maybeResumeGuidedTour("planner");
2934}
2935$("showUnavailable").onchange=()=>
2935{recalc();autosave()};
2936$("clearSelectionBtn").onclick=()=>{selected=[];targetColors={};recalc();autosave()};
2937
2938document.querySelectorAll(".planTab").forEach(btn=>btn.onclick=()=>{
2939  document.querySelectorAll(".planTab").forEach(x=>x.classList.remove("active"));
2940  btn.classList.add("active");
2941  let v=btn.dataset.view;
2942  $("instructionView").classList.toggle("hidden",v!=="instructions");
2943  $("chainView").classList.toggle("hidden",v!=="chain");
2944});
2945
2946/* ---------- Save / load / diagnostic export ---------- */
2947function serializeProject(includeDiagnostics=false){
2948  const base={
2949    schema:PROJECT_SCHEMA,
2950    version:PROJECT_VERSION,
2951    sdeBuild:D.meta.sdeBuild,
2952    savedAt:new Date().toISOString(),
2953    system:current?{id:current.system?.id??null,name:current.system?.name||"Ручной состав",security:current.system?.security??null,source:selectedSystemInfo?.source||"manual",planetDetails:normalizedPlanetDetails(current.system?.planetDetails)}:null,
2954    planetCounts:{...planetCounts},
2955    clones:clones.map(c=>({...c})),
2956    settings:{
2957      maxUtilization:$("maxUtilization").checked,
2958      showUnavailable:$("showUnavailable").checked
2959    },
2960    globalPlanetSettings:JSON.parse(JSON.stringify(globalPlanetSettings)),
2961    productionSettings:JSON.parse(JSON.stringify(productionSettings)),
2962    marketSettings:JSON.parse(JSON.stringify(marketSettings)),
2963    planetSettings:JSON.parse(JSON.stringify(planetSettings)),
2964    storageSettings:JSON.parse(JSON.stringify(storageSettings)),
2965    planetTemplateSettings:JSON.parse(JSON.stringify(planetTemplateSettings)),
2966    selected:[...selected],
2967    targetColors:{...targetColors}
2968  };
2969  if(includeDiagnostics&&current){
2970    let a=selected.length?allocation(selected):null;
2971    if(a?.ok&&$("maxUtilization").checked&&selected.length===1){
2972      const packed=maximumPackedAllocation(selected[0]);
2973      if(packed?.allocation?.ok)a=packed.allocation;
2974    }
2975    const generatedBuilt=a?.ok?buildPlanetInstructionsFor(selected,a,!!$("maxUtilization").checked):null;
2976    const generatedPlan=generatedBuilt?.plan||null;
2977    const currentPlanEconomics=generatedBuilt&&marketSettings.enabled&&!marketLoading?evaluateBuiltPlanEconomics(generatedBuilt,selected):null;
2978    if(generatedPlan)annotateCloneTransfers(generatedPlan);
2979    base.diagnostics={
2980      capacity:cap(),
2981      physicalPlanets:physicalPlanets(),
2982      selectedProducts:selected.map(id=>products.get(id)).filter(Boolean),
2983      dependencies:dependencyProducts(selected),
2984      requiredP0:selected.length?[...selected.reduce((acc,id)=>{roots(id).forEach(x=>acc.add(x));return acc},new Set())]:[],
2985      allocation:a,
2986      finalPlanStats:generatedPlan?{usedPlanets:generatedPlan.length,freePlanets:Math.max(0,cap()-generatedPlan.length)}:null,
2987      generatedPlan,
2988      currentPlanEconomics,
2989      marketSnapshot:marketSettings.enabled?{
2990        hub:marketSettings.hub,
2991        priceDate:marketPriceDate,
2992        priceBasis:"30-day volume-weighted average of ESI market history",
2993        prices:[...marketPrices].map(([id,average30])=>({id,name:products.get(id)?.n||String(id),tier:products.get(id)?.t,average30,lastCompletedDayAverage:marketTrends.get(id)?.current??null,averageDailyVolume:marketTrends.get(id)?.averageDailyVolume??null,volume:PRODUCT_VOLUME[products.get(id)?.t]||0})),
2994        profitabilityRanking:profitabilityRankingCache.map((x,index)=>({
2995          rank:index+1,id:x.product.id,name:x.product.n,tier:x.product.t,total30Days:x.total30Days,
2996          customsTransfer30Days:x.customsTransfer30Days,customsTransferExport30Days:x.customsTransferExport30Days,customsTransferImport30Days:x.customsTransferImport30Days,p0Requirements:x.p0Requirements,usedPlanets:x.usedPlanets,freePlanets:x.freePlanets,targetLines:x.targetLines,lines:x.lines
2997        }))
2998      }:null
2999    };
3000  }
3001  return base;
3002}
3003function downloadJson(obj,filename){
3004  const blob=new Blob([JSON.stringify(obj,null,2)],{type:"application/json"});
3005  const url=URL.createObjectURL(blob),a=document.createElement("a");
3006  a.href=url;a.download=filename;document.body.appendChild(a);a.click();a.remove();
3007  setTimeout(()=>URL.revokeObjectURL(url),1000);
3008}
3009$("exportDebugBtn").onclick=()=>{
3010  downloadJson(serializeProject(true),`eve_pi_debug_${current?.system?.name||"no-system"}.json`);
3011  toast("Диагностический файл создан");
3012};
3013// Импорт проектов временно скрыт из интерфейса; автосохранение в браузере остаётся.
3014async function applyProject(obj,{shared=false}={}){
3015  try{
3016    if(obj.schema!==PROJECT_SCHEMA)throw new Error("Неизвестный формат проекта");
3017    const sourceClones=Array.isArray(obj.clones)&&obj.clones.length?obj.clones:[];
3018    const plannedIds=sourceClones.map(c=>Number(c.characterId)).filter(Number.isSafeInteger).sort((a,b)=>a-b);
3019    const authorizedIds=authorizedClones.map(c=>Number(c.character_id)).filter(Number.isSafeInteger).sort((a,b)=>a-b);
3020    const exactCloneMatch=plannedIds.length===sourceClones.length&&plannedIds.length===authorizedIds.length&&plannedIds.every((id,index)=>id===authorizedIds[index]);
3021    clones=sourceClones.length?sourceClones.map((c,index)=>({
3022      name:shared&&!exactCloneMatch?`Клон ${index+1}`:(c.name||`Клон ${index+1}`),limit:fix(c.limit),ccLevel:Math.max(1,Math.min(6,Math.round(Number(c.ccLevel)||6))),
3023      ...(shared&&!exactCloneMatch?{}:Number.isSafeInteger(Number(c.characterId))?{characterId:Number(c.characterId)}:{})
3024    })):[{name:"Клон 1",limit:6,ccLevel:6}];
3025    $("maxUtilization").checked=!!obj.settings?.maxUtilization;
3026    $("showUnavailable").checked=obj.settings?.showUnavailable===true;
3027    renderClones();
3028    if(obj.planetCounts||obj.system?.name){
3029      if(obj.planetCounts){
3030        planetCounts=Object.fromEntries(D.meta.planetTypes.map(pt=>[pt,clampPlanetCount(obj.planetCounts[pt])]));
3031      }else{
3032        const legacy=D.systems.find(s=>s.name===obj.system.name);
3033        if(!legacy)throw new Error("В старом проекте нет данных о количестве планет");
3034        planetCounts=Object.fromEntries(D.meta.planetTypes.map((pt,i)=>[pt,legacy.planets[i]||0]));
3035      }
3036      selectedSystemInfo=obj.system?.id?{id:Number(obj.system.id),name:String(obj.system.name||""),security:obj.system.security??null,planets:D.meta.planetTypes.map(type=>planetCounts[type]||0),planetDetails:normalizedPlanetDetails(obj.system.planetDetails),source:obj.system.source||"saved"}:null;
3037      renderPlanetCountForm();
3038      $("systemInput").value=selectedSystemInfo?.name||"";
3039      current={system:selectedSystemInfo?{...selectedSystemInfo}:manualSystem()};
3040      planetCountsDirty=false;
3041      globalPlanetSettings=obj.globalPlanetSettings&&typeof obj.globalPlanetSettings==="object"
3042        ? {...globalPlanetSettings,...obj.globalPlanetSettings}
3043        : {maxP0:99,p0p1:true,p1p2:true,p2p3:true,p3p4:true};
3044      globalPlanetSettings.maxP0=99;globalPlanetSettings.p1p2=true;globalPlanetSettings.p2p3=true;globalPlanetSettings.p3p4=true;
3045      productionSettings=obj.productionSettings&&typeof obj.productionSettings==="object"
3046        ? normalizedProductionSettings(obj.productionSettings)
3047        : normalizedProductionSettings();
3048      marketSettings=obj.marketSettings&&typeof obj.marketSettings==="object"
3049        ? {enabled:!!obj.marketSettings.enabled,hub:MARKET_HUBS[obj.marketSettings.hub]?obj.marketSettings.hub:"Jita",tax:clampPercent(obj.marketSettings.tax),broker:clampPercent(obj.marketSettings.broker),customsTax:obj.marketSettings.customsTax==null?2:clampPercent(obj.marketSettings.customsTax),hauling:clampMoney(obj.marketSettings.hauling)}
3050        : {enabled:false,hub:"Jita",tax:3.6,broker:0,customsTax:2,hauling:0};
3051      planetSettings=obj.planetSettings&&typeof obj.planetSettings==="object"?obj.planetSettings:{};
3052      storageSettings=normalizedStorageSettings(obj.storageSettings);
3053      planetTemplateSettings=normalizedPlanetTemplateSettings(obj.planetTemplateSettings);
3054      ensurePlanetSettings();
3055      selected=(obj.selected||[]).map(Number).filter(id=>products.has(id));
3056      targetColors=obj.targetColors&&typeof obj.targetColors==="object"?{...obj.targetColors}:{};
3057      selected.forEach(ensureTargetColor);
3058      renderProductionControls();
3059      $("empty").classList.add("hidden");$("result").classList.remove("hidden");
3060      invalidateProfitabilityRanking();recalc();
3061      if(marketSettings.enabled)loadMarketTrends(false);
3062    }
3063    autosave();
3063toast(shared&&!exactCloneMatch?"План загружен; клоны заменены на Клон 1, Клон 2…":"Проект загружен");return true;
3064  }catch(err){alert(translateSiteText("Не удалось загрузить проект: "+err.message));return false}
3065}
3066$("projectFileInput").onchange=async e=>{
3067  const file=e.target.files?.[0];if(!file)return;
3068  await applyProject(JSON.parse(await file.text()));
3069  e.target.value="";
3070};
3071
3072function autosave(){
3073  try{
3074    localStorage.setItem("eve_pi_autosave_v10",JSON.stringify(serializeProject(false)));
3075    autosaveWarningShown=false;
3076  }catch(err){
3077    console.warn("PI Factory: автосохранение недоступно",err);
3078    if(!autosaveWarningShown){
3079      autosaveWarningShown=true;
3080      toast("Автосохранение недоступно в этом браузере");
3081    }
3082  }
3083}
3084
3085if($("langRuBtn")&&$("langEnBtn")){
3086  $("langRuBtn").onclick=()=>setSiteLanguage("ru");
3087  $("langEnBtn").onclick=()=>setSiteLanguage("en");
3088  applySiteLanguage();
3089  if(typeof MutationObserver!=="undefined"){
3090    let translating=false;
3091    new MutationObserver(records=>{
3092      if(translating)return;
3093      translating=true;
3094      for(const record of records)for(const node of record.addedNodes||[])if(node.nodeType===1||node.nodeType===3)applySiteLanguage(node.nodeType===3?node.parentNode:node);
3095      translating=false;
3096    }).observe(document.body,{childList:true,subtree:true});
3097  }
3098}
3099
3100if($("guidedTourOpenBtn")&&$("guidedTour")){
3101  $("guidedTourOpenBtn").onclick=()=>openGuidedTour(false);
3102  $("guidedTourPrev").onclick=()=>moveGuidedTour(-1);
3103  $("guidedTourNext").onclick=()=>moveGuidedTour(1);
3104  $("guidedTourChoiceSecondary").onclick=()=>handleGuidedTourChoice(false);
3105  $("guidedTourChoicePrimary").onclick=()=>handleGuidedTourChoice(true);
3106  $("guidedTourSkip").onclick=skipGuidedTour;
3107  $("guidedTourSkipTop").onclick=skipGuidedTour;
3108  if(typeof document.addEventListener==="function")document.addEventListener("keydown",event=>{
3109    if($("guidedTour").classList.contains("hidden"))return;
3110    if(event.key==="Escape"){event.preventDefault?.();skipGuidedTour()}
3111    else if(event.key==="ArrowLeft"){event.preventDefault?.();moveGuidedTour(-1)}
3112    else if(event.key==="ArrowRight"){event.preventDefault?.();moveGuidedTour(1)}
3113  });
3114  if(typeof window.addEventListener==="function"){
3115    window.addEventListener("resize",positionGuidedTour);
3116    window.addEventListener("scroll",positionGuidedTour,{passive:true,capture:true});
3117    let completed=false;try{completed=localStorage.getItem(GUIDED_TOUR_STORAGE_KEY)==="completed"}catch(_error){}
3118    document.body.classList.remove("guidedTourOpen");
3119    $("guidedTour").classList.add("hidden");
3120    try{if(!completed)localStorage.removeItem(GUIDED_TOUR_RESUME_KEY)}catch(_error){}
3121  }
3122}
3123
3124if($("piTemplatePreviewModal")){
3125  $("piTemplatePreviewClose").onclick=closeTemplatePreview;
3126  $("piTemplatePreviewCancel").onclick=closeTemplatePreview;
3127  $("piTemplatePreviewCopy").onclick=()=>{
3128    if(activeTemplatePreview)copyPlanetTemplateResult(activeTemplatePreview.planet,activeTemplatePreview.result);
3129  };
3130  $("piTemplatePreviewModal").onclick=event=>{if(event.target===$("piTemplatePreviewModal"))closeTemplatePreview()};
3131  if(typeof document.addEventListener==="function")document.addEventListener("keydown",event=>{
3132    if(event.key==="Escape"&&!$("piTemplatePreviewModal").classList.contains("hidden"))closeTemplatePreview();
3133  });
3134}
3135loadLocalAccountData();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.