From 85421afc5f9c0390c9014bcb769b83d9b505b96a Mon Sep 17 00:00:00 2001 From: Verter73 Date: Sun, 26 Jul 2026 03:49:25 +0300 Subject: [PATCH 1/7] ROI and nuclide set builder: a guided tool for ROI configs and libraries Adds Tools -> "ROI and nuclide set builder": a three-step window that turns a choice of nuclides into a ready ROI configuration or a NuclideSet library entry, so neither has to be typed in by hand line by line. What it does. Step 1 picks sources: 121 nuclides from an IAEA Live Chart / ENSDF snapshot with family codes (ANSI N42.34), three decay chains, and XRF lines of shielding and detector materials. Step 2 turns them into lines: filters by intensity, energy and half-life; merging of lines the detector cannot resolve (k*FWHM, scintillator model); secondary peaks (backscatter, Compton edge, escape 511/1022, iodine K-escape, annihilation, cascade sum, pile-up); and a search for what else emits near a given energy. Step 3 exports: an ROI configuration through ROIConfigManager, or a nuclide set through NuclideDefinitionManager with anchors set for the library fit. How it is wired in. One menu item; nothing existing changes behaviour. The resolution comes from the active spectrum's FWHM calibration through a delegate, so the window stays usable and testable with no spectrum open. No new dependencies: XmlSerializer, WinForms and XPTable are already in the project. Labels follow the project's own mechanism: RoiWizard/RoiWizardStrings.resx (neutral, English) plus .ru.resx, satellite built by MSBuild, exactly as for the other forms. The calculation core knows no language at all - the checker returns an issue code and its arguments, the form composes the phrase. Data is a snapshot in RoiWizard/nuclides.xml (101 KB), rebuilt by tools/export_catalog.py; the help text lives in help.xml, exported from the reference web page so the two cannot drift apart. Checked: the solution builds with the module (Release, .NET Framework 4.8, zero errors); 100 core tests pass; the window was driven under ru-RU and en-US. Co-Authored-By: Claude Opus 5 --- BecquerelMonitor/BecquerelMonitor.csproj | 28 + BecquerelMonitor/MainForm.Designer.cs | 10 + BecquerelMonitor/MainForm.cs | 44 + BecquerelMonitor/MainForm.resx | 6 + BecquerelMonitor/MainForm.ru.resx | 3 + BecquerelMonitor/RoiWizard/AnchorPicker.cs | 184 ++ .../RoiWizard/CatalogCellRenderers.cs | 340 +++ BecquerelMonitor/RoiWizard/HelpForm.cs | 319 +++ BecquerelMonitor/RoiWizard/LineMerger.cs | 152 ++ BecquerelMonitor/RoiWizard/LineSetBuilder.cs | 369 +++ BecquerelMonitor/RoiWizard/NuclideCatalog.cs | 347 +++ .../RoiWizard/RoiWizardForm.Designer.cs | 1164 ++++++++ BecquerelMonitor/RoiWizard/RoiWizardForm.cs | 2091 ++++++++++++++ .../RoiWizard/RoiWizardStrings.Designer.cs | 821 ++++++ .../RoiWizard/RoiWizardStrings.resx | 529 ++++ .../RoiWizard/RoiWizardStrings.ru.resx | 529 ++++ BecquerelMonitor/RoiWizard/SecondaryPeaks.cs | 228 ++ BecquerelMonitor/RoiWizard/SetChecker.cs | 207 ++ BecquerelMonitor/RoiWizard/SetExporter.cs | 147 + BecquerelMonitor/RoiWizard/SpectralLine.cs | 225 ++ BecquerelMonitor/RoiWizard/WizardTheme.cs | 205 ++ BecquerelMonitor/RoiWizard/ZoneCalculator.cs | 61 + BecquerelMonitor/RoiWizard/help.xml | 5 + BecquerelMonitor/RoiWizard/nuclides.xml | 2429 +++++++++++++++++ 24 files changed, 10443 insertions(+) create mode 100644 BecquerelMonitor/RoiWizard/AnchorPicker.cs create mode 100644 BecquerelMonitor/RoiWizard/CatalogCellRenderers.cs create mode 100644 BecquerelMonitor/RoiWizard/HelpForm.cs create mode 100644 BecquerelMonitor/RoiWizard/LineMerger.cs create mode 100644 BecquerelMonitor/RoiWizard/LineSetBuilder.cs create mode 100644 BecquerelMonitor/RoiWizard/NuclideCatalog.cs create mode 100644 BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs create mode 100644 BecquerelMonitor/RoiWizard/RoiWizardForm.cs create mode 100644 BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs create mode 100644 BecquerelMonitor/RoiWizard/RoiWizardStrings.resx create mode 100644 BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx create mode 100644 BecquerelMonitor/RoiWizard/SecondaryPeaks.cs create mode 100644 BecquerelMonitor/RoiWizard/SetChecker.cs create mode 100644 BecquerelMonitor/RoiWizard/SetExporter.cs create mode 100644 BecquerelMonitor/RoiWizard/SpectralLine.cs create mode 100644 BecquerelMonitor/RoiWizard/WizardTheme.cs create mode 100644 BecquerelMonitor/RoiWizard/ZoneCalculator.cs create mode 100644 BecquerelMonitor/RoiWizard/help.xml create mode 100644 BecquerelMonitor/RoiWizard/nuclides.xml diff --git a/BecquerelMonitor/BecquerelMonitor.csproj b/BecquerelMonitor/BecquerelMonitor.csproj index 9c158d0..f09ef8c 100644 --- a/BecquerelMonitor/BecquerelMonitor.csproj +++ b/BecquerelMonitor/BecquerelMonitor.csproj @@ -84,6 +84,30 @@ true + + + + + + + + + + + + + True + RoiWizardStrings.resx + + + Form + + + Form + + + RoiWizardForm.cs + @@ -875,6 +899,10 @@ + + + + AboutForm.cs diff --git a/BecquerelMonitor/MainForm.Designer.cs b/BecquerelMonitor/MainForm.Designer.cs index 2840dbc..421572a 100644 --- a/BecquerelMonitor/MainForm.Designer.cs +++ b/BecquerelMonitor/MainForm.Designer.cs @@ -87,6 +87,7 @@ void InitializeComponent() this.roiDefinitionRToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.NuclideDefinitionToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.NuclideSetToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.RoiWizardToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.NucDB = new System.Windows.Forms.ToolStripMenuItem(); this.OpenConfigNToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripSeparator5 = new System.Windows.Forms.ToolStripSeparator(); @@ -557,6 +558,7 @@ void InitializeComponent() this.roiDefinitionRToolStripMenuItem, this.NuclideDefinitionToolStripMenuItem, this.NuclideSetToolStripMenuItem, + this.RoiWizardToolStripMenuItem, this.NucDB, this.OpenConfigNToolStripMenuItem, this.toolStripSeparator5, @@ -588,6 +590,12 @@ void InitializeComponent() resources.ApplyResources(this.NuclideSetToolStripMenuItem, "NuclideSetToolStripMenuItem"); this.NuclideSetToolStripMenuItem.Click += new System.EventHandler(this.NuclideSetToolStripMenuItem_Click); // + // RoiWizardToolStripMenuItem + // + this.RoiWizardToolStripMenuItem.Name = "RoiWizardToolStripMenuItem"; + resources.ApplyResources(this.RoiWizardToolStripMenuItem, "RoiWizardToolStripMenuItem"); + this.RoiWizardToolStripMenuItem.Click += new System.EventHandler(this.RoiWizardToolStripMenuItem_Click); + // // NucDB // this.NucDB.Name = "NucDB"; @@ -877,6 +885,8 @@ void InitializeComponent() global::System.Windows.Forms.ToolStripMenuItem NuclideSetToolStripMenuItem; + global::System.Windows.Forms.ToolStripMenuItem RoiWizardToolStripMenuItem; + global::System.Windows.Forms.ToolStripMenuItem OpenConfigNToolStripMenuItem; global::System.Windows.Forms.ToolStripMenuItem NucDB; diff --git a/BecquerelMonitor/MainForm.cs b/BecquerelMonitor/MainForm.cs index d6a1fa8..ae132a0 100644 --- a/BecquerelMonitor/MainForm.cs +++ b/BecquerelMonitor/MainForm.cs @@ -1264,6 +1264,50 @@ public void ShowNuclideSetForm() form.ShowDialog(); } + void RoiWizardToolStripMenuItem_Click(object sender, EventArgs e) + { + this.ShowRoiWizardForm(); + } + + // Окно мастера. Разрешение детектора передаётся делегатом: форма спросит его, + // когда пользователь нажмёт «из спектра», и оставит введённое руками значение, + // если взять неоткуда (0). + public void ShowRoiWizardForm() + { + using (RoiWizard.RoiWizardForm form = new RoiWizard.RoiWizardForm(this.RoiWizardResolution)) + { + form.ShowDialog(this); + } + } + + // Разрешение для мастера — из родной FWHM-калибровки активного спектра, приведённой + // к тому виду, в котором его ждёт мастер: R в процентах на 662 кэВ. Калибровка + // задана в каналах, поэтому ширина переводится в энергию так же, как в + // DCPeakDetectionView: по краям окна ±FWHM/2. + double RoiWizardResolution() + { + DocEnergySpectrum document = this.ActiveDocument; + if (document == null || document.ActiveResultData == null) + { + return 0; + } + ResultData active = document.ActiveResultData; + EnergySpectrum spectrum = active.EnergySpectrum; + if (spectrum == null || spectrum.EnergyCalibration == null || active.FwhmCalibration == null) + { + return 0; + } + double channel = spectrum.EnergyCalibration.EnergyToChannel(662.0, maxChannels: spectrum.NumberOfChannels); + double fwhmChannels = active.FwhmCalibration.ChannelToFwhm(channel); + if (!(fwhmChannels > 0)) + { + return 0; + } + double left = spectrum.EnergyCalibration.ChannelToEnergy(channel - fwhmChannels / 2.0); + double right = spectrum.EnergyCalibration.ChannelToEnergy(channel + fwhmChannels / 2.0); + return right > left ? (right - left) / 662.0 * 100.0 : 0; + } + // Token: 0x06000A73 RID: 2675 RVA: 0x0003E2F4 File Offset: 0x0003C4F4 Rectangle GetTotalBound() { diff --git a/BecquerelMonitor/MainForm.resx b/BecquerelMonitor/MainForm.resx index 68dbf33..01da614 100644 --- a/BecquerelMonitor/MainForm.resx +++ b/BecquerelMonitor/MainForm.resx @@ -5444,4 +5444,10 @@ System.Windows.Forms.Form, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + 224, 22 + + + ROI and nuclide set builder... + diff --git a/BecquerelMonitor/MainForm.ru.resx b/BecquerelMonitor/MainForm.ru.resx index 3daa44e..80ca601 100644 --- a/BecquerelMonitor/MainForm.ru.resx +++ b/BecquerelMonitor/MainForm.ru.resx @@ -318,4 +318,7 @@ Импорт при помощи SpectUtils.. + + Конструктор ROI и наборов нуклидов... + diff --git a/BecquerelMonitor/RoiWizard/AnchorPicker.cs b/BecquerelMonitor/RoiWizard/AnchorPicker.cs new file mode 100644 index 0000000..fef7b8a --- /dev/null +++ b/BecquerelMonitor/RoiWizard/AnchorPicker.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace BecquerelMonitor.RoiWizard +{ + // Якорная линия: ровно одна запись набора получает IsAnchor = true. Найдя её в спектре, + // BecqMoni сажает остальные линии набора на табличные позиции и подгоняет амплитуды + // (библиотечный фит). Без якоря механизм не запускается вовсе. + public static class AnchorPicker + { + // Хороший якорь — сильная И одинокая линия: сосед внутри FWHM смещает центроид + // найденного пика, и совпадение с табличной энергией перестаёт быть надёжным. + // Правило даёт 2614.5 для ряда Th-232 и 609.3 для Ra-226. + // Сколько якорных линий помечать по умолчанию. LibraryPeakFitter перебирает все + // записи с IsAnchor, берёт сдвиг калибровки с сильнейшей по SNR и требует, чтобы + // с найденным пиком совпала хотя бы одна (допуск 0.5·FWHM). Единственный якорь — + // единственная точка отказа: не нашёлся 2614.5 — набор молчит целиком. + public const int DefaultCount = 3; + + // Несколько якорей по тому же правилу: сильные и одинокие γ-линии, по убыванию + // интенсивности. Одинокие идут первыми — у них центроид найденного пика не смещён + // соседом; если одиноких не хватает, добираются просто сильные. + public static List PickMany(IList lines, + ResolutionModel resolution, int count) + { + List picked = new List(); + if (lines == null || lines.Count == 0 || count <= 0) + { + return picked; + } + + double max = MaxGammaIntensity(lines); + List lonely = new List(); + List rest = new List(); + foreach (SpectralLine line in lines) + { + if (line.Type != LineType.Gamma || line.Intensity < 0.2 * max) + { + continue; + } + if (IsLonely(line, lines, resolution, max)) + { + lonely.Add(line); + } + else + { + rest.Add(line); + } + } + lonely.Sort(ByIntensityDesc); + rest.Sort(ByIntensityDesc); + + foreach (SpectralLine line in lonely) + { + if (picked.Count >= count) + { + break; + } + picked.Add(line); + } + foreach (SpectralLine line in rest) + { + if (picked.Count >= count) + { + break; + } + picked.Add(line); + } + if (picked.Count == 0) + { + // γ-линий в наборе нет вовсе — та же оговорка, что и у Pick + SpectralLine fallback = Strongest(lines, LineType.Xray); + if (fallback != null) + { + picked.Add(fallback); + } + } + return picked; + } + + static int ByIntensityDesc(SpectralLine a, SpectralLine b) + { + return b.Intensity.CompareTo(a.Intensity); + } + + static double MaxGammaIntensity(IList lines) + { + double max = 0.0; + foreach (SpectralLine line in lines) + { + if (line.Type == LineType.Gamma && line.Intensity > max) + { + max = line.Intensity; + } + } + return max; + } + + public static SpectralLine Pick(IList lines, ResolutionModel resolution) + { + if (lines == null || lines.Count == 0) + { + return null; + } + // Порог 0.2·max считается по ОДНИМ γ-линиям. Интенсивности ХРИ условные + // (Kα1 = 100), и если брать максимум по всем линиям, то у слабо-γ нуклида + // рядом с ХРИ свинца все настоящие γ уходят ниже порога. + double max = 0.0; + foreach (SpectralLine line in lines) + { + if (line.Type == LineType.Gamma && line.Intensity > max) + { + max = line.Intensity; + } + } + + SpectralLine best = null; + SpectralLine bestLonely = null; + foreach (SpectralLine line in lines) + { + if (line.Type != LineType.Gamma || line.Intensity < 0.2 * max) + { + continue; + } + if (best == null || line.Intensity > best.Intensity) + { + best = line; + } + if (IsLonely(line, lines, resolution, max) && + (bestLonely == null || line.Intensity > bestLonely.Intensity)) + { + bestLonely = line; + } + } + SpectralLine pick = bestLonely ?? best; + if (pick != null) + { + return pick; + } + // Фолбэк только на настоящие линии распада: у ХРИ интенсивность условная, + // у вторичных положение — эмпирическая поправка. Якорь на таком маркере + // означал бы, что LibraryPeakFitter сажает весь набор по нефизической опоре. + return Strongest(lines, LineType.Xray); + } + + static SpectralLine Strongest(IList lines, LineType type) + { + SpectralLine pick = null; + foreach (SpectralLine line in lines) + { + if (line.Type == type && (pick == null || line.Intensity > pick.Intensity)) + { + pick = line; + } + } + return pick; + } + + // Годится ли линия в якоря: набор без якоря библиотечный фит не запускает вовсе, + // а якорь на ХРИ или вторичном маркере хуже отсутствия — фит «найдёт» опору там, + // где её физически нет. + public static bool IsAcceptable(SpectralLine line) + { + return line != null && (line.Type == LineType.Gamma || line.Type == LineType.Xray); + } + + static bool IsLonely(SpectralLine line, IList lines, + ResolutionModel resolution, double max) + { + double window = resolution.Fwhm(line.Energy); + foreach (SpectralLine other in lines) + { + if (!ReferenceEquals(other, line) && other.Intensity >= 0.05 * max && + Math.Abs(other.Energy - line.Energy) < window) + { + return false; + } + } + return true; + } + } + +} diff --git a/BecquerelMonitor/RoiWizard/CatalogCellRenderers.cs b/BecquerelMonitor/RoiWizard/CatalogCellRenderers.cs new file mode 100644 index 0000000..06c384b --- /dev/null +++ b/BecquerelMonitor/RoiWizard/CatalogCellRenderers.cs @@ -0,0 +1,340 @@ +using System; +using System.Drawing; +using System.Windows.Forms; +using XPTable.Events; +using XPTable.Models; +using XPTable.Renderers; + +namespace BecquerelMonitor.RoiWizard +{ + // Список нуклидов на странице — не таблица, а набор строк со своей вёрсткой: + // имя, цветные бейджи семейств и приглушённый хвост «T½ γN XN». Штатная ячейка + // XPTable знает один цвет и один шрифт на ячейку, поэтому три колонки списка + // рисуются своими рендерерами. Шрифт держится полем: OnPaint зовётся на каждую + // видимую ячейку при каждой перерисовке. + + // Бейджи семейств — правило .fbadge темы: прямоугольник без скругления, + // 9.5 px полужирным, свой цвет фона и текста на каждый код. + public class FamilyBadgeCellRenderer : CellRenderer + { + // padding 0 4px, margin-right 3px, line-height 14px — числа из темы + const int PadX = 4; + const int Gap = 3; + const int BadgeHeight = 14; + + Font font = WizardTheme.BadgeFont; + + protected override void OnPaint(PaintCellEventArgs e) + { + base.OnPaint(e); + if (e.Cell == null || string.IsNullOrEmpty(e.Cell.Text)) + { + return; + } + + Rectangle rect = this.ClientRectangle; + int x = rect.X; + int y = rect.Y + (rect.Height - BadgeHeight) / 2; + foreach (string code in e.Cell.Text.Split(' ')) + { + if (code.Length == 0) + { + continue; + } + string caption = code.ToUpperInvariant(); + int width = TextRenderer.MeasureText(e.Graphics, caption, this.font, + new Size(rect.Width, BadgeHeight), TextFormatFlags.NoPadding).Width + PadX * 2; + if (x + width > rect.Right) + { + break; // не влезло — так же обрывается строка на странице + } + Color back; + Color fore; + WizardTheme.FamilyColors(code, out back, out fore); + using (SolidBrush brush = new SolidBrush(back)) + { + e.Graphics.FillRectangle(brush, x, y, width, BadgeHeight); + } + TextRenderer.DrawText(e.Graphics, caption, this.font, + new Rectangle(x + PadX, y, width - PadX * 2, BadgeHeight), fore, + TextFormatFlags.NoPadding | TextFormatFlags.VerticalCenter); + x += width + Gap; + } + } + + public override void Dispose() + { + if (this.font != null) + { + this.font.Dispose(); + this.font = null; + } + base.Dispose(); + } + } + + // Счётчики линий: «γ12 X4» — γ акцентным цветом, X сиреневым, как в списке + // на странице. Числа приходят текстом ячейки вида «12 4»; X при нуле не рисуется. + public class LineCountCellRenderer : CellRenderer + { + Font font = WizardTheme.HintFont; + + protected override void OnPaint(PaintCellEventArgs e) + { + base.OnPaint(e); + if (e.Cell == null || string.IsNullOrEmpty(e.Cell.Text)) + { + return; + } + string[] parts = e.Cell.Text.Split(' '); + if (parts.Length < 2) + { + return; + } + + Rectangle rect = this.ClientRectangle; + int x = rect.X; + x += Draw(e.Graphics, "γ" + parts[0], this.font, WizardTheme.Accent, rect, x); + if (!string.Equals(parts[1], "0", StringComparison.Ordinal)) + { + Draw(e.Graphics, " X" + parts[1], this.font, WizardTheme.Xray, rect, x); + } + } + + static int Draw(Graphics graphics, string text, Font font, Color color, Rectangle rect, int x) + { + Size size = TextRenderer.MeasureText(graphics, text, font, + new Size(rect.Width, rect.Height), TextFormatFlags.NoPadding); + TextRenderer.DrawText(graphics, text, font, + new Rectangle(x, rect.Y, size.Width, rect.Height), color, + TextFormatFlags.NoPadding | TextFormatFlags.VerticalCenter); + return size.Width; + } + + public override void Dispose() + { + if (this.font != null) + { + this.font.Dispose(); + this.font = null; + } + base.Dispose(); + } + } + + // Заголовки числовых колонок — по центру. Штатный рендерер выравнивает все + // подписи влево (Table.HeaderAlignWithColumn по умолчанию выключен), и подпись + // висела над левым краем, тогда как числа прижаты к правому. + public class CenteredHeaderRenderer : XPHeaderRenderer + { + public override void OnPaintHeader(PaintHeaderEventArgs e) + { + if (e.Column != null) + { + this.Alignment = e.Column.Alignment == ColumnAlignment.Right + ? ColumnAlignment.Center + : e.Column.Alignment; + } + base.OnPaintHeader(e); + } + } + + // Числовая колонка с отступом от правого края: штатная ячейка прижимает число + // вплотную к границе, а на странице у td задан padding 0 7px. + public class NumberCellRenderer : CellRenderer + { + internal const int PadRight = 7; + + protected override void OnPaint(PaintCellEventArgs e) + { + base.OnPaint(e); + if (e.Cell == null || string.IsNullOrEmpty(e.Cell.Text)) + { + return; + } + Rectangle rect = this.ClientRectangle; + rect.Width -= PadRight; + TextRenderer.DrawText(e.Graphics, e.Cell.Text, this.Font, rect, this.ForeColor, + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding); + } + } + + // Микро-бар интенсивности: полупрозрачная заливка на долю относительной + // интенсивности, число поверх неё справа. Видно, где сильные линии, не читая + // чисел. Заливка именно полупрозрачная (--bar), а не сплошная: на выбранной + // строке фон и так --sel, сплошной бар с ним сливался бы. + // Доля приходит в Cell.Tag — Data занят интенсивностью, по ней идёт сортировка. + public class IntensityBarCellRenderer : CellRenderer + { + protected override void OnPaint(PaintCellEventArgs e) + { + base.OnPaint(e); + if (e.Cell == null) + { + return; + } + Rectangle rect = this.ClientRectangle; + if (e.Cell.Tag is double) + { + double share = (double)e.Cell.Tag; + int width = (int)Math.Round(rect.Width * Math.Max(0.0, Math.Min(100.0, share)) / 100.0); + if (width > 0) + { + using (SolidBrush brush = new SolidBrush(WizardTheme.Bar)) + { + e.Graphics.FillRectangle(brush, rect.X, rect.Y + 1, width, rect.Height - 2); + } + } + } + if (!string.IsNullOrEmpty(e.Cell.Text)) + { + rect.Width -= NumberCellRenderer.PadRight; // тот же отступ, что у прочих чисел + TextRenderer.DrawText(e.Graphics, e.Cell.Text, this.Font, rect, this.ForeColor, + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPadding); + } + } + } + + // Тип линии бейджем, как .badge в таблице на странице: γ, X, ХРИ, втор — + // каждый своей парой цветов. + public class LineTypeCellRenderer : CellRenderer + { + const int PadX = 5; + const int BadgeHeight = 14; + + Font font = WizardTheme.BadgeFont; + + protected override void OnPaint(PaintCellEventArgs e) + { + base.OnPaint(e); + if (e.Cell == null || string.IsNullOrEmpty(e.Cell.Text)) + { + return; + } + string caption = e.Cell.Text; + Rectangle rect = this.ClientRectangle; + int width = TextRenderer.MeasureText(e.Graphics, caption, this.font, + new Size(rect.Width, BadgeHeight), TextFormatFlags.NoPadding).Width + PadX * 2; + int y = rect.Y + (rect.Height - BadgeHeight) / 2; + Color back; + Color fore; + WizardTheme.LineTypeColors(e.Cell.Tag as string, out back, out fore); + using (SolidBrush brush = new SolidBrush(back)) + { + e.Graphics.FillRectangle(brush, rect.X, y, width, BadgeHeight); + } + TextRenderer.DrawText(e.Graphics, caption, this.font, + new Rectangle(rect.X + PadX, y, width - PadX * 2, BadgeHeight), fore, + TextFormatFlags.NoPadding | TextFormatFlags.VerticalCenter); + } + + public override void Dispose() + { + if (this.font != null) + { + this.font.Dispose(); + this.font = null; + } + base.Dispose(); + } + } + + // Кнопка «+ добавить» в строке результата поиска близких линий. Штатный + // ButtonCellRenderer растягивает кнопку на всю ячейку, а на странице это + // компактная кнопка у левого края; для нуклида, уже лежащего в наборе, кнопки + // на странице нет вовсе — вместо неё подпись «в наборе». + // + // Обе правки делаются одним местом: и рисование, и попадание мыши в XPTable + // идут через CalcButtonBounds. Пустой прямоугольник для «в наборе» гасит и + // отрисовку кнопки (ThemeManager.DrawButton молча выходит на нулевом размере), + // и клик — событие поднимается только из своих границ. + public class NearAddCellRenderer : ButtonCellRenderer + { + const int PadX = 9; // button{padding:2px 9px} темы + const int Inset = 2; // кнопка ниже строки таблицы, как на странице + + Cell current; + + protected override Rectangle CalcButtonBounds() + { + Rectangle rect = this.ClientRectangle; + if (this.current == null || this.current.Tag == null) + { + return Rectangle.Empty; // уже в наборе — кнопки нет + } + int width = TextRenderer.MeasureText(this.current.Text ?? "", this.Font).Width + PadX * 2; + return new Rectangle(rect.X, rect.Y + Inset, + Math.Min(rect.Width, width), Math.Max(0, rect.Height - Inset * 2)); + } + + public override void OnPaintCell(PaintCellEventArgs e) + { + this.current = e.Cell; + base.OnPaintCell(e); + if (e.Cell != null && e.Cell.Tag == null && !string.IsNullOrEmpty(e.Cell.Text)) + { + // кнопки нет — на её месте приглушённая подпись + TextRenderer.DrawText(e.Graphics, e.Cell.Text, this.Font, this.ClientRectangle, + WizardTheme.Muted, + TextFormatFlags.NoPadding | TextFormatFlags.VerticalCenter); + } + } + + // Мышь приходит без прохода через OnPaintCell, а границы кнопки зависят от + // ячейки — иначе клик по «в наборе» считался бы попаданием в чужую кнопку. + public override void OnMouseDown(CellMouseEventArgs e) + { + this.current = e.Cell; + base.OnMouseDown(e); + } + + public override void OnMouseUp(CellMouseEventArgs e) + { + this.current = e.Cell; + base.OnMouseUp(e); + } + + public override void OnMouseMove(CellMouseEventArgs e) + { + this.current = e.Cell; + base.OnMouseMove(e); + } + + public override void OnMouseEnter(CellMouseEventArgs e) + { + this.current = e.Cell; + base.OnMouseEnter(e); + } + } + + // Приглушённый хвост строки (.nuc .hl): 11 px цветом --muted. Цвет берётся + // из ячейки, если он задан — так серым гаснет нуклид без линий. + public class HintCellRenderer : CellRenderer + { + Font font = WizardTheme.HintFont; + + protected override void OnPaint(PaintCellEventArgs e) + { + base.OnPaint(e); + if (e.Cell == null || string.IsNullOrEmpty(e.Cell.Text)) + { + return; + } + Color color = this.ForeColor.IsEmpty || this.ForeColor == Color.Transparent + ? WizardTheme.Muted + : this.ForeColor; + TextRenderer.DrawText(e.Graphics, e.Cell.Text, this.font, this.ClientRectangle, color, + TextFormatFlags.NoPadding | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); + } + + public override void Dispose() + { + if (this.font != null) + { + this.font.Dispose(); + this.font = null; + } + base.Dispose(); + } + } +} diff --git a/BecquerelMonitor/RoiWizard/HelpForm.cs b/BecquerelMonitor/RoiWizard/HelpForm.cs new file mode 100644 index 0000000..e6ac34d --- /dev/null +++ b/BecquerelMonitor/RoiWizard/HelpForm.cs @@ -0,0 +1,319 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Windows.Forms; +using System.Xml; + +namespace BecquerelMonitor.RoiWizard +{ + // Справка — тот же текст, что в модальном окне страницы. Он не переписан в код, + // а вынут из index.html скриптом tools/export_help.py в ресурс help.xml: два + // текста иначе разъедутся после первой же правки страницы. Здесь разбирается + // то же подмножество разметки, что использует страница: p, b, code, a, таблица. + public class HelpForm : Form + { + const string ResourceName = "BecquerelMonitor.RoiWizard.help.xml"; + + readonly RichTextBox view = new RichTextBox(); + readonly List links = new List(); + readonly bool russian = + Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "ru"; + + Font baseFont; + Font boldFont; + Font codeFont; + + public HelpForm() + { + this.baseFont = WizardTheme.BaseFont; + this.boldFont = new Font(this.baseFont, FontStyle.Bold); + this.codeFont = new Font("Consolas", this.baseFont.SizeInPoints, FontStyle.Regular); + + this.Text = RoiWizardStrings.helpTitle; + this.ClientSize = new Size(940, 660); + this.MinimumSize = new Size(600, 400); + this.StartPosition = FormStartPosition.CenterParent; + this.MinimizeBox = false; + this.MaximizeBox = true; + this.ShowInTaskbar = false; + + this.view.Dock = DockStyle.Fill; + this.view.ReadOnly = true; + this.view.BorderStyle = BorderStyle.None; + this.view.BackColor = WizardTheme.Card; + this.view.ForeColor = WizardTheme.Ink; + this.view.Font = this.baseFont; + this.view.DetectUrls = false; // ссылки расставляет разбор, а не эвристика + this.view.WordWrap = true; + this.view.ScrollBars = RichTextBoxScrollBars.Vertical; + this.view.MouseClick += this.OnViewClick; + this.view.MouseMove += this.OnViewMove; + // поля страницы: RichTextBox сам отступов не держит, текст лип бы к рамке + Panel page = new Panel(); + page.Dock = DockStyle.Fill; + page.Padding = new Padding(16, 14, 12, 14); + page.BackColor = WizardTheme.Card; + page.Controls.Add(this.view); + this.Controls.Add(page); + + this.Render(LoadHelp(this.russian ? "ru" : "en")); + this.view.Select(0, 0); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + this.baseFont.Dispose(); + this.boldFont.Dispose(); + this.codeFont.Dispose(); + } + base.Dispose(disposing); + } + + protected override bool ProcessCmdKey(ref Message message, Keys key) + { + if (key == Keys.Escape) + { + this.Close(); + return true; + } + return base.ProcessCmdKey(ref message, key); + } + + static string LoadHelp(string language) + { + Assembly assembly = Assembly.GetExecutingAssembly(); + using (Stream stream = assembly.GetManifestResourceStream(ResourceName)) + { + if (stream == null) + { + throw new FileNotFoundException( + "Resource " + ResourceName + " not found: add RoiWizard\\help.xml " + + "to the project as an EmbeddedResource (generated by tools/export_help.py)."); + } + XmlDocument document = new XmlDocument(); + document.Load(stream); + XmlNode node = document.SelectSingleNode("/Help/Text[@lang='" + language + "']") + ?? document.SelectSingleNode("/Help/Text"); + return node == null ? "" : node.InnerText; + } + } + + // ─── разбор разметки ──────────────────────────────────────────────── + + sealed class LinkSpan + { + public readonly int Start; + public readonly int Length; + public readonly string Url; + + public LinkSpan(int start, int length, string url) + { + this.Start = start; + this.Length = length; + this.Url = url; + } + + public bool Contains(int index) + { + return index >= this.Start && index < this.Start + this.Length; + } + } + + int bold; + int code; + string linkUrl; + int linkStart; + int cellIndex; + bool headerRow; + + void Render(string html) + { + this.view.Clear(); + this.links.Clear(); + foreach (Match token in Regex.Matches(html, @"<[^>]+>|[^<]+")) + { + string piece = token.Value; + if (piece[0] == '<') + { + this.Tag(piece); + } + else + { + this.Write(Decode(piece)); + } + } + } + + void Tag(string tag) + { + bool closing = tag.StartsWith("= 2 ? "\n\n" : "\n"); + } + + void Write(string text) + { + if (text.Length == 0) + { + return; + } + this.view.SelectionStart = this.view.TextLength; + this.view.SelectionLength = 0; + this.view.SelectionFont = this.code > 0 ? this.codeFont + : (this.bold > 0 || this.headerRow ? this.boldFont : this.baseFont); + this.view.SelectionColor = this.linkUrl != null ? WizardTheme.Accent : WizardTheme.Ink; + this.view.AppendText(text); + } + + static readonly string[][] Entities = { + new string[] { "«", "«" }, new string[] { "»", "»" }, + new string[] { "α", "α" }, new string[] { "β", "β" }, + new string[] { "≥", "≥" }, new string[] { "≤", "≤" }, + new string[] { " ", " " }, new string[] { """, "\"" }, + new string[] { "<", "<" }, new string[] { ">", ">" }, + new string[] { "&", "&" } + }; + + static string Decode(string text) + { + if (text.IndexOf('&') < 0) + { + return text; + } + StringBuilder result = new StringBuilder(text); + foreach (string[] pair in Entities) + { + result.Replace(pair[0], pair[1]); // & последним: иначе развернёт остальные дважды + } + return result.ToString(); + } + + // ─── ссылки ───────────────────────────────────────────────────────── + + LinkSpan LinkAt(Point point) + { + int index = this.view.GetCharIndexFromPosition(point); + foreach (LinkSpan link in this.links) + { + if (link.Contains(index)) + { + return link; + } + } + return null; + } + + void OnViewClick(object sender, MouseEventArgs e) + { + LinkSpan link = this.LinkAt(e.Location); + if (link != null && !string.IsNullOrEmpty(link.Url)) + { + try + { + Process.Start(link.Url); + } + catch (Exception error) + { + MessageBox.Show(this, error.Message, this.Text, + MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + } + + void OnViewMove(object sender, MouseEventArgs e) + { + this.view.Cursor = this.LinkAt(e.Location) != null ? Cursors.Hand : Cursors.Default; + } + } +} diff --git a/BecquerelMonitor/RoiWizard/LineMerger.cs b/BecquerelMonitor/RoiWizard/LineMerger.cs new file mode 100644 index 0000000..f75168a --- /dev/null +++ b/BecquerelMonitor/RoiWizard/LineMerger.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace BecquerelMonitor.RoiWizard +{ + // Слияние линий, которые детектор всё равно не разделит. + // Аналог того, что делал NuclideMaster в IDWin, но с порогами из LibraryPeakFitter. + public class LineMerger + { + readonly ResolutionModel resolution; + readonly double factor; + + public LineMerger(ResolutionModel resolution, double factorOfFwhm) + { + this.resolution = resolution; + this.factor = factorOfFwhm; + } + + public static LineMerger For(ResolutionModel resolution, MergeCriterion criterion) + { + return new LineMerger(resolution, MergeCriterionInfo.DefaultFactor(criterion)); + } + + public int MergedGroups { get; private set; } + + public int AbsorbedLines { get; private set; } + + // Возвращает набор, в котором слитые группы заменены одной линией. + // Порог применяется ко ВСЕЙ группе (complete linkage), а не к соседней паре: + // при попарном сравнении цепочка близких линий склеивалась в группу шире порога — + // например у Ac-228 944…989 кэВ при пороге 12 кэВ. + public List Merge(IEnumerable lines) + { + this.MergedGroups = 0; + this.AbsorbedLines = 0; + + Dictionary> byNuclide = new Dictionary>(); + List untouched = new List(); + foreach (SpectralLine line in lines) + { + // вторичные пики не сливаем: они и так расчётные маркеры + if (line.Type == LineType.Secondary) + { + untouched.Add(line); + continue; + } + List list; + if (!byNuclide.TryGetValue(line.Nuclide, out list)) + { + list = new List(); + byNuclide[line.Nuclide] = list; + } + list.Add(line); + } + + List result = new List(untouched); + foreach (KeyValuePair> entry in byNuclide) + { + List group = entry.Value; + group.Sort(delegate(SpectralLine a, SpectralLine b) { return a.Energy.CompareTo(b.Energy); }); + + List cluster = new List(); + foreach (SpectralLine line in group) + { + if (cluster.Count == 0) + { + cluster.Add(line); + continue; + } + // расстояние меряется от ПЕРВОЙ линии группы, поэтому вся группа + // укладывается в порог, а не только соседние пары + if (line.Energy - cluster[0].Energy <= this.factor * this.resolution.Fwhm(line.Energy)) + { + cluster.Add(line); + } + else + { + Flush(cluster, result); + cluster = new List { line }; + } + } + Flush(cluster, result); + } + result.Sort(delegate(SpectralLine a, SpectralLine b) { return a.Energy.CompareTo(b.Energy); }); + return result; + } + + void Flush(List cluster, List output) + { + if (cluster.Count == 0) + { + return; + } + if (cluster.Count == 1) + { + output.Add(cluster[0]); + return; + } + + double sum = 0.0; + double weighted = 0.0; + bool anySelected = false; + SpectralLine strongest = cluster[0]; + foreach (SpectralLine line in cluster) + { + sum += line.Intensity; + weighted += line.Energy * line.Intensity; + anySelected = anySelected || line.Selected; + if (line.Intensity > strongest.Intensity) + { + strongest = line; + } + } + // центроид взвешен по интенсивности — именно туда сядет найденный пик + double centroid = sum > 0 ? weighted / sum : cluster[0].Energy; + string interval = Fmt(cluster[0].Energy) + "–" + Fmt(cluster[cluster.Count - 1].Energy); + + output.Add(new SpectralLine + { + Key = "m|" + strongest.Nuclide + "|" + centroid.ToString("0.0", CultureInfo.InvariantCulture), + Nuclide = strongest.Nuclide, + Label = strongest.Label + " (" + interval + ")", + Interval = interval, + Merged = true, + Energy = Math.Round(centroid, 2), + Intensity = Math.Round(sum, 3), + RawIntensity = sum, + Type = strongest.Type, + HalfLifeYears = strongest.HalfLifeYears, + HalfLifeText = strongest.HalfLifeText, + Selected = anySelected + }); + this.MergedGroups++; + this.AbsorbedLines += cluster.Count; + } + + // Порог в кэВ на характерных энергиях — то, что стоит показать пользователю: + // «сливаются линии ближе N кэВ на 100, M на 662». + public double ThresholdAt(double energy) + { + return this.factor * this.resolution.Fwhm(energy); + } + + static string Fmt(double energy) + { + return energy < 100 + ? energy.ToString("0.0", CultureInfo.InvariantCulture) + : Math.Round(energy).ToString(CultureInfo.InvariantCulture); + } + } +} diff --git a/BecquerelMonitor/RoiWizard/LineSetBuilder.cs b/BecquerelMonitor/RoiWizard/LineSetBuilder.cs new file mode 100644 index 0000000..a3757f2 --- /dev/null +++ b/BecquerelMonitor/RoiWizard/LineSetBuilder.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace BecquerelMonitor.RoiWizard +{ + // Как нуклид попадает в набор. Скобки в имени — не украшение: BecqMoni читает по ним + // цепочку (ChainOf в LibraryPeakFitter) и связывает амплитуды линий ряда. + public enum AddMode + { + // только собственные линии нуклида + Single, + // линии дочерних идут под именем родителя, одной записью + FamilyLines, + // дочерние — отдельными нуклидами, родитель в скобках + Chain + } + + public class SourceSelection + { + // имя нуклида -> подпись, под которой его линии попадут в набор + public Dictionary Nuclides { get; private set; } + + // символы элементов, чей характеристический рентген добавлен как маркеры + public HashSet XrfElements { get; private set; } + + public SourceSelection() + { + this.Nuclides = new Dictionary(StringComparer.OrdinalIgnoreCase); + this.XrfElements = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + public void Add(NuclideCatalog catalog, string name, AddMode mode) + { + CatalogNuclide nuclide = catalog.Find(name); + if (nuclide == null) + { + return; + } + switch (mode) + { + case AddMode.Single: + this.Nuclides[name] = name; + break; + + case AddMode.FamilyLines: + // все члены ряда под именем выбранного нуклида: в наборе он один, + // а линии дочерних приписаны ему + this.Nuclides[name] = name; + foreach (string member in MembersBelow(catalog, nuclide)) + { + this.Nuclides[member] = name; + } + break; + + case AddMode.Chain: + // корень цепочки идёт без скобок — иначе он оказался бы сам себе родителем + this.Nuclides[name] = name; + foreach (string member in MembersBelow(catalog, nuclide)) + { + this.Nuclides[member] = member + " (" + name + ")"; + } + break; + } + } + + // Добавление в составе группы: у члена ЕРН-ряда подпись обязана нести КОРЕНЬ ряда, + // а не имя самого нуклида. Иначе набор, собранный кнопкой «добавить все» по ряду + // или по семейству, распадается на два десятка одиночных «цепочек», и связка + // амплитуд в LibraryPeakFitter не собирается ни по одной. + // + // Отдельный метод, а не режим Single, потому что одиночно добавленный нуклид — + // это другой случай: у него родителя в наборе нет, подпись остаётся своей, + // и равновесный пересчёт к нему не применяется. + public void AddGroupMember(NuclideCatalog catalog, string name) + { + CatalogNuclide nuclide = catalog.Find(name); + if (nuclide == null) + { + return; + } + string root = catalog.ChainRoot(nuclide); + this.Nuclides[name] = string.IsNullOrEmpty(root) || string.Equals(root, name, StringComparison.Ordinal) + ? name + : name + " (" + root + ")"; + } + + public void Remove(string name) + { + this.Nuclides.Remove(name); + } + + public void Clear() + { + this.Nuclides.Clear(); + this.XrfElements.Clear(); + } + + static IEnumerable MembersBelow(NuclideCatalog catalog, CatalogNuclide parent) + { + CatalogChain chain = catalog.FindChain(parent.Chain); + if (chain == null) + { + yield break; + } + int start = chain.Members.IndexOf(parent.Name); + if (start < 0) + { + yield break; + } + for (int i = start + 1; i < chain.Members.Count; i++) + { + yield return chain.Members[i]; + } + } + } + + // Фильтры отбирают линии в набор; видимостью в таблице они не управляют. + // Разделение выстрадано: иначе пользователь снимает галку типа, а из набора + // молча пропадают линии. + public class LineFilter + { + public bool IntensityOn { get; set; } + public double MinIntensity { get; set; } + // относительная — в процентах от сильнейшей линии этого же нуклида + public bool RelativeIntensity { get; set; } + + public bool EnergyOn { get; set; } + public double MinEnergy { get; set; } + public double MaxEnergy { get; set; } + + public bool HalfLifeOn { get; set; } + public double MinHalfLifeYears { get; set; } + public double MaxHalfLifeYears { get; set; } + + public LineFilter() + { + this.MinIntensity = 3.0; + this.RelativeIntensity = true; + this.MinEnergy = 10.0; + this.MaxEnergy = 3000.0; + this.MaxHalfLifeYears = double.PositiveInfinity; + } + + public bool Passes(SpectralLine line, double strongestOfNuclide) + { + if (this.IntensityOn) + { + double value = this.RelativeIntensity && strongestOfNuclide > 0 + ? 100.0 * line.Intensity / strongestOfNuclide + : line.Intensity; + if (value < this.MinIntensity) + { + return false; + } + } + if (this.EnergyOn && (line.Energy < this.MinEnergy || line.Energy > this.MaxEnergy)) + { + return false; + } + if (this.HalfLifeOn && (line.HalfLifeYears < this.MinHalfLifeYears || + line.HalfLifeYears > this.MaxHalfLifeYears)) + { + return false; + } + return true; + } + } + + public class LineSetBuilder + { + readonly NuclideCatalog catalog; + + public LineSetBuilder(NuclideCatalog catalog) + { + this.catalog = catalog; + } + + // Пересчёт интенсивностей на один распад родителя ряда. Держать включённым для + // рядов: веса связанных линий BecqMoni берёт из Intencity, и без пересчёта + // относительные высоты линий разных членов ряда несопоставимы. + public bool ScaleToSeriesParent { get; set; } + + public LineSetBuilder Reset() + { + this.ScaleToSeriesParent = true; + return this; + } + + public List Build(SourceSelection selection, LineFilter filter) + { + List lines = new List(); + foreach (KeyValuePair entry in selection.Nuclides) + { + CatalogNuclide nuclide = this.catalog.Find(entry.Key); + if (nuclide == null) + { + continue; + } + string label = entry.Value; + // Пересчёт на распад родителя применим только к нуклиду, взятому в составе + // ряда: у одиночно добавленного Tl-208 родителя в наборе нет, и множитель + // 0.3594 превратил бы 99.75 % его линии 2614 кэВ в 35.85 % без всяких на то + // оснований. Признак «взят в составе ряда» — подпись отличается от имени: + // при Chain это «Tl-208 (Th-232)», при FamilyLines — имя родителя. + bool partOfSeries = !string.Equals(label, nuclide.Name, StringComparison.Ordinal); + double equilibrium = this.ScaleToSeriesParent && partOfSeries + ? EquilibriumFactors.For(nuclide.Name) + : 1.0; + + foreach (CatalogGammaLine gamma in nuclide.Gamma) + { + lines.Add(new SpectralLine + { + Key = "g|" + nuclide.Name + "|" + Fmt(gamma.Energy), + Nuclide = nuclide.Name, + Label = label, + OwnerLabel = label, + Energy = gamma.Energy, + Intensity = Math.Round(gamma.Intensity * equilibrium, 4), + RawIntensity = gamma.Intensity, + Type = LineType.Gamma, + HalfLifeYears = nuclide.HalfLifeYears > 0 ? nuclide.HalfLifeYears : 1e9, + HalfLifeText = nuclide.HalfLifeText + }); + } + foreach (CatalogXrayLine xray in nuclide.Xray) + { + lines.Add(new SpectralLine + { + Key = "x|" + nuclide.Name + "|" + Fmt(xray.Energy) + "|" + xray.Shell, + Nuclide = nuclide.Name, + Label = WithSuffix(label, "X " + xray.Shell), + OwnerLabel = label, + Energy = xray.Energy, + Intensity = Math.Round(xray.Intensity * equilibrium, 4), + RawIntensity = xray.Intensity, + Type = LineType.Xray, + HalfLifeYears = nuclide.HalfLifeYears > 0 ? nuclide.HalfLifeYears : 1e9, + HalfLifeText = nuclide.HalfLifeText + }); + } + } + + foreach (string symbol in selection.XrfElements) + { + XrfElement element = this.catalog.FindElement(symbol); + if (element == null) + { + continue; + } + foreach (XrfLine line in element.Lines) + { + lines.Add(new SpectralLine + { + Key = "xrf|" + symbol + "|" + Fmt(line.Energy), + Nuclide = "XRF " + symbol, + Label = "XRF " + symbol + " " + line.Label, + Energy = line.Energy, + // интенсивности ХРИ условные (Ka1 = 100) — это маркеры, не выходы, + // поэтому равновесие ряда к ним не применяется + Intensity = line.Intensity, + RawIntensity = line.Intensity, + Type = LineType.Xrf, + HalfLifeYears = 1e9, + HalfLifeText = "—" + }); + } + } + + ApplySelection(lines, filter); + return lines; + } + + // Профиль «полный набор» для библиотечного фита: все γ- и X-линии выбранных + // источников, независимо от галок, фильтров и слияния. + // + // Прореживание набора вредит: по прогонам разработчика BecqMoni (RjmcmcTuning, + // итерация 9) полная цепочка ряда даёт recall 26/29 для Th-232 и 27/27 для Ra-226, + // а «набор пользователя из 16 сильных линий» не добавляет к слепому поиску ничего. + // Слияние тоже не применяется: пары от 0.25 до 0.85 FWHM разбирает сам библиотечный + // фит по якорю, и склеивать их — значит отнимать у него линии. + // + // Вторичных пиков здесь нет и быть не должно: escape- и сумм-компоненты + // LibraryPeakFitter строит сам, а имя вида «SE (Bi-214)» создало бы ложную цепочку. + public List BuildFullSet(SourceSelection selection) + { + List lines = this.Build(selection, null); + foreach (SpectralLine line in lines) + { + line.Selected = true; + } + return lines; + } + + // Фильтр решает, что выбрано, а не что видно + static void ApplySelection(List lines, LineFilter filter) + { + if (filter == null) + { + return; + } + Dictionary strongest = new Dictionary(); + foreach (SpectralLine line in lines) + { + double current; + if (!strongest.TryGetValue(line.Nuclide, out current) || line.Intensity > current) + { + strongest[line.Nuclide] = line.Intensity; + } + } + foreach (SpectralLine line in lines) + { + double max; + strongest.TryGetValue(line.Nuclide, out max); + line.Selected = filter.Passes(line, max); + } + } + + // Оставить только N самых сильных линий каждого нуклида + public static void SelectTopPerNuclide(List lines, int count) + { + Dictionary> byNuclide = new Dictionary>(); + foreach (SpectralLine line in lines) + { + List list; + if (!byNuclide.TryGetValue(line.Nuclide, out list)) + { + list = new List(); + byNuclide[line.Nuclide] = list; + } + list.Add(line); + } + foreach (List list in byNuclide.Values) + { + list.Sort(delegate(SpectralLine a, SpectralLine b) { return b.Intensity.CompareTo(a.Intensity); }); + for (int i = 0; i < list.Count; i++) + { + list[i].Selected = i < count; + } + } + } + + // ChainOf в BecqMoni читает текст в ПОСЛЕДНИХ скобках имени как имя родительской + // цепочки. Суффикс рентгеновской линии («X KA1») шёл после скобок и разрывал признак + // цепочки — «Tl-208 (Th-232) X KA1» вместо «Tl-208 X KA1 (Th-232)», поэтому суффикс + // вставляется перед скобками родителя. + internal static string WithSuffix(string label, string suffix) + { + if (string.IsNullOrEmpty(label)) + { + return suffix; + } + int close = label.LastIndexOf(')'); + int open = close > 0 ? label.LastIndexOf('(', close - 1) : -1; + if (close == label.Length - 1 && open > 0) + { + return label.Substring(0, open).TrimEnd() + " " + suffix + + " (" + label.Substring(open + 1, close - open - 1) + ")"; + } + return label + " " + suffix; + } + + static string Fmt(double value) + { + return value.ToString("0.####", CultureInfo.InvariantCulture); + } + } +} diff --git a/BecquerelMonitor/RoiWizard/NuclideCatalog.cs b/BecquerelMonitor/RoiWizard/NuclideCatalog.cs new file mode 100644 index 0000000..cdb86ea --- /dev/null +++ b/BecquerelMonitor/RoiWizard/NuclideCatalog.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Xml.Serialization; + +namespace BecquerelMonitor.RoiWizard +{ + // Снимок ядерных данных IAEA Live Chart (ENSDF), встроенный в сборку ресурсом. + // API IAEA не отдаёт CORS и требует сети, поэтому данные лежат снимком; обновление — + // скриптом tools/export_catalog.py, он же проставляет Generated и пороги. + [XmlRoot("NuclideCatalog")] + public class NuclideCatalog + { + [XmlAttribute] + public string Generated { get; set; } + + // Пороги, ниже которых линии в снимок не попали, % + [XmlAttribute] + public double GammaMinIntensity { get; set; } + + [XmlAttribute] + public double XrayMinIntensity { get; set; } + + // Чем задана классификация семейств — окно показывает это под пояснением кода + [XmlAttribute] + public string FamilyStandard { get; set; } + + [XmlAttribute] + public string FamilyStandardRu { get; set; } + + [XmlArray("Families"), XmlArrayItem("Family")] + public List Families { get; set; } + + [XmlArray("Nuclides"), XmlArrayItem("Nuclide")] + public List Nuclides { get; set; } + + [XmlArray("Chains"), XmlArrayItem("Chain")] + public List Chains { get; set; } + + [XmlArray("XrfElements"), XmlArrayItem("Element")] + public List XrfElements { get; set; } + + Dictionary byName; + Dictionary byChainId; + Dictionary byElement; + + public NuclideCatalog() + { + this.Nuclides = new List(); + this.Chains = new List(); + this.XrfElements = new List(); + this.Families = new List(); + } + + const string ResourceName = "BecquerelMonitor.RoiWizard.nuclides.xml"; + + static NuclideCatalog instance; + static readonly object instanceLock = new object(); + + // Каталог читается один раз на процесс: снимок неизменен, а разбор XML на 121 нуклид + // стоит заметно дороже, чем удержание его в памяти. Блокировка нужна не ради UI-потока, + // а на случай, если каталог когда-нибудь понадобится фоновой обработке спектра: + // двойная загрузка дала бы два разных экземпляра и рассинхронизацию ссылок. + public static NuclideCatalog GetInstance() + { + if (instance == null) + { + lock (instanceLock) + { + if (instance == null) + { + instance = LoadEmbedded(); + } + } + } + return instance; + } + + public static NuclideCatalog LoadEmbedded() + { + Assembly assembly = Assembly.GetExecutingAssembly(); + using (Stream stream = assembly.GetManifestResourceStream(ResourceName)) + { + if (stream == null) + { + throw new FileNotFoundException( + "Resource " + ResourceName + " not found: add RoiWizard\\nuclides.xml " + + "to the project as an EmbeddedResource."); + } + return Load(stream); + } + } + + public static NuclideCatalog Load(Stream stream) + { + XmlSerializer serializer = new XmlSerializer(typeof(NuclideCatalog)); + NuclideCatalog catalog = (NuclideCatalog)serializer.Deserialize(stream); + catalog.BuildIndex(); + return catalog; + } + + void BuildIndex() + { + this.byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (CatalogNuclide nuclide in this.Nuclides) + { + this.byName[nuclide.Name] = nuclide; + } + this.byChainId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (CatalogChain chain in this.Chains) + { + this.byChainId[chain.Id] = chain; + } + this.byElement = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (XrfElement element in this.XrfElements) + { + this.byElement[element.Symbol] = element; + } + } + + public CatalogNuclide Find(string name) + { + CatalogNuclide result; + if (name != null && this.byName != null && this.byName.TryGetValue(name, out result)) + { + return result; + } + return null; + } + + public CatalogChain FindChain(string id) + { + CatalogChain result; + if (id != null && this.byChainId != null && this.byChainId.TryGetValue(id, out result)) + { + return result; + } + return null; + } + + public XrfElement FindElement(string symbol) + { + XrfElement result; + if (symbol != null && this.byElement != null && this.byElement.TryGetValue(symbol, out result)) + { + return result; + } + return null; + } + + // Корень ряда: по нему нуклид получает пометку «(Th-232)» в имени, а BecqMoni — + // признак цепочки (ChainOf в LibraryPeakFitter читает последние скобки имени). + public string ChainRoot(CatalogNuclide nuclide) + { + if (nuclide == null || string.IsNullOrEmpty(nuclide.Chain)) + { + return null; + } + CatalogChain chain = this.FindChain(nuclide.Chain); + return chain == null ? null : chain.Root; + } + + public CatalogFamily FindFamily(string code) + { + foreach (CatalogFamily family in this.Families) + { + if (string.Equals(family.Code, code, StringComparison.OrdinalIgnoreCase)) + { + return family; + } + } + return null; + } + + public IEnumerable ByFamily(string family) + { + foreach (CatalogNuclide nuclide in this.Nuclides) + { + if (nuclide.HasFamily(family)) + { + yield return nuclide; + } + } + } + } + + // Семейство нуклидов: код, человеческое название и пояснение на обоих языках. + // Классификация NORM/MED/IND/SNM — по ANSI N42.34; FISS/NAA/WASTE вне стандарта. + public class CatalogFamily + { + [XmlAttribute] + public string Code { get; set; } + + [XmlAttribute] + public string Title { get; set; } + + [XmlAttribute] + public string TitleRu { get; set; } + + [XmlAttribute] + public string Info { get; set; } + + [XmlAttribute] + public string InfoRu { get; set; } + } + + public class CatalogNuclide + { + [XmlAttribute] + public string Name { get; set; } + + // Идентификатор ряда распада (u238, th232, u235) либо пусто + [XmlAttribute] + public string Chain { get; set; } + + // Коды семейств через пробел: NORM MED IND SNM FISS NAA WASTE POPULAR. + // Классификация — ANSI N42.34 (идентификаторы RIID); FISS/NAA/WASTE вне стандарта. + [XmlAttribute] + public string Families { get; set; } + + [XmlAttribute] + public double HalfLifeSeconds { get; set; } + + [XmlAttribute] + public double HalfLifeYears { get; set; } + + // Готовая подпись периода полураспада («3,05 мин»), чтобы не форматировать заново + [XmlAttribute] + public string HalfLifeText { get; set; } + + [XmlArray("Gamma"), XmlArrayItem("Line")] + public List Gamma { get; set; } + + [XmlArray("Xray"), XmlArrayItem("Line")] + public List Xray { get; set; } + + public CatalogNuclide() + { + this.Gamma = new List(); + this.Xray = new List(); + } + + public bool HasFamily(string family) + { + if (string.IsNullOrEmpty(this.Families) || string.IsNullOrEmpty(family)) + { + return false; + } + foreach (string code in this.Families.Split(' ')) + { + if (string.Equals(code, family, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + public int LineCount + { + get { return this.Gamma.Count + this.Xray.Count; } + } + } + + public class CatalogGammaLine + { + [XmlAttribute("E")] + public double Energy { get; set; } + + // На 100 распадов нуклида, % + [XmlAttribute("I")] + public double Intensity { get; set; } + } + + public class CatalogXrayLine + { + [XmlAttribute("E")] + public double Energy { get; set; } + + [XmlAttribute("I")] + public double Intensity { get; set; } + + // Оболочка: KA1, KA2, KpB1, KB, L + [XmlAttribute("Shell")] + public string Shell { get; set; } + } + + public class CatalogChain + { + [XmlAttribute] + public string Id { get; set; } + + [XmlAttribute] + public string Root { get; set; } + + [XmlAttribute] + public string Title { get; set; } + + // Порядок членов ряда сверху вниз + [XmlArray("Members"), XmlArrayItem("Member")] + public List Members { get; set; } + + public CatalogChain() + { + this.Members = new List(); + } + } + + // Характеристический рентген материалов защиты и детектора: маркеры, не выходы. + // Интенсивности условные, Kα1 = 100. + public class XrfElement + { + [XmlAttribute] + public string Symbol { get; set; } + + [XmlAttribute] + public int Z { get; set; } + + [XmlAttribute] + public string Context { get; set; } + + // русское пояснение — форма показывает его при русской культуре интерфейса + [XmlAttribute] + public string ContextRu { get; set; } + + [XmlArray("Lines"), XmlArrayItem("Line")] + public List Lines { get; set; } + + public XrfElement() + { + this.Lines = new List(); + } + } + + public class XrfLine + { + [XmlAttribute("Label")] + public string Label { get; set; } + + [XmlAttribute("E")] + public double Energy { get; set; } + + [XmlAttribute("I")] + public double Intensity { get; set; } + } +} diff --git a/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs b/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs new file mode 100644 index 0000000..3bd594a --- /dev/null +++ b/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs @@ -0,0 +1,1164 @@ +namespace BecquerelMonitor.RoiWizard +{ + partial class RoiWizardForm + { + System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && components != null) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + // Разметка собрана руками, без дизайнера: три вкладки повторяют шаги веб-версии. + // Подписи заданы по-английски — базовый язык интерфейса BecqMoni; русский + // накладывается в RoiWizardForm.ApplyRussian() по текущей культуре UI. + void InitializeComponent() + { + this.tabs = new System.Windows.Forms.TabControl(); + this.tabSources = new System.Windows.Forms.TabPage(); + this.tabLines = new System.Windows.Forms.TabPage(); + this.tabExport = new System.Windows.Forms.TabPage(); + + // — шаг 1: поиск и группы + this.groupSearch = new System.Windows.Forms.GroupBox(); + this.textSearch = new System.Windows.Forms.TextBox(); + this.buttonAddSingle = new System.Windows.Forms.Button(); + this.buttonAddFamily = new System.Windows.Forms.Button(); + this.buttonAddChain = new System.Windows.Forms.Button(); + this.tableCatalog = new XPTable.Models.Table(); + this.columnModelCatalog = new XPTable.Models.ColumnModel(); + this.columnCatalogName = new XPTable.Models.TextColumn(); + this.columnCatalogFamilies = new XPTable.Models.TextColumn(); + this.columnCatalogHalfLife = new XPTable.Models.TextColumn(); + this.columnCatalogLines = new XPTable.Models.TextColumn(); + this.tableModelCatalog = new XPTable.Models.TableModel(); + + this.groupGroup = new System.Windows.Forms.GroupBox(); + this.comboGroup = new System.Windows.Forms.ComboBox(); + this.buttonGroupAll = new System.Windows.Forms.Button(); + this.buttonGroupFamily = new System.Windows.Forms.Button(); + this.buttonGroupChain = new System.Windows.Forms.Button(); + this.checkedGroup = new System.Windows.Forms.CheckedListBox(); + this.buttonFamilyInfo = new System.Windows.Forms.Button(); + this.labelFamilyInfo = new System.Windows.Forms.Label(); + this.labelSearchHint = new System.Windows.Forms.Label(); + this.panelPresets = new System.Windows.Forms.FlowLayoutPanel(); + this.labelXrfHint = new System.Windows.Forms.Label(); + this.labelGroupHint = new System.Windows.Forms.Label(); + this.groupXrf = new System.Windows.Forms.GroupBox(); + this.checkedXrf = new System.Windows.Forms.CheckedListBox(); + this.labelXrf = new System.Windows.Forms.Label(); + + this.groupSelected = new System.Windows.Forms.GroupBox(); + this.panelSelected = new System.Windows.Forms.FlowLayoutPanel(); + this.buttonClear = new System.Windows.Forms.Button(); + + // — шаг 2: разрешение, слияние, фильтры, таблица линий + this.groupResolution = new System.Windows.Forms.GroupBox(); + this.labelResolution = new System.Windows.Forms.Label(); + this.numResolution = new System.Windows.Forms.NumericUpDown(); + this.buttonFromSpectrum = new System.Windows.Forms.Button(); + this.labelCriterion = new System.Windows.Forms.Label(); + this.comboCriterion = new System.Windows.Forms.ComboBox(); + this.numFactor = new System.Windows.Forms.NumericUpDown(); + this.labelFactor = new System.Windows.Forms.Label(); + this.buttonMerge = new System.Windows.Forms.Button(); + this.buttonUnmerge = new System.Windows.Forms.Button(); + this.labelMergeInfo = new System.Windows.Forms.Label(); + + this.groupFilters = new System.Windows.Forms.GroupBox(); + this.checkIntensity = new System.Windows.Forms.CheckBox(); + this.numMinIntensity = new System.Windows.Forms.NumericUpDown(); + this.comboIntensityMode = new System.Windows.Forms.ComboBox(); + this.checkEnergy = new System.Windows.Forms.CheckBox(); + this.numMinEnergy = new System.Windows.Forms.NumericUpDown(); + this.numMaxEnergy = new System.Windows.Forms.NumericUpDown(); + this.checkHalfLife = new System.Windows.Forms.CheckBox(); + this.numMinHalfLife = new System.Windows.Forms.NumericUpDown(); + this.comboMinHalfLifeUnit = new System.Windows.Forms.ComboBox(); + this.numMaxHalfLife = new System.Windows.Forms.NumericUpDown(); + this.comboMaxHalfLifeUnit = new System.Windows.Forms.ComboBox(); + this.checkHideUnselected = new System.Windows.Forms.CheckBox(); + this.labelTypes = new System.Windows.Forms.Label(); + this.checkTypeGamma = new System.Windows.Forms.CheckBox(); + this.checkTypeXray = new System.Windows.Forms.CheckBox(); + this.checkTypeXrf = new System.Windows.Forms.CheckBox(); + this.checkTypeSecondary = new System.Windows.Forms.CheckBox(); + this.checkEquilibrium = new System.Windows.Forms.CheckBox(); + this.groupSecondary = new System.Windows.Forms.GroupBox(); + this.labelSecondaryMin = new System.Windows.Forms.Label(); + this.numSecondaryMin = new System.Windows.Forms.NumericUpDown(); + this.checkSecBackscatter = new System.Windows.Forms.CheckBox(); + this.checkSecComptonEdge = new System.Windows.Forms.CheckBox(); + this.checkSecSingleEscape = new System.Windows.Forms.CheckBox(); + this.checkSecDoubleEscape = new System.Windows.Forms.CheckBox(); + this.checkSecIodine = new System.Windows.Forms.CheckBox(); + this.checkSecAnnihilation = new System.Windows.Forms.CheckBox(); + this.checkSecSum = new System.Windows.Forms.CheckBox(); + this.checkSecPileUp = new System.Windows.Forms.CheckBox(); + this.buttonGenerateSecondary = new System.Windows.Forms.Button(); + this.groupNear = new System.Windows.Forms.GroupBox(); + this.labelNearEnergy = new System.Windows.Forms.Label(); + this.numNearEnergy = new System.Windows.Forms.NumericUpDown(); + this.labelNearWindow = new System.Windows.Forms.Label(); + this.numNearWindow = new System.Windows.Forms.NumericUpDown(); + this.labelNearIntensity = new System.Windows.Forms.Label(); + this.numNearIntensity = new System.Windows.Forms.NumericUpDown(); + this.labelNearHalfLife = new System.Windows.Forms.Label(); + this.numNearHalfLife = new System.Windows.Forms.NumericUpDown(); + this.comboNearHalfLifeUnit = new System.Windows.Forms.ComboBox(); + this.buttonNearSearch = new System.Windows.Forms.Button(); + this.labelNearHint = new System.Windows.Forms.Label(); + this.tableNear = new XPTable.Models.Table(); + this.columnModelNear = new XPTable.Models.ColumnModel(); + this.columnNearDelta = new XPTable.Models.TextColumn(); + this.columnNearName = new XPTable.Models.TextColumn(); + this.columnNearEnergy = new XPTable.Models.TextColumn(); + this.columnNearIntensity = new XPTable.Models.TextColumn(); + this.columnNearType = new XPTable.Models.TextColumn(); + this.columnNearHalfLife = new XPTable.Models.TextColumn(); + this.columnNearAdd = new XPTable.Models.ButtonColumn(); + this.columnNearFill = new XPTable.Models.TextColumn(); + this.tableModelNear = new XPTable.Models.TableModel(); + this.buttonSelectAll = new System.Windows.Forms.Button(); + this.buttonSelectNone = new System.Windows.Forms.Button(); + this.numTopN = new System.Windows.Forms.NumericUpDown(); + this.labelTopN = new System.Windows.Forms.Label(); + this.buttonSelectTop = new System.Windows.Forms.Button(); + + this.tableLines = new XPTable.Models.Table(); + this.columnModelLines = new XPTable.Models.ColumnModel(); + this.columnLineSelected = new XPTable.Models.CheckBoxColumn(); + this.columnLineName = new XPTable.Models.TextColumn(); + this.columnLineEnergy = new XPTable.Models.TextColumn(); + this.columnLineIntensity = new XPTable.Models.TextColumn(); + this.columnLineRelative = new XPTable.Models.TextColumn(); + this.columnLineHalfLife = new XPTable.Models.TextColumn(); + this.columnLineType = new XPTable.Models.TextColumn(); + this.tableModelLines = new XPTable.Models.TableModel(); + + // — шаг 3: оформление и экспорт + this.groupStyle = new System.Windows.Forms.GroupBox(); + this.labelStyle = new System.Windows.Forms.Label(); + this.comboStyle = new System.Windows.Forms.ComboBox(); + this.labelWidth = new System.Windows.Forms.Label(); + this.comboWidthMode = new System.Windows.Forms.ComboBox(); + this.numZonePercent = new System.Windows.Forms.NumericUpDown(); + this.numZoneFactor = new System.Windows.Forms.NumericUpDown(); + this.labelColors = new System.Windows.Forms.Label(); + this.buttonColorByChain = new System.Windows.Forms.Button(); + this.buttonColorByNuclide = new System.Windows.Forms.Button(); + this.panelColors = new System.Windows.Forms.FlowLayoutPanel(); + + this.groupExport = new System.Windows.Forms.GroupBox(); + this.labelConfigName = new System.Windows.Forms.Label(); + this.textConfigName = new System.Windows.Forms.TextBox(); + this.buttonCreateRoi = new System.Windows.Forms.Button(); + this.buttonPreview = new System.Windows.Forms.Button(); + this.textPreview = new System.Windows.Forms.TextBox(); + this.labelSetName = new System.Windows.Forms.Label(); + this.textSetName = new System.Windows.Forms.TextBox(); + this.labelAnchor = new System.Windows.Forms.Label(); + this.comboAnchor = new System.Windows.Forms.ComboBox(); + this.buttonCreateSet = new System.Windows.Forms.Button(); + this.checkFullSet = new System.Windows.Forms.CheckBox(); + this.labelAnchorCount = new System.Windows.Forms.Label(); + this.numAnchors = new System.Windows.Forms.NumericUpDown(); + this.listIssues = new System.Windows.Forms.ListBox(); + this.labelIssues = new System.Windows.Forms.Label(); + + this.statusStrip = new System.Windows.Forms.StatusStrip(); + this.statusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + this.buttonHelp = new System.Windows.Forms.ToolStripButton(); + this.buttonStepPrev = new System.Windows.Forms.ToolStripButton(); + this.buttonStepNext = new System.Windows.Forms.ToolStripButton(); + + ((System.ComponentModel.ISupportInitialize)(this.numResolution)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numFactor)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numMinIntensity)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numMinEnergy)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numMaxEnergy)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numTopN)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numMinHalfLife)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numMaxHalfLife)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numSecondaryMin)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numNearEnergy)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numNearWindow)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numNearIntensity)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numNearHalfLife)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numZonePercent)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numZoneFactor)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.numAnchors)).BeginInit(); + this.SuspendLayout(); + + // ─── вкладки ─────────────────────────────────────────────────── + // размер задаётся до наполнения страниц: иначе дети запомнят расстояния + // до краёв страницы размером 200x100 по умолчанию и на реальном разъедутся + this.tabs.Size = new System.Drawing.Size(1180, 598); + this.tabs.Dock = System.Windows.Forms.DockStyle.Fill; + this.tabs.Controls.Add(this.tabSources); + this.tabs.Controls.Add(this.tabLines); + this.tabs.Controls.Add(this.tabExport); + + // размер каждой странице явно: TabControl размечает только выбранную, + // остальные остаются 200x100 и портят привязки своих детей + this.tabSources.Size = new System.Drawing.Size(1172, 572); + this.tabLines.Size = new System.Drawing.Size(1172, 572); + this.tabExport.Size = new System.Drawing.Size(1172, 572); + this.tabSources.Text = RoiWizardStrings.tabSources_Text; + this.tabSources.Padding = new System.Windows.Forms.Padding(6); + this.tabSources.UseVisualStyleBackColor = true; + this.tabLines.Text = RoiWizardStrings.tabLines_Text; + this.tabLines.Padding = new System.Windows.Forms.Padding(6); + this.tabLines.UseVisualStyleBackColor = true; + this.tabExport.Text = RoiWizardStrings.tabExport_Text; + this.tabExport.Padding = new System.Windows.Forms.Padding(6); + this.tabExport.UseVisualStyleBackColor = true; + + // ─── шаг 1 ───────────────────────────────────────────────────── + this.groupSearch.Text = RoiWizardStrings.groupSearch_Text; + this.groupSearch.Location = new System.Drawing.Point(8, 6); + this.groupSearch.Size = new System.Drawing.Size(376, 340); + this.groupSearch.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; + + this.textSearch.Location = new System.Drawing.Point(8, 20); + this.textSearch.Size = new System.Drawing.Size(360, 21); + this.textSearch.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.buttonAddSingle.Text = RoiWizardStrings.buttonAddSingle_Text; + this.buttonAddSingle.Location = new System.Drawing.Point(8, 48); + this.buttonAddSingle.Size = new System.Drawing.Size(104, 25); + this.buttonAddFamily.Text = RoiWizardStrings.buttonAddFamily_Text; + this.buttonAddFamily.Location = new System.Drawing.Point(118, 48); + this.buttonAddFamily.Size = new System.Drawing.Size(122, 25); + this.buttonAddChain.Text = RoiWizardStrings.buttonAddChain_Text; + this.buttonAddChain.Location = new System.Drawing.Point(246, 48); + this.buttonAddChain.Size = new System.Drawing.Size(122, 25); + + this.tableCatalog.Location = new System.Drawing.Point(8, 80); + this.tableCatalog.Size = new System.Drawing.Size(360, 190); + this.tableCatalog.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | + System.Windows.Forms.AnchorStyles.Right; + this.tableCatalog.BorderColor = System.Drawing.Color.Black; + this.tableCatalog.ColumnModel = this.columnModelCatalog; + this.tableCatalog.FullRowSelect = true; + this.tableCatalog.GridLines = XPTable.Models.GridLines.Rows; + this.tableCatalog.TableModel = this.tableModelCatalog; + this.tableCatalog.HeaderRenderer = new CenteredHeaderRenderer(); + // строка списка нуклидов повторяет .nuc на странице: имя, бейджи семейств, + // приглушённый хвост «T½ γN XN». Высота 18 px — line-height 16 плюс padding. + this.tableModelCatalog.RowHeight = 18; + this.columnCatalogName.Editable = false; // таблицы только для чтения: правки идут через контролы + this.columnCatalogName.Text = RoiWizardStrings.columnCatalogName_Text; + this.columnCatalogName.Width = 72; + this.columnCatalogFamilies.Editable = false; + this.columnCatalogFamilies.Text = RoiWizardStrings.columnCatalogFamilies_Text; + this.columnCatalogFamilies.Width = 132; + this.columnCatalogFamilies.Renderer = new FamilyBadgeCellRenderer(); + this.columnCatalogHalfLife.Alignment = XPTable.Models.ColumnAlignment.Right; + this.columnCatalogHalfLife.Editable = false; + this.columnCatalogHalfLife.Text = "T½"; + this.columnCatalogHalfLife.Width = 78; + this.columnCatalogHalfLife.Renderer = new HintCellRenderer(); + this.columnCatalogLines.Editable = false; + this.columnCatalogLines.Text = RoiWizardStrings.columnCatalogLines_Text; + this.columnCatalogLines.Width = 56; + this.columnCatalogLines.Renderer = new LineCountCellRenderer(); + this.columnModelCatalog.Columns.AddRange(new XPTable.Models.Column[] { + this.columnCatalogName, this.columnCatalogFamilies, + this.columnCatalogHalfLife, this.columnCatalogLines }); + + this.groupSearch.Controls.Add(this.textSearch); + this.groupSearch.Controls.Add(this.buttonAddSingle); + this.groupSearch.Controls.Add(this.buttonAddFamily); + this.groupSearch.Controls.Add(this.buttonAddChain); + this.labelSearchHint.Text = RoiWizardStrings.labelSearchHint_Text; + this.labelSearchHint.Location = new System.Drawing.Point(8, 274); + this.labelSearchHint.Size = new System.Drawing.Size(360, 16); + this.labelSearchHint.Anchor = System.Windows.Forms.AnchorStyles.Bottom | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + // строка пресетов: готовые наборы одним щелчком, как .presets на странице + this.panelPresets.Location = new System.Drawing.Point(6, 292); + this.panelPresets.Size = new System.Drawing.Size(364, 44); + this.panelPresets.WrapContents = true; // .presets переносится: flex-wrap:wrap + this.panelPresets.AutoScroll = false; + this.panelPresets.Anchor = System.Windows.Forms.AnchorStyles.Bottom | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.groupSearch.Controls.Add(this.tableCatalog); + this.groupSearch.Controls.Add(this.labelSearchHint); + this.groupSearch.Controls.Add(this.panelPresets); + + this.groupGroup.Text = RoiWizardStrings.groupGroup_Text; + this.groupGroup.Location = new System.Drawing.Point(392, 6); + this.groupGroup.Size = new System.Drawing.Size(376, 340); + this.groupGroup.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; + this.comboGroup.Location = new System.Drawing.Point(8, 22); + this.comboGroup.Size = new System.Drawing.Size(330, 23); + this.comboGroup.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.comboGroup.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.buttonFamilyInfo.Text = "i"; + this.buttonFamilyInfo.Location = new System.Drawing.Point(342, 22); + this.buttonFamilyInfo.Size = new System.Drawing.Size(26, 23); + this.buttonFamilyInfo.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Right; + // словарик кодов — поверх списка, чтобы не двигать вёрстку (.infoPop) + this.labelFamilyInfo.Location = new System.Drawing.Point(8, 47); + this.labelFamilyInfo.Size = new System.Drawing.Size(360, 158); + this.labelFamilyInfo.BackColor = System.Drawing.Color.FromArgb(255, 255, 225); + this.labelFamilyInfo.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.labelFamilyInfo.Padding = new System.Windows.Forms.Padding(6, 5, 6, 5); + this.labelFamilyInfo.Visible = false; + this.labelFamilyInfo.Cursor = System.Windows.Forms.Cursors.Hand; + this.labelFamilyInfo.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.buttonGroupAll.Text = RoiWizardStrings.buttonGroupAll_Text; + this.buttonGroupAll.Location = new System.Drawing.Point(8, 50); + this.buttonGroupAll.Size = new System.Drawing.Size(104, 25); + this.buttonGroupFamily.Text = RoiWizardStrings.buttonGroupFamily_Text; + this.buttonGroupFamily.Location = new System.Drawing.Point(118, 50); + this.buttonGroupFamily.Size = new System.Drawing.Size(140, 25); + this.buttonGroupChain.Text = RoiWizardStrings.buttonGroupChain_Text; + this.buttonGroupChain.Location = new System.Drawing.Point(264, 50); + this.buttonGroupChain.Size = new System.Drawing.Size(104, 25); + this.checkedGroup.Location = new System.Drawing.Point(8, 82); + this.checkedGroup.Size = new System.Drawing.Size(360, 230); + this.checkedGroup.CheckOnClick = true; + this.checkedGroup.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | + System.Windows.Forms.AnchorStyles.Right; + this.labelGroupHint.Text = "Tick a nuclide - the buttons apply to it."; + this.labelGroupHint.Location = new System.Drawing.Point(8, 316); + this.labelGroupHint.Size = new System.Drawing.Size(360, 18); + this.labelGroupHint.Anchor = System.Windows.Forms.AnchorStyles.Bottom | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + + this.groupXrf.Text = RoiWizardStrings.groupXrf_Text; + this.groupXrf.Location = new System.Drawing.Point(776, 6); + this.groupXrf.Size = new System.Drawing.Size(396, 340); + this.groupXrf.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | + System.Windows.Forms.AnchorStyles.Right; + this.labelXrf.Text = RoiWizardStrings.labelXrf_Text; + this.labelXrf.Location = new System.Drawing.Point(8, 20); + this.labelXrf.AutoSize = true; + this.checkedXrf.Location = new System.Drawing.Point(8, 44); + this.checkedXrf.Size = new System.Drawing.Size(380, 258); + this.checkedXrf.HorizontalScrollbar = true; + this.labelXrfHint.Text = RoiWizardStrings.labelXrfHint_Text; + this.labelXrfHint.Location = new System.Drawing.Point(8, 306); + this.labelXrfHint.Size = new System.Drawing.Size(380, 28); + this.labelXrfHint.Anchor = System.Windows.Forms.AnchorStyles.Bottom | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.checkedXrf.CheckOnClick = true; + this.checkedXrf.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | + System.Windows.Forms.AnchorStyles.Right; + this.groupGroup.Controls.Add(this.labelFamilyInfo); + this.groupGroup.Controls.Add(this.comboGroup); + this.groupGroup.Controls.Add(this.buttonFamilyInfo); + this.groupGroup.Controls.Add(this.buttonGroupAll); + this.groupGroup.Controls.Add(this.buttonGroupFamily); + this.groupGroup.Controls.Add(this.buttonGroupChain); + this.groupGroup.Controls.Add(this.checkedGroup); + this.groupGroup.Controls.Add(this.labelGroupHint); + this.groupXrf.Controls.Add(this.labelXrf); + this.groupXrf.Controls.Add(this.checkedXrf); + this.groupXrf.Controls.Add(this.labelXrfHint); + + this.groupSelected.Text = RoiWizardStrings.groupSelected_Text; + this.groupSelected.Location = new System.Drawing.Point(8, 352); + this.groupSelected.Size = new System.Drawing.Size(1156, 72); + this.groupSelected.Anchor = System.Windows.Forms.AnchorStyles.Bottom | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.panelSelected.Location = new System.Drawing.Point(8, 18); + this.panelSelected.Size = new System.Drawing.Size(1038, 48); + this.panelSelected.AutoScroll = true; + this.panelSelected.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | + System.Windows.Forms.AnchorStyles.Right; + this.buttonClear.Text = RoiWizardStrings.buttonClear_Text; + this.buttonClear.Location = new System.Drawing.Point(1054, 18); + this.buttonClear.Size = new System.Drawing.Size(94, 25); + this.buttonClear.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Right; + this.groupSelected.Controls.Add(this.panelSelected); + this.groupSelected.Controls.Add(this.buttonClear); + + this.tabSources.Controls.Add(this.groupSearch); + this.tabSources.Controls.Add(this.groupGroup); + this.tabSources.Controls.Add(this.groupXrf); + this.tabSources.Controls.Add(this.groupSelected); + + // ─── шаг 2 ───────────────────────────────────────────────────── + this.groupResolution.Text = RoiWizardStrings.groupResolution_Text; + this.groupResolution.Location = new System.Drawing.Point(8, 6); + this.groupResolution.Size = new System.Drawing.Size(1156, 80); + this.groupResolution.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.labelResolution.Text = RoiWizardStrings.labelResolution_Text; + this.labelResolution.Location = new System.Drawing.Point(8, 23); + this.labelResolution.AutoSize = true; + this.numResolution.Location = new System.Drawing.Point(102, 20); + this.numResolution.Size = new System.Drawing.Size(56, 21); + this.numResolution.DecimalPlaces = 1; + this.numResolution.Increment = new decimal(new int[] { 1, 0, 0, 65536 }); + this.numResolution.Minimum = new decimal(new int[] { 1, 0, 0, 65536 }); + this.numResolution.Maximum = new decimal(new int[] { 30, 0, 0, 0 }); + this.numResolution.Value = new decimal(new int[] { 75, 0, 0, 65536 }); + this.buttonFromSpectrum.Text = RoiWizardStrings.buttonFromSpectrum_Text; + this.buttonFromSpectrum.Location = new System.Drawing.Point(164, 19); + this.buttonFromSpectrum.Size = new System.Drawing.Size(104, 23); + this.labelCriterion.Text = RoiWizardStrings.labelCriterion_Text; + this.labelCriterion.Location = new System.Drawing.Point(276, 23); + this.labelCriterion.AutoSize = true; + this.comboCriterion.Location = new System.Drawing.Point(358, 20); + this.comboCriterion.Size = new System.Drawing.Size(300, 23); + this.comboCriterion.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.numFactor.Location = new System.Drawing.Point(666, 20); + this.numFactor.Size = new System.Drawing.Size(56, 21); + this.numFactor.DecimalPlaces = 2; + this.numFactor.Increment = new decimal(new int[] { 5, 0, 0, 131072 }); + this.numFactor.Minimum = new decimal(new int[] { 5, 0, 0, 131072 }); + this.numFactor.Maximum = new decimal(new int[] { 10, 0, 0, 0 }); + this.numFactor.Value = new decimal(new int[] { 85, 0, 0, 131072 }); + this.labelFactor.Text = RoiWizardStrings.labelFactor_Text; + this.labelFactor.Location = new System.Drawing.Point(728, 23); + this.labelFactor.AutoSize = true; + this.buttonMerge.Text = RoiWizardStrings.buttonMerge_Text; + this.buttonMerge.Location = new System.Drawing.Point(838, 19); + this.buttonMerge.Size = new System.Drawing.Size(150, 25); + this.buttonUnmerge.Text = RoiWizardStrings.buttonUnmerge_Text; + this.buttonUnmerge.Location = new System.Drawing.Point(996, 19); + this.buttonUnmerge.Size = new System.Drawing.Size(158, 25); + this.groupResolution.Controls.Add(this.labelResolution); + this.groupResolution.Controls.Add(this.numResolution); + this.groupResolution.Controls.Add(this.buttonFromSpectrum); + this.groupResolution.Controls.Add(this.labelCriterion); + this.groupResolution.Controls.Add(this.comboCriterion); + this.groupResolution.Controls.Add(this.numFactor); + this.groupResolution.Controls.Add(this.labelFactor); + this.groupResolution.Controls.Add(this.buttonMerge); + this.groupResolution.Controls.Add(this.buttonUnmerge); + this.groupResolution.Controls.Add(this.labelMergeInfo); + + this.labelMergeInfo.Location = new System.Drawing.Point(10, 52); + this.labelMergeInfo.Size = new System.Drawing.Size(1136, 18); + this.labelMergeInfo.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + + this.groupFilters.Text = RoiWizardStrings.groupFilters_Text; + this.groupFilters.Location = new System.Drawing.Point(8, 92); + this.groupFilters.Size = new System.Drawing.Size(1156, 106); + this.groupFilters.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.checkIntensity.Text = RoiWizardStrings.checkIntensity_Text; + this.checkIntensity.Location = new System.Drawing.Point(8, 21); + this.checkIntensity.Size = new System.Drawing.Size(124, 20); + this.numMinIntensity.Location = new System.Drawing.Point(136, 20); + this.numMinIntensity.Size = new System.Drawing.Size(52, 21); + this.numMinIntensity.DecimalPlaces = 1; + this.numMinIntensity.Maximum = new decimal(new int[] { 100, 0, 0, 0 }); + this.numMinIntensity.Value = new decimal(new int[] { 3, 0, 0, 0 }); + this.comboIntensityMode.Location = new System.Drawing.Point(196, 20); + this.comboIntensityMode.Size = new System.Drawing.Size(292, 23); + this.comboIntensityMode.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.checkEnergy.Text = RoiWizardStrings.checkEnergy_Text; + this.checkEnergy.Location = new System.Drawing.Point(500, 21); + this.checkEnergy.Size = new System.Drawing.Size(92, 20); + this.numMinEnergy.Location = new System.Drawing.Point(596, 20); + this.numMinEnergy.Size = new System.Drawing.Size(60, 21); + this.numMinEnergy.Maximum = new decimal(new int[] { 10000, 0, 0, 0 }); + this.numMinEnergy.Value = new decimal(new int[] { 10, 0, 0, 0 }); + this.numMaxEnergy.Location = new System.Drawing.Point(662, 20); + this.numMaxEnergy.Size = new System.Drawing.Size(60, 21); + this.numMaxEnergy.Maximum = new decimal(new int[] { 10000, 0, 0, 0 }); + this.numMaxEnergy.Value = new decimal(new int[] { 3000, 0, 0, 0 }); + // фильтр по периоду полураспада — как в вебе: два поля со своими единицами + this.checkHalfLife.Text = RoiWizardStrings.checkHalfLife_Text; + this.checkHalfLife.Location = new System.Drawing.Point(738, 21); + this.checkHalfLife.Size = new System.Drawing.Size(40, 20); + this.numMinHalfLife.Location = new System.Drawing.Point(782, 20); + this.numMinHalfLife.Size = new System.Drawing.Size(52, 21); + this.numMinHalfLife.DecimalPlaces = 2; + this.numMinHalfLife.Maximum = new decimal(new int[] { 1000000, 0, 0, 0 }); + this.numMinHalfLife.Value = new decimal(new int[] { 1, 0, 0, 0 }); + this.comboMinHalfLifeUnit.Location = new System.Drawing.Point(840, 20); + this.comboMinHalfLifeUnit.Size = new System.Drawing.Size(56, 21); + this.comboMinHalfLifeUnit.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.numMaxHalfLife.Location = new System.Drawing.Point(906, 20); + this.numMaxHalfLife.Size = new System.Drawing.Size(52, 21); + this.numMaxHalfLife.DecimalPlaces = 2; + this.numMaxHalfLife.Maximum = new decimal(new int[] { 1000000, 0, 0, 0 }); + this.comboMaxHalfLifeUnit.Location = new System.Drawing.Point(964, 20); + this.comboMaxHalfLifeUnit.Size = new System.Drawing.Size(56, 21); + this.comboMaxHalfLifeUnit.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + + this.checkHideUnselected.Text = RoiWizardStrings.checkHideUnselected_Text; + this.checkHideUnselected.Location = new System.Drawing.Point(626, 48); + this.checkHideUnselected.Size = new System.Drawing.Size(180, 20); + + this.checkEquilibrium.Text = RoiWizardStrings.checkEquilibrium_Text; + this.labelTypes.Text = RoiWizardStrings.labelTypes_Text; + this.labelTypes.Location = new System.Drawing.Point(8, 75); + this.labelTypes.Size = new System.Drawing.Size(66, 16); + this.checkTypeGamma.Text = "γ"; + this.checkTypeGamma.Location = new System.Drawing.Point(78, 73); + this.checkTypeGamma.Size = new System.Drawing.Size(40, 20); + this.checkTypeGamma.Checked = true; + this.checkTypeXray.Text = RoiWizardStrings.checkTypeXray_Text; + this.checkTypeXray.Location = new System.Drawing.Point(120, 73); + this.checkTypeXray.Size = new System.Drawing.Size(90, 20); + this.checkTypeXray.Checked = true; + this.checkTypeXrf.Text = RoiWizardStrings.checkTypeXrf_Text; + this.checkTypeXrf.Location = new System.Drawing.Point(212, 73); + this.checkTypeXrf.Size = new System.Drawing.Size(60, 20); + this.checkTypeXrf.Checked = true; + this.checkTypeSecondary.Text = RoiWizardStrings.checkTypeSecondary_Text; + this.checkTypeSecondary.Location = new System.Drawing.Point(274, 73); + this.checkTypeSecondary.Size = new System.Drawing.Size(96, 20); + this.checkTypeSecondary.Checked = true; + + this.checkEquilibrium.Location = new System.Drawing.Point(402, 73); + this.checkEquilibrium.Size = new System.Drawing.Size(560, 20); + this.checkEquilibrium.Checked = true; + this.buttonSelectAll.Text = RoiWizardStrings.buttonSelectAll_Text; + this.buttonSelectAll.Location = new System.Drawing.Point(8, 46); + this.buttonSelectAll.Size = new System.Drawing.Size(140, 25); + this.buttonSelectNone.Text = RoiWizardStrings.buttonSelectNone_Text; + this.buttonSelectNone.Location = new System.Drawing.Point(152, 46); + this.buttonSelectNone.Size = new System.Drawing.Size(130, 25); + this.labelTopN.Text = RoiWizardStrings.labelTopN_Text; + this.labelTopN.Location = new System.Drawing.Point(292, 50); + this.labelTopN.Size = new System.Drawing.Size(136, 18); + this.numTopN.Location = new System.Drawing.Point(432, 47); + this.numTopN.Size = new System.Drawing.Size(48, 21); + this.numTopN.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); + this.numTopN.Value = new decimal(new int[] { 5, 0, 0, 0 }); + this.buttonSelectTop.Text = RoiWizardStrings.buttonSelectTop_Text; + this.buttonSelectTop.Location = new System.Drawing.Point(488, 46); + this.buttonSelectTop.Size = new System.Drawing.Size(126, 25); + // Панель вторичных пиков повторяет блок веб-версии: порог по родительской + // линии, восемь видов особенностей и кнопка расчёта. Расчёт по кнопке, а не + // автоматически: маркеры добавляются к текущему набору линий. + this.groupSecondary.Text = RoiWizardStrings.groupSecondary_Text; + this.groupSecondary.Location = new System.Drawing.Point(8, 204); + this.groupSecondary.Size = new System.Drawing.Size(1156, 78); + this.groupSecondary.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.labelSecondaryMin.Text = RoiWizardStrings.labelSecondaryMin_Text; + this.labelSecondaryMin.Location = new System.Drawing.Point(8, 26); + this.labelSecondaryMin.Size = new System.Drawing.Size(140, 18); + this.numSecondaryMin.Location = new System.Drawing.Point(152, 23); + this.numSecondaryMin.Size = new System.Drawing.Size(56, 23); + this.numSecondaryMin.DecimalPlaces = 1; + this.numSecondaryMin.Maximum = new decimal(new int[] { 100, 0, 0, 0 }); + this.numSecondaryMin.Value = new decimal(new int[] { 10, 0, 0, 0 }); + this.checkSecBackscatter.Text = RoiWizardStrings.checkSecBackscatter_Text; + this.checkSecBackscatter.Location = new System.Drawing.Point(224, 24); + this.checkSecBackscatter.Size = new System.Drawing.Size(150, 20); + this.checkSecBackscatter.Checked = true; + this.checkSecComptonEdge.Text = RoiWizardStrings.checkSecComptonEdge_Text; + this.checkSecComptonEdge.Location = new System.Drawing.Point(380, 24); + this.checkSecComptonEdge.Size = new System.Drawing.Size(166, 20); + this.checkSecSingleEscape.Text = RoiWizardStrings.checkSecSingleEscape_Text; + this.checkSecSingleEscape.Location = new System.Drawing.Point(552, 24); + this.checkSecSingleEscape.Size = new System.Drawing.Size(146, 20); + this.checkSecSingleEscape.Checked = true; + this.checkSecDoubleEscape.Text = RoiWizardStrings.checkSecDoubleEscape_Text; + this.checkSecDoubleEscape.Location = new System.Drawing.Point(704, 24); + this.checkSecDoubleEscape.Size = new System.Drawing.Size(156, 20); + this.checkSecDoubleEscape.Checked = true; + this.checkSecIodine.Text = RoiWizardStrings.checkSecIodine_Text; + this.checkSecIodine.Location = new System.Drawing.Point(224, 48); + this.checkSecIodine.Size = new System.Drawing.Size(190, 20); + this.checkSecAnnihilation.Text = RoiWizardStrings.checkSecAnnihilation_Text; + this.checkSecAnnihilation.Location = new System.Drawing.Point(420, 48); + this.checkSecAnnihilation.Size = new System.Drawing.Size(146, 20); + this.checkSecSum.Text = RoiWizardStrings.checkSecSum_Text; + this.checkSecSum.Location = new System.Drawing.Point(572, 48); + this.checkSecSum.Size = new System.Drawing.Size(180, 20); + this.checkSecPileUp.Text = RoiWizardStrings.checkSecPileUp_Text; + this.checkSecPileUp.Location = new System.Drawing.Point(758, 48); + this.checkSecPileUp.Size = new System.Drawing.Size(120, 20); + this.buttonGenerateSecondary.Text = RoiWizardStrings.buttonGenerateSecondary_Text; + this.buttonGenerateSecondary.Location = new System.Drawing.Point(940, 22); + this.buttonGenerateSecondary.Size = new System.Drawing.Size(150, 25); + this.groupSecondary.Controls.Add(this.labelSecondaryMin); + this.groupSecondary.Controls.Add(this.numSecondaryMin); + this.groupSecondary.Controls.Add(this.checkSecBackscatter); + this.groupSecondary.Controls.Add(this.checkSecComptonEdge); + this.groupSecondary.Controls.Add(this.checkSecSingleEscape); + this.groupSecondary.Controls.Add(this.checkSecDoubleEscape); + this.groupSecondary.Controls.Add(this.checkSecIodine); + this.groupSecondary.Controls.Add(this.checkSecAnnihilation); + this.groupSecondary.Controls.Add(this.checkSecSum); + this.groupSecondary.Controls.Add(this.checkSecPileUp); + this.groupSecondary.Controls.Add(this.buttonGenerateSecondary); + this.groupFilters.Controls.Add(this.checkIntensity); + this.groupFilters.Controls.Add(this.numMinIntensity); + this.groupFilters.Controls.Add(this.comboIntensityMode); + this.groupFilters.Controls.Add(this.checkEnergy); + this.groupFilters.Controls.Add(this.numMinEnergy); + this.groupFilters.Controls.Add(this.numMaxEnergy); + this.groupFilters.Controls.Add(this.checkHalfLife); + this.groupFilters.Controls.Add(this.numMinHalfLife); + this.groupFilters.Controls.Add(this.comboMinHalfLifeUnit); + this.groupFilters.Controls.Add(this.numMaxHalfLife); + this.groupFilters.Controls.Add(this.comboMaxHalfLifeUnit); + this.groupFilters.Controls.Add(this.checkHideUnselected); + this.groupFilters.Controls.Add(this.labelTypes); + this.groupFilters.Controls.Add(this.checkTypeGamma); + this.groupFilters.Controls.Add(this.checkTypeXray); + this.groupFilters.Controls.Add(this.checkTypeXrf); + this.groupFilters.Controls.Add(this.checkTypeSecondary); + this.groupFilters.Controls.Add(this.checkEquilibrium); + this.groupFilters.Controls.Add(this.buttonSelectAll); + this.groupFilters.Controls.Add(this.buttonSelectNone); + this.groupFilters.Controls.Add(this.numTopN); + this.groupFilters.Controls.Add(this.labelTopN); + this.groupFilters.Controls.Add(this.buttonSelectTop); + this.tabLines.Controls.Add(this.groupSecondary); + + this.groupNear.Text = RoiWizardStrings.groupNear_Text; + this.groupNear.Location = new System.Drawing.Point(8, 288); + this.groupNear.Size = new System.Drawing.Size(1156, 178); + this.groupNear.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.labelNearEnergy.Text = RoiWizardStrings.labelNearEnergy_Text; + this.labelNearEnergy.Location = new System.Drawing.Point(8, 26); + this.labelNearEnergy.Size = new System.Drawing.Size(90, 18); + this.numNearEnergy.Location = new System.Drawing.Point(102, 23); + this.numNearEnergy.Size = new System.Drawing.Size(72, 23); + this.numNearEnergy.DecimalPlaces = 2; + this.numNearEnergy.Maximum = new decimal(new int[] { 10000, 0, 0, 0 }); + this.numNearEnergy.Value = new decimal(new int[] { 362, 0, 0, 0 }); + this.labelNearWindow.Text = RoiWizardStrings.labelNearWindow_Text; + this.labelNearWindow.Location = new System.Drawing.Point(186, 26); + this.labelNearWindow.Size = new System.Drawing.Size(72, 18); + this.numNearWindow.Location = new System.Drawing.Point(262, 23); + this.numNearWindow.Size = new System.Drawing.Size(60, 23); + this.numNearWindow.DecimalPlaces = 1; + this.numNearWindow.Minimum = new decimal(new int[] { 5, 0, 0, 65536 }); + this.numNearWindow.Maximum = new decimal(new int[] { 1000, 0, 0, 0 }); + this.numNearWindow.Value = new decimal(new int[] { 10, 0, 0, 0 }); + this.labelNearIntensity.Text = RoiWizardStrings.labelNearIntensity_Text; + this.labelNearIntensity.Location = new System.Drawing.Point(334, 26); + this.labelNearIntensity.Size = new System.Drawing.Size(50, 18); + this.numNearIntensity.Location = new System.Drawing.Point(388, 23); + this.numNearIntensity.Size = new System.Drawing.Size(60, 23); + this.numNearIntensity.DecimalPlaces = 2; + this.numNearIntensity.Value = new decimal(new int[] { 5, 0, 0, 65536 }); + this.labelNearHalfLife.Text = RoiWizardStrings.labelNearHalfLife_Text; + this.labelNearHalfLife.Location = new System.Drawing.Point(460, 26); + this.labelNearHalfLife.Size = new System.Drawing.Size(44, 18); + this.numNearHalfLife.Location = new System.Drawing.Point(508, 23); + this.numNearHalfLife.Size = new System.Drawing.Size(60, 23); + this.numNearHalfLife.DecimalPlaces = 2; + this.numNearHalfLife.Maximum = new decimal(new int[] { 1000000, 0, 0, 0 }); + this.comboNearHalfLifeUnit.Location = new System.Drawing.Point(574, 23); + this.comboNearHalfLifeUnit.Size = new System.Drawing.Size(64, 23); + this.comboNearHalfLifeUnit.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.buttonNearSearch.Text = RoiWizardStrings.buttonNearSearch_Text; + this.buttonNearSearch.Location = new System.Drawing.Point(654, 22); + this.buttonNearSearch.Size = new System.Drawing.Size(110, 25); + // подсказка о найденном: сколько всего и сколько показано — на странице + // она стоит под таблицей, здесь встала в свободное место строки фильтров + this.labelNearHint.Location = new System.Drawing.Point(776, 26); + this.labelNearHint.Size = new System.Drawing.Size(360, 18); + this.labelNearHint.Text = ""; + + // Результаты — таблица со строкой на линию и кнопкой «+ добавить» в каждой, + // как на странице: нуклид добавляется прямо из находки, не возвращаясь к шагу 1. + this.tableNear.Location = new System.Drawing.Point(8, 52); + this.tableNear.Size = new System.Drawing.Size(1140, 118); + this.tableNear.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.tableNear.BorderColor = System.Drawing.Color.Black; + this.tableNear.ColumnModel = this.columnModelNear; + this.tableNear.TableModel = this.tableModelNear; + this.tableNear.FullRowSelect = true; + this.tableNear.GridLines = XPTable.Models.GridLines.Rows; + this.tableNear.HeaderRenderer = new CenteredHeaderRenderer(); + this.tableModelNear.RowHeight = 20; + this.columnNearDelta.Editable = false; + this.columnNearDelta.Text = RoiWizardStrings.columnNearDelta_Text; + this.columnNearDelta.Width = 60; + this.columnNearDelta.Alignment = XPTable.Models.ColumnAlignment.Right; + this.columnNearDelta.Renderer = new NumberCellRenderer(); + this.columnNearName.Editable = false; + this.columnNearName.Text = RoiWizardStrings.columnLineName_Text; + this.columnNearName.Width = 200; + this.columnNearEnergy.Editable = false; + this.columnNearEnergy.Text = RoiWizardStrings.columnLineEnergy_Text; + this.columnNearEnergy.Width = 90; + this.columnNearEnergy.Alignment = XPTable.Models.ColumnAlignment.Right; + this.columnNearEnergy.Renderer = new NumberCellRenderer(); + this.columnNearIntensity.Editable = false; + this.columnNearIntensity.Text = RoiWizardStrings.columnLineIntensity_Text; + this.columnNearIntensity.Width = 90; + this.columnNearIntensity.Alignment = XPTable.Models.ColumnAlignment.Right; + this.columnNearIntensity.Renderer = new NumberCellRenderer(); + this.columnNearType.Editable = false; + this.columnNearType.Text = RoiWizardStrings.columnLineType_Text; + this.columnNearType.Width = 80; + this.columnNearType.Renderer = new LineTypeCellRenderer(); + this.columnNearHalfLife.Editable = false; + this.columnNearHalfLife.Text = RoiWizardStrings.columnLineHalfLife_Text; + this.columnNearHalfLife.Width = 100; + this.columnNearHalfLife.Alignment = XPTable.Models.ColumnAlignment.Right; + this.columnNearHalfLife.Renderer = new NumberCellRenderer(); + this.columnNearAdd.Text = ""; + this.columnNearAdd.Width = 110; + this.columnNearAdd.Resizable = false; + this.columnNearAdd.Sortable = false; + this.columnNearAdd.Renderer = new NearAddCellRenderer(); + // на странице таблица находок шириной по содержимому, а XPTable всегда + // занимает контрол целиком — лишнее место забирает пустой столбец справа, + // иначе оно растянуло бы колонку с именем и оторвало числа от подписей + this.columnNearFill.Text = ""; + this.columnNearFill.Editable = false; + this.columnNearFill.Sortable = false; + this.columnNearFill.Width = 40; + this.columnModelNear.Columns.AddRange(new XPTable.Models.Column[] { + this.columnNearDelta, this.columnNearName, this.columnNearEnergy, + this.columnNearIntensity, this.columnNearType, this.columnNearHalfLife, + this.columnNearAdd, this.columnNearFill }); + this.groupNear.Controls.Add(this.labelNearEnergy); + this.groupNear.Controls.Add(this.numNearEnergy); + this.groupNear.Controls.Add(this.labelNearWindow); + this.groupNear.Controls.Add(this.numNearWindow); + this.groupNear.Controls.Add(this.labelNearIntensity); + this.groupNear.Controls.Add(this.numNearIntensity); + this.groupNear.Controls.Add(this.labelNearHalfLife); + this.groupNear.Controls.Add(this.numNearHalfLife); + this.groupNear.Controls.Add(this.comboNearHalfLifeUnit); + this.groupNear.Controls.Add(this.buttonNearSearch); + this.groupNear.Controls.Add(this.labelNearHint); + this.groupNear.Controls.Add(this.tableNear); + this.tabLines.Controls.Add(this.groupNear); + + this.tableLines.Location = new System.Drawing.Point(8, 416); + this.tableLines.Size = new System.Drawing.Size(1156, 150); + this.tableLines.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.tableLines.BorderColor = System.Drawing.Color.Black; + this.tableLines.ColumnModel = this.columnModelLines; + this.tableLines.FullRowSelect = true; + this.tableLines.GridLines = XPTable.Models.GridLines.Rows; + this.tableLines.TableModel = this.tableModelLines; + this.tableLines.HeaderRenderer = new CenteredHeaderRenderer(); + // строка 20 px: шрифт темы 9 pt в штатные 15 px не помещается, + // а на странице строка таблицы 21-22 px + this.tableModelLines.RowHeight = 20; + this.columnLineSelected.Resizable = false; + this.columnLineSelected.Sortable = false; + this.columnLineSelected.Alignment = XPTable.Models.ColumnAlignment.Center; + this.columnLineSelected.Text = "✓"; + this.columnLineSelected.Width = 30; + this.columnLineName.Editable = false; + this.columnLineName.Text = RoiWizardStrings.columnLineName_Text; + this.columnLineName.Width = 320; + this.columnLineEnergy.Editable = false; + this.columnLineEnergy.Text = RoiWizardStrings.columnLineEnergy_Text; + this.columnLineEnergy.Width = 90; + this.columnLineEnergy.Alignment = XPTable.Models.ColumnAlignment.Right; + this.columnLineEnergy.Renderer = new NumberCellRenderer(); + this.columnLineIntensity.Editable = false; + this.columnLineIntensity.Text = RoiWizardStrings.columnLineIntensity_Text; + this.columnLineIntensity.Width = 90; + this.columnLineIntensity.Alignment = XPTable.Models.ColumnAlignment.Right; + this.columnLineIntensity.Renderer = new IntensityBarCellRenderer(); + this.columnLineRelative.Editable = false; + this.columnLineRelative.Text = RoiWizardStrings.columnLineRelative_Text; + this.columnLineRelative.Width = 80; + this.columnLineRelative.Alignment = XPTable.Models.ColumnAlignment.Right; + this.columnLineRelative.Renderer = new NumberCellRenderer(); + this.columnLineHalfLife.Editable = false; + this.columnLineHalfLife.Text = RoiWizardStrings.columnLineHalfLife_Text; + this.columnLineHalfLife.Width = 90; + this.columnLineHalfLife.Alignment = XPTable.Models.ColumnAlignment.Right; + this.columnLineHalfLife.Renderer = new NumberCellRenderer(); + this.columnLineType.Editable = false; + this.columnLineType.Renderer = new LineTypeCellRenderer(); + this.columnLineType.Text = RoiWizardStrings.columnLineType_Text; + this.columnLineType.Width = 80; + this.columnModelLines.Columns.AddRange(new XPTable.Models.Column[] { + this.columnLineSelected, this.columnLineName, this.columnLineEnergy, + this.columnLineIntensity, this.columnLineRelative, + this.columnLineHalfLife, this.columnLineType }); + + this.tabLines.Controls.Add(this.groupResolution); + this.tabLines.Controls.Add(this.groupFilters); + this.tabLines.Controls.Add(this.tableLines); + + // ─── шаг 3 ───────────────────────────────────────────────────── + this.groupStyle.Text = RoiWizardStrings.groupStyle_Text; + this.groupStyle.Location = new System.Drawing.Point(8, 6); + this.groupStyle.Size = new System.Drawing.Size(1156, 104); + this.groupStyle.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.labelStyle.Text = RoiWizardStrings.labelStyle_Text; + this.labelStyle.Location = new System.Drawing.Point(8, 23); + this.labelStyle.AutoSize = true; + this.comboStyle.Location = new System.Drawing.Point(56, 20); + this.comboStyle.Size = new System.Drawing.Size(260, 21); + this.comboStyle.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.labelWidth.Text = RoiWizardStrings.labelWidth_Text; + this.labelWidth.Location = new System.Drawing.Point(330, 23); + this.labelWidth.AutoSize = true; + this.comboWidthMode.Location = new System.Drawing.Point(420, 20); + this.comboWidthMode.Size = new System.Drawing.Size(220, 21); + this.comboWidthMode.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.numZonePercent.Location = new System.Drawing.Point(648, 20); + this.numZonePercent.Size = new System.Drawing.Size(56, 21); + this.numZonePercent.DecimalPlaces = 1; + this.numZonePercent.Minimum = new decimal(new int[] { 5, 0, 0, 65536 }); + this.numZonePercent.Maximum = new decimal(new int[] { 50, 0, 0, 0 }); + this.numZonePercent.Value = new decimal(new int[] { 5, 0, 0, 0 }); + this.numZoneFactor.Location = new System.Drawing.Point(710, 20); + this.numZoneFactor.Size = new System.Drawing.Size(56, 21); + this.numZoneFactor.DecimalPlaces = 1; + this.numZoneFactor.Minimum = new decimal(new int[] { 5, 0, 0, 65536 }); + this.numZoneFactor.Maximum = new decimal(new int[] { 10, 0, 0, 0 }); + this.numZoneFactor.Value = new decimal(new int[] { 3, 0, 0, 0 }); + this.groupStyle.Controls.Add(this.labelStyle); + this.groupStyle.Controls.Add(this.comboStyle); + this.groupStyle.Controls.Add(this.labelWidth); + this.groupStyle.Controls.Add(this.comboWidthMode); + this.groupStyle.Controls.Add(this.numZonePercent); + this.labelColors.Text = RoiWizardStrings.labelColors_Text; + this.labelColors.Location = new System.Drawing.Point(8, 60); + this.labelColors.Size = new System.Drawing.Size(70, 18); + this.buttonColorByChain.Text = RoiWizardStrings.buttonColorByChain_Text; + this.buttonColorByChain.Location = new System.Drawing.Point(80, 56); + this.buttonColorByChain.Size = new System.Drawing.Size(110, 25); + this.buttonColorByNuclide.Text = RoiWizardStrings.buttonColorByNuclide_Text; + this.buttonColorByNuclide.Location = new System.Drawing.Point(196, 56); + this.buttonColorByNuclide.Size = new System.Drawing.Size(110, 25); + // чипы владельцев: цветной квадрат + подпись, клик по квадрату открывает выбор + this.panelColors.Location = new System.Drawing.Point(316, 56); + this.panelColors.Size = new System.Drawing.Size(836, 28); + this.panelColors.AutoScroll = true; + this.panelColors.WrapContents = false; + this.groupStyle.Controls.Add(this.numZoneFactor); + this.groupStyle.Controls.Add(this.labelColors); + this.groupStyle.Controls.Add(this.buttonColorByChain); + this.groupStyle.Controls.Add(this.buttonColorByNuclide); + this.groupStyle.Controls.Add(this.panelColors); + + this.groupExport.Text = RoiWizardStrings.groupExport_Text; + this.groupExport.Location = new System.Drawing.Point(8, 114); + this.groupExport.Size = new System.Drawing.Size(1156, 120); + this.groupExport.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.labelConfigName.Text = RoiWizardStrings.labelConfigName_Text; + this.labelConfigName.Location = new System.Drawing.Point(8, 23); + this.labelConfigName.AutoSize = true; + this.textConfigName.Location = new System.Drawing.Point(148, 20); + this.textConfigName.Size = new System.Drawing.Size(220, 21); + this.textConfigName.Text = "IAEA lines"; + this.buttonCreateRoi.Text = RoiWizardStrings.buttonCreateRoi_Text; + this.buttonCreateRoi.Location = new System.Drawing.Point(376, 19); + this.buttonCreateRoi.Size = new System.Drawing.Size(180, 23); + this.buttonPreview.Text = RoiWizardStrings.buttonPreview_Text; + this.buttonPreview.Location = new System.Drawing.Point(564, 19); + this.buttonPreview.Size = new System.Drawing.Size(130, 23); + this.labelSetName.Text = RoiWizardStrings.labelSetName_Text; + this.labelSetName.Location = new System.Drawing.Point(8, 53); + this.labelSetName.AutoSize = true; + this.textSetName.Location = new System.Drawing.Point(148, 50); + this.textSetName.Size = new System.Drawing.Size(220, 21); + this.textSetName.Text = RoiWizardStrings.textSetName_Text; + this.labelAnchor.Text = RoiWizardStrings.labelAnchor_Text; + this.labelAnchor.Location = new System.Drawing.Point(376, 53); + this.labelAnchor.AutoSize = true; + this.comboAnchor.Location = new System.Drawing.Point(468, 50); + this.comboAnchor.Size = new System.Drawing.Size(278, 21); + this.comboAnchor.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.buttonCreateSet.Text = RoiWizardStrings.buttonCreateSet_Text; + this.buttonCreateSet.Location = new System.Drawing.Point(754, 49); + this.buttonCreateSet.Size = new System.Drawing.Size(192, 23); + this.checkFullSet.Text = RoiWizardStrings.checkFullSet_Text; + this.checkFullSet.Location = new System.Drawing.Point(148, 80); + this.checkFullSet.Size = new System.Drawing.Size(220, 19); + this.labelAnchorCount.Text = RoiWizardStrings.labelAnchorCount_Text; + this.labelAnchorCount.Location = new System.Drawing.Point(376, 82); + this.labelAnchorCount.AutoSize = true; + this.numAnchors.Location = new System.Drawing.Point(446, 79); + this.numAnchors.Size = new System.Drawing.Size(60, 21); + this.numAnchors.Minimum = 1; + this.numAnchors.Maximum = 9; + this.numAnchors.Value = 3; + this.groupExport.Controls.Add(this.labelConfigName); + this.groupExport.Controls.Add(this.textConfigName); + this.groupExport.Controls.Add(this.buttonCreateRoi); + this.groupExport.Controls.Add(this.buttonPreview); + this.groupExport.Controls.Add(this.labelSetName); + this.groupExport.Controls.Add(this.textSetName); + this.groupExport.Controls.Add(this.labelAnchor); + this.groupExport.Controls.Add(this.comboAnchor); + this.groupExport.Controls.Add(this.buttonCreateSet); + this.groupExport.Controls.Add(this.checkFullSet); + this.groupExport.Controls.Add(this.labelAnchorCount); + this.groupExport.Controls.Add(this.numAnchors); + + this.labelIssues.Text = RoiWizardStrings.labelIssues_Text; + this.labelIssues.Location = new System.Drawing.Point(12, 240); + this.labelIssues.AutoSize = true; + this.listIssues.Location = new System.Drawing.Point(8, 258); + this.listIssues.Size = new System.Drawing.Size(1156, 158); + this.listIssues.Anchor = System.Windows.Forms.AnchorStyles.Top | + System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + // моноширинный текст: предпросмотр повторяет
 на странице
+            this.textPreview.Location = new System.Drawing.Point(8, 424);
+            this.textPreview.Size = new System.Drawing.Size(1156, 140);
+            this.textPreview.Multiline = true;
+            this.textPreview.ReadOnly = true;
+            this.textPreview.WordWrap = false;
+            this.textPreview.ScrollBars = System.Windows.Forms.ScrollBars.Both;
+            this.textPreview.Font = new System.Drawing.Font("Consolas", 8.25F);
+            this.textPreview.Anchor = System.Windows.Forms.AnchorStyles.Top |
+                System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
+            this.listIssues.HorizontalScrollbar = true;
+
+            this.tabExport.Controls.Add(this.groupStyle);
+            this.tabExport.Controls.Add(this.groupExport);
+            this.tabExport.Controls.Add(this.labelIssues);
+            this.tabExport.Controls.Add(this.listIssues);
+            this.tabExport.Controls.Add(this.textPreview);
+
+            // ─── числа в полях настроек ────────────────────────────────────
+            // Все счётчики выровнены по правому краю: разряды выстраиваются в столбик,
+            // и число не отъезжает от подписи при вводе. Отступ от правого края задаёт
+            // WizardTheme.Apply — свойства для него у поля ввода нет.
+            this.numResolution.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numFactor.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numMinIntensity.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numMinEnergy.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numMaxEnergy.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numMinHalfLife.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numMaxHalfLife.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numTopN.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numSecondaryMin.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numNearEnergy.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numNearWindow.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numNearIntensity.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numNearHalfLife.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numZonePercent.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numZoneFactor.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+            this.numAnchors.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
+
+            // ─── строка состояния ──────────────────────────────────────────
+            // счётчик занимает всё свободное место, кнопки прижаты вправо — как на странице
+            this.statusLabel.Spring = true;
+            this.statusLabel.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
+            this.statusLabel.Text = "";
+            this.buttonStepPrev.Text = "◂ Back";
+            this.buttonStepPrev.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
+            this.buttonStepPrev.AutoToolTip = false;
+            this.buttonStepNext.Text = "Next ▸";
+            this.buttonStepNext.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
+            this.buttonStepNext.AutoToolTip = false;
+            this.buttonHelp.Text = RoiWizardStrings.buttonHelp_Text;
+            this.buttonHelp.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
+            this.buttonHelp.AutoToolTip = false;
+            this.buttonHelp.Alignment = System.Windows.Forms.ToolStripItemAlignment.Right;
+            this.statusStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
+                this.statusLabel, this.buttonHelp, this.buttonStepPrev, this.buttonStepNext });
+
+            // ─── форма ─────────────────────────────────────────────────────
+            this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
+            this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
+            this.ClientSize = new System.Drawing.Size(1180, 620);
+            this.MinimumSize = new System.Drawing.Size(1000, 500);
+            this.Controls.Add(this.tabs);
+            this.Controls.Add(this.statusStrip);
+            this.Name = "RoiWizardForm";
+            this.Text = RoiWizardStrings.form_Title;
+            this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
+
+            ((System.ComponentModel.ISupportInitialize)(this.numResolution)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numFactor)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numMinIntensity)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numMinEnergy)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numMaxEnergy)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numTopN)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numMinHalfLife)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numMaxHalfLife)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numSecondaryMin)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numNearEnergy)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numNearWindow)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numNearIntensity)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numNearHalfLife)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numZonePercent)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numZoneFactor)).EndInit();
+            ((System.ComponentModel.ISupportInitialize)(this.numAnchors)).EndInit();
+            this.ResumeLayout(false);
+            this.PerformLayout();
+        }
+
+        System.Windows.Forms.TabControl tabs;
+        System.Windows.Forms.TabPage tabSources;
+        System.Windows.Forms.TabPage tabLines;
+        System.Windows.Forms.TabPage tabExport;
+
+        System.Windows.Forms.GroupBox groupSearch;
+        System.Windows.Forms.TextBox textSearch;
+        System.Windows.Forms.Button buttonAddSingle;
+        System.Windows.Forms.Button buttonAddFamily;
+        System.Windows.Forms.Button buttonAddChain;
+        XPTable.Models.Table tableCatalog;
+        XPTable.Models.ColumnModel columnModelCatalog;
+        XPTable.Models.TextColumn columnCatalogName;
+        XPTable.Models.TextColumn columnCatalogFamilies;
+        XPTable.Models.TextColumn columnCatalogHalfLife;
+        XPTable.Models.TextColumn columnCatalogLines;
+        XPTable.Models.TableModel tableModelCatalog;
+
+        System.Windows.Forms.GroupBox groupGroup;
+        System.Windows.Forms.GroupBox groupXrf;
+        System.Windows.Forms.CheckedListBox checkedGroup;
+        System.Windows.Forms.Button buttonFamilyInfo;
+        System.Windows.Forms.Label labelFamilyInfo;
+        System.Windows.Forms.Label labelSearchHint;
+        System.Windows.Forms.FlowLayoutPanel panelPresets;
+        System.Windows.Forms.Label labelXrfHint;
+        System.Windows.Forms.Label labelGroupHint;
+        System.Windows.Forms.ComboBox comboGroup;
+        System.Windows.Forms.Button buttonGroupAll;
+        System.Windows.Forms.Button buttonGroupFamily;
+        System.Windows.Forms.Button buttonGroupChain;
+        System.Windows.Forms.Label labelXrf;
+        System.Windows.Forms.CheckedListBox checkedXrf;
+
+        System.Windows.Forms.GroupBox groupSelected;
+        System.Windows.Forms.FlowLayoutPanel panelSelected;
+        System.Windows.Forms.Button buttonClear;
+
+        System.Windows.Forms.GroupBox groupResolution;
+        System.Windows.Forms.Label labelResolution;
+        System.Windows.Forms.NumericUpDown numResolution;
+        System.Windows.Forms.Button buttonFromSpectrum;
+        System.Windows.Forms.Label labelCriterion;
+        System.Windows.Forms.ComboBox comboCriterion;
+        System.Windows.Forms.NumericUpDown numFactor;
+        System.Windows.Forms.Label labelFactor;
+        System.Windows.Forms.Button buttonMerge;
+        System.Windows.Forms.Button buttonUnmerge;
+        System.Windows.Forms.Label labelMergeInfo;
+
+        System.Windows.Forms.GroupBox groupFilters;
+        System.Windows.Forms.CheckBox checkIntensity;
+        System.Windows.Forms.NumericUpDown numMinIntensity;
+        System.Windows.Forms.ComboBox comboIntensityMode;
+        System.Windows.Forms.CheckBox checkEnergy;
+        System.Windows.Forms.NumericUpDown numMinEnergy;
+        System.Windows.Forms.NumericUpDown numMaxEnergy;
+        System.Windows.Forms.CheckBox checkEquilibrium;
+        System.Windows.Forms.GroupBox groupSecondary;
+        System.Windows.Forms.Label labelSecondaryMin;
+        System.Windows.Forms.NumericUpDown numSecondaryMin;
+        System.Windows.Forms.CheckBox checkSecBackscatter;
+        System.Windows.Forms.CheckBox checkSecComptonEdge;
+        System.Windows.Forms.CheckBox checkSecSingleEscape;
+        System.Windows.Forms.CheckBox checkSecDoubleEscape;
+        System.Windows.Forms.CheckBox checkSecIodine;
+        System.Windows.Forms.CheckBox checkSecAnnihilation;
+        System.Windows.Forms.CheckBox checkSecSum;
+        System.Windows.Forms.CheckBox checkSecPileUp;
+        System.Windows.Forms.Button buttonGenerateSecondary;
+        System.Windows.Forms.GroupBox groupNear;
+        System.Windows.Forms.Label labelNearEnergy;
+        System.Windows.Forms.NumericUpDown numNearEnergy;
+        System.Windows.Forms.Label labelNearWindow;
+        System.Windows.Forms.NumericUpDown numNearWindow;
+        System.Windows.Forms.Label labelNearIntensity;
+        System.Windows.Forms.NumericUpDown numNearIntensity;
+        System.Windows.Forms.Label labelNearHalfLife;
+        System.Windows.Forms.NumericUpDown numNearHalfLife;
+        System.Windows.Forms.ComboBox comboNearHalfLifeUnit;
+        System.Windows.Forms.Button buttonNearSearch;
+        System.Windows.Forms.Label labelNearHint;
+        XPTable.Models.Table tableNear;
+        XPTable.Models.ColumnModel columnModelNear;
+        XPTable.Models.TextColumn columnNearDelta;
+        XPTable.Models.TextColumn columnNearName;
+        XPTable.Models.TextColumn columnNearEnergy;
+        XPTable.Models.TextColumn columnNearIntensity;
+        XPTable.Models.TextColumn columnNearType;
+        XPTable.Models.TextColumn columnNearHalfLife;
+        XPTable.Models.ButtonColumn columnNearAdd;
+        XPTable.Models.TextColumn columnNearFill;
+        XPTable.Models.TableModel tableModelNear;
+        System.Windows.Forms.Button buttonSelectAll;
+        System.Windows.Forms.Button buttonSelectNone;
+        System.Windows.Forms.NumericUpDown numTopN;
+        System.Windows.Forms.Label labelTopN;
+        System.Windows.Forms.Button buttonSelectTop;
+
+        XPTable.Models.Table tableLines;
+        XPTable.Models.ColumnModel columnModelLines;
+        XPTable.Models.CheckBoxColumn columnLineSelected;
+        XPTable.Models.TextColumn columnLineName;
+        XPTable.Models.TextColumn columnLineEnergy;
+        XPTable.Models.TextColumn columnLineIntensity;
+        XPTable.Models.TextColumn columnLineRelative;
+        XPTable.Models.TextColumn columnLineHalfLife;
+        XPTable.Models.TextColumn columnLineType;
+        System.Windows.Forms.CheckBox checkHalfLife;
+        System.Windows.Forms.NumericUpDown numMinHalfLife;
+        System.Windows.Forms.ComboBox comboMinHalfLifeUnit;
+        System.Windows.Forms.NumericUpDown numMaxHalfLife;
+        System.Windows.Forms.ComboBox comboMaxHalfLifeUnit;
+        System.Windows.Forms.CheckBox checkHideUnselected;
+        System.Windows.Forms.Label labelTypes;
+        System.Windows.Forms.CheckBox checkTypeGamma;
+        System.Windows.Forms.CheckBox checkTypeXray;
+        System.Windows.Forms.CheckBox checkTypeXrf;
+        System.Windows.Forms.CheckBox checkTypeSecondary;
+        XPTable.Models.TableModel tableModelLines;
+
+        System.Windows.Forms.GroupBox groupStyle;
+        System.Windows.Forms.Label labelStyle;
+        System.Windows.Forms.ComboBox comboStyle;
+        System.Windows.Forms.Label labelWidth;
+        System.Windows.Forms.ComboBox comboWidthMode;
+        System.Windows.Forms.NumericUpDown numZonePercent;
+        System.Windows.Forms.NumericUpDown numZoneFactor;
+        System.Windows.Forms.Label labelColors;
+        System.Windows.Forms.Button buttonColorByChain;
+        System.Windows.Forms.Button buttonColorByNuclide;
+        System.Windows.Forms.FlowLayoutPanel panelColors;
+
+        System.Windows.Forms.GroupBox groupExport;
+        System.Windows.Forms.Label labelConfigName;
+        System.Windows.Forms.TextBox textConfigName;
+        System.Windows.Forms.Button buttonCreateRoi;
+        System.Windows.Forms.Button buttonPreview;
+        System.Windows.Forms.TextBox textPreview;
+        System.Windows.Forms.Label labelSetName;
+        System.Windows.Forms.TextBox textSetName;
+        System.Windows.Forms.Label labelAnchor;
+        System.Windows.Forms.ComboBox comboAnchor;
+        System.Windows.Forms.Button buttonCreateSet;
+        System.Windows.Forms.CheckBox checkFullSet;
+        System.Windows.Forms.Label labelAnchorCount;
+        System.Windows.Forms.NumericUpDown numAnchors;
+        System.Windows.Forms.Label labelIssues;
+        System.Windows.Forms.ListBox listIssues;
+
+        System.Windows.Forms.StatusStrip statusStrip;
+        System.Windows.Forms.ToolStripStatusLabel statusLabel;
+        System.Windows.Forms.ToolStripButton buttonHelp;
+        System.Windows.Forms.ToolStripButton buttonStepPrev;
+        System.Windows.Forms.ToolStripButton buttonStepNext;
+    }
+}
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardForm.cs b/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
new file mode 100644
index 0000000..96243ba
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
@@ -0,0 +1,2091 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Windows.Forms;
+using System.Xml.Serialization;
+using XPTable.Events;
+using XPTable.Models;
+
+namespace BecquerelMonitor.RoiWizard
+{
+    // Окно конструктора: три шага повторяют веб-версию инструмента, но результат
+    // никуда не выгружается файлом — ROI-конфигурация уходит в ROIConfigManager,
+    // а набор нуклидов в NuclideDefinitionManager.
+    public partial class RoiWizardForm : Form
+    {
+        readonly NuclideCatalog catalog;
+        readonly SourceSelection selection = new SourceSelection();
+        readonly LineSetBuilder builder;
+        // пересоздаются при смене R: модель разрешения захватывается экземпляром,
+        // иначе ширина зон считалась бы по устаревшему значению
+        SetExporter exporter;
+        ZoneCalculator zones;
+
+        List lines = new List();
+        List beforeMerge;
+        // источник разрешения из хоста: FWHM-калибровка открытого спектра.
+        // Если не задан, кнопка «из спектра» просто выключена — форма остаётся
+        // самостоятельной и тестируемой без приложения.
+        readonly Func resolutionProvider;
+
+        readonly List groupKeys = new List();
+        readonly List xrfSymbols = new List();
+        bool suspendEvents;
+
+        public RoiWizardForm() : this(null)
+        {
+        }
+
+        public RoiWizardForm(Func resolutionProvider)
+        {
+            this.InitializeComponent();
+            // цвета и шрифт — из темы веб-версии, чтобы окно выглядело так же
+            WizardTheme.Apply(this);
+            this.resolutionProvider = resolutionProvider;
+
+            this.catalog = NuclideCatalog.GetInstance();
+            this.builder = new LineSetBuilder(this.catalog).Reset();
+            this.zones = new ZoneCalculator(this.Resolution);
+            this.exporter = new SetExporter(this.Resolution, this.zones);
+
+            this.FillCombos();
+            this.FillGroups();
+            this.FillXrf();
+            this.RefreshCatalog();
+            this.WireEvents();
+
+            this.buttonFromSpectrum.Enabled = resolutionProvider != null;
+            this.SyncSetControls();
+            this.FillXrf();
+            this.FillPresets();
+            this.SetFold(this.groupSecondary, false);
+            this.SetFold(this.groupNear, false);
+            this.LayoutSources();
+            this.LayoutLineColumns();
+            this.RefreshGroupList();
+            this.UpdateMergeInfo();
+            this.UpdateStepButtons();
+            this.UpdateStatus();
+        }
+
+        ResolutionModel Resolution
+        {
+            get { return new ResolutionModel((double)this.numResolution.Value); }
+        }
+
+        // ─── наполнение ─────────────────────────────────────────────────────
+
+        void FillCombos()
+        {
+            this.comboCriterion.Items.AddRange(new object[] {
+                RoiWizardStrings.criterionSparrow,
+                RoiWizardStrings.criterionAnchored,
+                RoiWizardStrings.criterionManual
+            });
+            this.comboCriterion.SelectedIndex = 0;
+
+            this.comboIntensityMode.Items.AddRange(new object[] {
+                RoiWizardStrings.intensityRelative,
+                RoiWizardStrings.intensityAbsolute
+            });
+            this.comboIntensityMode.SelectedIndex = 0;
+
+            this.comboStyle.Items.AddRange(new object[] {
+                RoiWizardStrings.roiStyleMarkers,
+                RoiWizardStrings.roiStyleZones,
+                RoiWizardStrings.roiStyleBoth
+            });
+            this.comboStyle.SelectedIndex = 0;
+
+            this.comboWidthMode.Items.AddRange(new object[] {
+                RoiWizardStrings.widthModePercent,
+                RoiWizardStrings.widthModeFwhm
+            });
+            this.comboWidthMode.SelectedIndex = 0;
+
+            object[] units = {
+                RoiWizardStrings.unitSeconds, RoiWizardStrings.unitHours,
+                RoiWizardStrings.unitDays, RoiWizardStrings.unitYears };
+            this.comboMinHalfLifeUnit.Items.AddRange(units);
+            this.comboMinHalfLifeUnit.SelectedIndex = 2;      // сутки, как в вебе
+            this.comboMaxHalfLifeUnit.Items.AddRange((object[])units.Clone());
+            this.comboMaxHalfLifeUnit.SelectedIndex = 3;      // годы
+            this.comboNearHalfLifeUnit.Items.AddRange((object[])units.Clone());
+            this.comboNearHalfLifeUnit.SelectedIndex = 2;
+            this.SyncZoneControls();
+        }
+
+        void FillGroups()
+        {
+            this.comboGroup.Items.Clear();
+            this.groupKeys.Clear();
+            string[] families = { "POPULAR", "NORM", "MED", "IND", "SNM", "FISS", "NAA", "WASTE" };
+            foreach (string family in families)
+            {
+                int count = 0;
+                foreach (CatalogNuclide nuclide in this.catalog.ByFamily(family))
+                {
+                    count++;
+                }
+                if (count == 0)
+                {
+                    continue;
+                }
+                this.groupKeys.Add("f:" + family);
+                CatalogFamily entry = this.catalog.FindFamily(family);
+                string title = entry == null ? family
+                    : (this.russian && !string.IsNullOrEmpty(entry.TitleRu) ? entry.TitleRu : entry.Title);
+                this.comboGroup.Items.Add(title + " (" + count + ")");
+            }
+            foreach (CatalogChain chain in this.catalog.Chains)
+            {
+                this.groupKeys.Add("c:" + chain.Id);
+                this.comboGroup.Items.Add(chain.Title + " (" + chain.Members.Count + ")");
+            }
+            if (this.comboGroup.Items.Count > 0)
+            {
+                this.comboGroup.SelectedIndex = 0;
+            }
+        }
+
+        // Готовые наборы: пустой старт не объясняет инструмент, а один клик — объясняет.
+        // Те же пять, что на странице.
+        void FillPresets()
+        {
+            this.panelPresets.SuspendLayout();
+            this.panelPresets.Controls.Clear();
+            Label caption = new Label();
+            caption.Text = this.presetsCaption;
+            caption.AutoSize = true;
+            caption.ForeColor = WizardTheme.Muted;
+            caption.Margin = new Padding(2, 4, 2, 2);
+            this.panelPresets.Controls.Add(caption);
+
+            for (int i = 0; i < this.presets.Length; i++)
+            {
+                if (i > 0)
+                {
+                    Label separator = new Label();
+                    separator.Text = "·";
+                    separator.AutoSize = true;
+                    separator.ForeColor = WizardTheme.Muted;
+                    separator.Margin = new Padding(0, 4, 0, 2);
+                    this.panelPresets.Controls.Add(separator);
+                }
+                Preset preset = this.presets[i];
+                LinkLabel link = new LinkLabel();
+                link.Text = preset.Title;
+                link.AutoSize = true;
+                link.LinkColor = WizardTheme.Accent;
+                link.ActiveLinkColor = WizardTheme.AccentInk;
+                link.LinkBehavior = LinkBehavior.HoverUnderline;
+                link.Margin = new Padding(0, 4, 0, 2);
+                this.tips.SetToolTip(link, preset.Hint);
+                Preset captured = preset;
+                link.LinkClicked += delegate { this.ApplyPreset(captured); };
+                this.panelPresets.Controls.Add(link);
+            }
+            this.panelPresets.ResumeLayout();
+        }
+
+        void ApplyPreset(Preset preset)
+        {
+            foreach (string chainRoot in preset.Chains)
+            {
+                this.selection.Add(this.catalog, chainRoot, AddMode.Chain);
+            }
+            foreach (string name in preset.Nuclides)
+            {
+                this.selection.Add(this.catalog, name, AddMode.Single);
+            }
+            foreach (string code in preset.Families)
+            {
+                foreach (CatalogNuclide nuclide in this.catalog.ByFamily(code))
+                {
+                    this.selection.AddGroupMember(this.catalog, nuclide.Name);
+                }
+            }
+            foreach (string symbol in preset.Xrf)
+            {
+                for (int i = 0; i < this.xrfSymbols.Count; i++)
+                {
+                    if (string.Equals(this.xrfSymbols[i], symbol, StringComparison.Ordinal))
+                    {
+                        this.checkedXrf.SetItemChecked(i, true);   // галка сама добавит элемент
+                    }
+                }
+            }
+            this.RefreshGroupList();
+            this.Rebuild();
+        }
+
+        sealed class Preset
+        {
+            public readonly string Title;
+            public readonly string Hint;
+            public readonly string[] Chains;
+            public readonly string[] Nuclides;
+            public readonly string[] Families;
+            public readonly string[] Xrf;
+
+            public Preset(string title, string hint,
+                          string[] chains, string[] nuclides, string[] families, string[] xrf)
+            {
+                this.Title = title;
+                this.Hint = hint;
+                this.Chains = chains;
+                this.Nuclides = nuclides;
+                this.Families = families;
+                this.Xrf = xrf;
+            }
+        }
+
+        static readonly string[] None = new string[0];
+
+        readonly Preset[] presets = {
+            new Preset(RoiWizardStrings.preset1_Title, RoiWizardStrings.preset1_Hint,
+                       new string[] { "Th-232", "U-238" }, new string[] { "K-40" }, None, None),
+            new Preset(RoiWizardStrings.preset2_Title, RoiWizardStrings.preset2_Hint,
+                       None, new string[] { "Cs-137", "Co-60" }, None, None),
+            new Preset(RoiWizardStrings.preset3_Title, RoiWizardStrings.preset3_Hint,
+                       None, new string[] { "Am-241", "Ba-133", "Eu-152", "Cs-137", "Co-60" }, None, None),
+            new Preset(RoiWizardStrings.preset4_Title, RoiWizardStrings.preset4_Hint,
+                       None, None, new string[] { "MED" }, None),
+            new Preset(RoiWizardStrings.preset5_Title, RoiWizardStrings.preset5_Hint,
+                       None, None, None, new string[] { "Pb", "W", "La", "Ba", "I" })
+        };
+
+        readonly ToolTip tips = new ToolTip();
+        string presetsCaption { get { return RoiWizardStrings.presetsCaption; } }
+
+        // Словарик кодов: пояснение выбранного семейства и чем задана классификация.
+        // Всплывает поверх списка и закрывается щелчком по себе или Esc — как .infoPop.
+        void ToggleFamilyInfo()
+        {
+            if (this.labelFamilyInfo.Visible)
+            {
+                this.labelFamilyInfo.Visible = false;
+                return;
+            }
+            this.UpdateFamilyInfo();
+            this.labelFamilyInfo.Visible = true;
+            this.labelFamilyInfo.BringToFront();
+        }
+
+        void UpdateFamilyInfo()
+        {
+            int index = this.comboGroup.SelectedIndex;
+            string text = "";
+            if (index >= 0 && index < this.groupKeys.Count &&
+                this.groupKeys[index].StartsWith("f:", StringComparison.Ordinal))
+            {
+                CatalogFamily family = this.catalog.FindFamily(this.groupKeys[index].Substring(2));
+                if (family != null)
+                {
+                    string title = this.russian && !string.IsNullOrEmpty(family.TitleRu)
+                        ? family.TitleRu : family.Title;
+                    string info = this.russian && !string.IsNullOrEmpty(family.InfoRu)
+                        ? family.InfoRu : family.Info;
+                    text = title + " — " + info + Environment.NewLine + Environment.NewLine;
+                }
+            }
+            string standard = this.russian && !string.IsNullOrEmpty(this.catalog.FamilyStandardRu)
+                ? this.catalog.FamilyStandardRu : this.catalog.FamilyStandard;
+            this.labelFamilyInfo.Text = text + (standard == null ? "" : standard);
+        }
+
+        void FillXrf()
+        {
+            this.checkedXrf.Items.Clear();
+            this.xrfSymbols.Clear();
+            foreach (XrfElement element in this.catalog.XrfElements)
+            {
+                this.xrfSymbols.Add(element.Symbol);
+                string context = this.russian && !string.IsNullOrEmpty(element.ContextRu)
+                    ? element.ContextRu
+                    : element.Context;
+                this.checkedXrf.Items.Add(element.Symbol + " — " + context);
+            }
+        }
+
+        void RefreshCatalog()
+        {
+            string filter = this.textSearch.Text.Trim();
+            this.tableCatalog.SuspendLayout();
+            this.tableModelCatalog.Rows.Clear();
+            foreach (CatalogNuclide nuclide in this.catalog.Nuclides)
+            {
+                if (filter.Length > 0 &&
+                    nuclide.Name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0 &&
+                    (nuclide.Families ?? "").IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
+                {
+                    continue;
+                }
+                Row row = new Row();
+                row.Cells.Add(new Cell(nuclide.Name));
+                row.Cells.Add(new Cell(nuclide.Families ?? ""));
+                row.Cells.Add(new Cell(HalfLifeLabel(nuclide.HalfLifeText ?? ""),
+                                       nuclide.HalfLifeYears));
+                // счётчики уходят рендереру парой чисел: «γ» и «X» красятся по-разному
+                row.Cells.Add(new Cell(
+                    nuclide.Gamma.Count.ToString(CultureInfo.InvariantCulture) + " " +
+                    nuclide.Xray.Count.ToString(CultureInfo.InvariantCulture), nuclide.LineCount));
+                if (nuclide.LineCount == 0)
+                {
+                    row.ForeColor = WizardTheme.NoLines;   // .nuc.nolines — нечего искать в спектре
+                }
+                row.Tag = nuclide;
+                this.tableModelCatalog.Rows.Add(row);
+            }
+            this.tableCatalog.ResumeLayout();
+            this.LayoutCatalogColumns();
+        }
+
+        // Блоки вторичных пиков и поиска близких линий свёрнуты по умолчанию — на
+        // странице это .group с .gbody{display:none}. GroupBox так не умеет: прячем
+        // содержимое и ужимаем высоту до заголовка. Щелчок по полосе заголовка (верхние
+        // строки блока) переключает состояние, маркер ▾/▴ стоит в подписи.
+        readonly Dictionary foldedHeights = new Dictionary();
+        readonly Dictionary foldedTitles = new Dictionary();
+        readonly Dictionary foldedOpen = new Dictionary();
+
+        int HeaderHeight(GroupBox box)
+        {
+            return box.Font.Height + 8;
+        }
+
+        void SetFold(GroupBox box, bool open)
+        {
+            if (!this.foldedHeights.ContainsKey(box))
+            {
+                this.foldedHeights[box] = box.Height;
+                this.foldedTitles[box] = box.Text;
+            }
+            this.foldedOpen[box] = open;
+            foreach (Control child in box.Controls)
+            {
+                child.Visible = open;
+            }
+            box.Height = open ? this.foldedHeights[box] : this.HeaderHeight(box);
+            box.Text = this.foldedTitles[box] + (open ? "  ▴" : "  ▾");
+            this.LayoutLines();
+        }
+
+        void ToggleFold(GroupBox box, int y)
+        {
+            if (y <= this.HeaderHeight(box))
+            {
+                bool open;
+                this.SetFold(box, !(this.foldedOpen.TryGetValue(box, out open) && open));
+            }
+        }
+
+        // Вкладка «Линии» складывается сверху вниз: свернув блок, всё под ним
+        // поднимается, а остаток высоты достаётся таблице линий.
+        void LayoutLines()
+        {
+            int width = this.tabLines.ClientSize.Width;
+            int height = this.tabLines.ClientSize.Height;
+            if (width < 120 || height < 120)
+            {
+                return;
+            }
+            const int Pad = 8;
+            const int Gap = 6;
+            int y = 6;
+            GroupBox[] boxes = {
+                this.groupResolution, this.groupFilters, this.groupSecondary, this.groupNear };
+            foreach (GroupBox box in boxes)
+            {
+                box.SetBounds(Pad, y, width - Pad * 2, box.Height);
+                y += box.Height + Gap;
+            }
+            int rest = height - Pad - y;
+            if (rest < 60)
+            {
+                rest = 60;
+            }
+            this.tableLines.SetBounds(Pad, y, width - Pad * 2, rest);
+        }
+
+        // Три колонки шага 1 делят ширину поровну — .cols3 на странице задана как
+        // grid-template-columns: repeat(3, 1fr). Привязки WinForms умеют только
+        // «держать край», поэтому доли считаются здесь; полоса «Выбрано» прижата к низу.
+        void LayoutSources()
+        {
+            int width = this.tabSources.ClientSize.Width;
+            int height = this.tabSources.ClientSize.Height;
+            if (width < 120 || height < 120)
+            {
+                return;
+            }
+            const int Pad = 8;
+            const int Gap = 8;
+            const int Top = 6;
+            this.groupSelected.SetBounds(Pad, height - Pad - this.groupSelected.Height,
+                                         width - Pad * 2, this.groupSelected.Height);
+            // доли из .cols3: 1fr 1.15fr 1fr — средняя колонка шире, в ней комбобокс
+            // группы и три кнопки раскрытия
+            int free = width - Pad * 2 - Gap * 2;
+            int column = free * 100 / 315;
+            int middle = free - column * 2;
+            int boxHeight = this.groupSelected.Top - Gap - Top;
+            this.groupSearch.SetBounds(Pad, Top, column, boxHeight);
+            this.groupGroup.SetBounds(Pad + column + Gap, Top, middle, boxHeight);
+            this.groupXrf.SetBounds(Pad + column + Gap + middle + Gap, Top, column, boxHeight);
+
+            // ряды кнопок делят ширину своей панели: жёсткие ширины не влезают,
+            // когда колонка уже суммы масштабированных кнопок (.line — flex-строка)
+            LayoutButtonRow(this.groupSearch, new Control[] {
+                this.buttonAddSingle, this.buttonAddFamily, this.buttonAddChain },
+                new int[] { 104, 122, 122 });
+            LayoutButtonRow(this.groupGroup, new Control[] {
+                this.buttonGroupAll, this.buttonGroupFamily, this.buttonGroupChain },
+                new int[] { 104, 140, 104 });
+        }
+
+        static void LayoutButtonRow(Control box, Control[] buttons, int[] shares)
+        {
+            const int Pad = 8;
+            const int Gap = 6;
+            int free = box.ClientSize.Width - Pad * 2 - Gap * (buttons.Length - 1);
+            if (free < 120)
+            {
+                return;
+            }
+            int total = 0;
+            foreach (int share in shares)
+            {
+                total += share;
+            }
+            int x = Pad;
+            for (int i = 0; i < buttons.Length; i++)
+            {
+                int width = i == buttons.Length - 1
+                    ? box.ClientSize.Width - Pad - x
+                    : free * shares[i] / total;
+                buttons[i].SetBounds(x, buttons[i].Top, width, buttons[i].Height);
+                x += width + Gap;
+            }
+        }
+
+        // Свободное место таблицы линий уходит в имя нуклида: с пометкой цепочки
+        // «Ra-228 X L (Th-232)» подписи длинные, а числовые колонки фиксированы.
+        // Доли берутся из ТЕКУЩИХ ширин, а не из чисел разметки: границы колонок
+        // тянутся мышью (XPTable это умеет), и жёсткие доли возвращали бы их
+        // к исходным на первом же изменении размера окна.
+        void LayoutLineColumns()
+        {
+            int free = this.tableLines.ClientSize.Width - SystemInformation.VerticalScrollBarWidth - 4
+                       - this.columnLineSelected.Width;
+            if (free < 400)
+            {
+                return;
+            }
+            Column[] columns = {
+                this.columnLineName, this.columnLineEnergy, this.columnLineIntensity,
+                this.columnLineRelative, this.columnLineHalfLife, this.columnLineType };
+            int total = 0;
+            foreach (Column column in columns)
+            {
+                total += column.Width;
+            }
+            if (total <= 0 || total == free)
+            {
+                return;
+            }
+            int used = 0;
+            for (int i = 0; i < columns.Length - 1; i++)
+            {
+                int width = Math.Max(24, free * columns[i].Width / total);
+                columns[i].Width = width;
+                used += width;
+            }
+            columns[columns.Length - 1].Width = Math.Max(24, free - used);   // остаток — последней
+        }
+
+        // В таблице находок колонки держат свою ширину, а лишнее место уходит в пустой
+        // столбец справа: на странице такая таблица шириной по содержимому, и числа
+        // стоят сразу за именем нуклида, а не через полстраницы пустоты.
+        void LayoutNearColumns()
+        {
+            int used = this.columnNearDelta.Width
+                       + this.columnNearName.Width
+                       + this.columnNearEnergy.Width
+                       + this.columnNearIntensity.Width
+                       + this.columnNearType.Width
+                       + this.columnNearHalfLife.Width
+                       + this.columnNearAdd.Width;
+            int free = this.tableNear.ClientSize.Width - SystemInformation.VerticalScrollBarWidth - 4
+                       - used;
+            if (free > 0 && free != this.columnNearFill.Width)
+            {
+                this.columnNearFill.Width = free;
+            }
+        }
+
+        // Свободное место забирает колонка семейств: «T½ γN X N» остаётся прижатым
+        // к правому краю строки — это margin-left:auto у .nuc .hl на странице.
+        void LayoutCatalogColumns()
+        {
+            int free = this.tableCatalog.ClientSize.Width - SystemInformation.VerticalScrollBarWidth - 4
+                       - this.columnCatalogName.Width
+                       - this.columnCatalogHalfLife.Width
+                       - this.columnCatalogLines.Width;
+            if (free > 60 && free != this.columnCatalogFamilies.Width)
+            {
+                this.columnCatalogFamilies.Width = free;
+            }
+        }
+
+        // ─── события ────────────────────────────────────────────────────────
+
+        void WireEvents()
+        {
+            this.tabs.SelectedIndexChanged += delegate { this.UpdateStepButtons(); };
+            this.buttonHelp.Click += delegate { this.ShowHelp(); };
+            this.buttonStepPrev.Click += delegate { this.GoStep(-1); };
+            this.buttonStepNext.Click += delegate { this.GoStep(1); };
+            this.tabSources.Resize += delegate { this.LayoutSources(); };
+            this.tabLines.Resize += delegate { this.LayoutLines(); };
+            this.tabExport.Resize += delegate { this.LayoutExport(); };
+            this.groupSecondary.MouseClick += delegate(object sender, MouseEventArgs e)
+            {
+                this.ToggleFold(this.groupSecondary, e.Y);
+            };
+            this.groupNear.MouseClick += delegate(object sender, MouseEventArgs e)
+            {
+                this.ToggleFold(this.groupNear, e.Y);
+            };
+            this.tableCatalog.Resize += delegate { this.LayoutCatalogColumns(); };
+            this.tableLines.Resize += delegate { this.LayoutLineColumns(); };
+            this.textSearch.TextChanged += delegate { this.RefreshCatalog(); };
+            this.buttonAddSingle.Click += delegate { this.AddFromCatalog(AddMode.Single); };
+            this.buttonAddFamily.Click += delegate { this.AddFromCatalog(AddMode.FamilyLines); };
+            this.buttonAddChain.Click += delegate { this.AddFromCatalog(AddMode.Chain); };
+            this.tableCatalog.DoubleClick += delegate { this.AddFromCatalog(AddMode.Single); };
+
+            this.comboGroup.SelectedIndexChanged += delegate
+            {
+                this.RefreshGroupList();
+                if (this.labelFamilyInfo.Visible)
+                {
+                    this.UpdateFamilyInfo();     // словарик открыт — сразу про новую группу
+                }
+            };
+            this.buttonFamilyInfo.Click += delegate { this.ToggleFamilyInfo(); };
+            this.labelFamilyInfo.Click += delegate { this.labelFamilyInfo.Visible = false; };
+            this.checkedGroup.ItemCheck += this.OnGroupItemCheck;
+            this.buttonGroupAll.Click += delegate { this.AddFromGroup(AddMode.Single); };
+            this.buttonGroupFamily.Click += delegate { this.AddFromGroup(AddMode.FamilyLines); };
+            this.buttonGroupChain.Click += delegate { this.AddFromGroup(AddMode.Chain); };
+            this.checkedXrf.ItemCheck += this.OnXrfCheck;
+
+            this.buttonClear.Click += delegate
+            {
+                this.selection.Clear();
+                for (int i = 0; i < this.checkedXrf.Items.Count; i++)
+                {
+                    this.checkedXrf.SetItemChecked(i, false);
+                }
+                this.RefreshGroupList();
+                this.Rebuild();
+            };
+
+            this.numResolution.ValueChanged += delegate { this.UpdateMergeInfo(); };
+            this.comboCriterion.SelectedIndexChanged += this.OnCriterionChanged;
+            this.numFactor.ValueChanged += delegate { this.UpdateMergeInfo(); };
+            this.buttonMerge.Click += delegate { this.MergeLines(); };
+            this.buttonUnmerge.Click += delegate { this.UnmergeLines(); };
+
+            EventHandler rebuild = delegate { this.Rebuild(); };
+            this.checkIntensity.CheckedChanged += rebuild;
+            this.numMinIntensity.ValueChanged += rebuild;
+            this.comboIntensityMode.SelectedIndexChanged += rebuild;
+            this.checkEnergy.CheckedChanged += rebuild;
+            this.numMinEnergy.ValueChanged += rebuild;
+            this.numMaxEnergy.ValueChanged += rebuild;
+            this.checkHalfLife.CheckedChanged += rebuild;
+            this.numMinHalfLife.ValueChanged += rebuild;
+            this.comboMinHalfLifeUnit.SelectedIndexChanged += rebuild;
+            this.numMaxHalfLife.ValueChanged += rebuild;
+            this.comboMaxHalfLifeUnit.SelectedIndexChanged += rebuild;
+            EventHandler refreshLines = delegate { this.RefreshLines(); };
+            this.checkHideUnselected.CheckedChanged += refreshLines;
+            this.checkTypeGamma.CheckedChanged += refreshLines;
+            this.checkTypeXray.CheckedChanged += refreshLines;
+            this.checkTypeXrf.CheckedChanged += refreshLines;
+            this.checkTypeSecondary.CheckedChanged += refreshLines;
+            this.checkEquilibrium.CheckedChanged += rebuild;
+
+            this.buttonSelectAll.Click += delegate { this.SetVisibleSelected(true); };
+            this.buttonSelectNone.Click += delegate { this.SetVisibleSelected(false); };
+            this.buttonGenerateSecondary.Click += delegate { this.GenerateSecondary(); };
+            this.buttonNearSearch.Click += delegate { this.SearchNearby(); };
+            this.tableNear.CellButtonClicked += delegate(object sender, CellButtonEventArgs e)
+            {
+                // что добавлять, знает сама ячейка: строки переупорядочиваются
+                // сортировкой по столбцу, и номер строки списку находок уже не равен
+                NearHit hit = e.Cell == null ? null : e.Cell.Tag as NearHit;
+                if (hit != null)
+                {
+                    this.AddFromNearby(hit);
+                }
+            };
+            this.tableNear.Resize += delegate { this.LayoutNearColumns(); };
+            this.buttonSelectTop.Click += delegate
+            {
+                LineSetBuilder.SelectTopPerNuclide(this.lines, (int)this.numTopN.Value);
+                this.RefreshLines();
+            };
+            this.tableLines.CellCheckChanged += this.OnLineCheckChanged;
+
+            this.comboStyle.SelectedIndexChanged += delegate { this.SyncZoneControls(); this.RunChecks(); };
+            this.comboWidthMode.SelectedIndexChanged += delegate { this.SyncZoneControls(); this.RunChecks(); };
+            this.numZonePercent.ValueChanged += delegate { this.RunChecks(); };
+            this.numZoneFactor.ValueChanged += delegate { this.RunChecks(); };
+            this.buttonColorByChain.Click += delegate { this.SetColorMode(true); };
+            this.buttonColorByNuclide.Click += delegate { this.SetColorMode(false); };
+            this.buttonPreview.Click += delegate { this.PreviewXml(); };
+            this.buttonCreateRoi.Click += delegate { this.CreateRoiConfig(); };
+            this.buttonCreateSet.Click += delegate { this.CreateNuclideSet(); };
+            // при «полном наборе» таблица и ручной якорь не участвуют — набор собирается
+            // заново из источников, поэтому выбор якоря отдаётся автоматике
+            this.checkFullSet.CheckedChanged += delegate { this.SyncSetControls(); this.RunChecks(); };
+            this.numAnchors.ValueChanged += delegate { this.RunChecks(); };
+            this.buttonFromSpectrum.Click += delegate { this.TakeResolutionFromSpectrum(); };
+            this.tabs.SelectedIndexChanged += delegate
+            {
+                if (this.tabs.SelectedTab == this.tabExport)
+                {
+                    this.RefreshAnchorCombo();
+                    this.RunChecks();
+                }
+            };
+        }
+
+        void OnCriterionChanged(object sender, EventArgs e)
+        {
+            MergeCriterion criterion = (MergeCriterion)this.comboCriterion.SelectedIndex;
+            this.suspendEvents = true;
+            this.numFactor.Value = (decimal)MergeCriterionInfo.DefaultFactor(criterion);
+            // предел Sparrow — величина физическая, менять её руками смысла нет
+            this.numFactor.Enabled = criterion != MergeCriterion.Sparrow;
+            this.suspendEvents = false;
+            this.UpdateMergeInfo();
+        }
+
+        void OnXrfCheck(object sender, ItemCheckEventArgs e)
+        {
+            if (this.suspendEvents)
+            {
+                return;
+            }
+            string symbol = this.xrfSymbols[e.Index];
+            if (e.NewValue == CheckState.Checked)
+            {
+                this.selection.XrfElements.Add(symbol);
+            }
+            else
+            {
+                this.selection.XrfElements.Remove(symbol);
+            }
+            this.BeginInvoke((MethodInvoker)delegate { this.Rebuild(); });
+        }
+
+        void OnLineCheckChanged(object sender, XPTable.Events.CellCheckBoxEventArgs e)
+        {
+            if (this.suspendEvents)
+            {
+                return;
+            }
+            Row row = this.tableModelLines.Rows[e.Row];
+            SpectralLine line = row.Tag as SpectralLine;
+            if (line != null)
+            {
+                line.Selected = row.Cells[0].Checked;
+                this.UpdateStatus();
+            }
+        }
+
+        // ─── выбор источников ───────────────────────────────────────────────
+
+        void AddFromCatalog(AddMode mode)
+        {
+            CatalogNuclide nuclide = this.CurrentCatalogNuclide();
+            if (nuclide == null)
+            {
+                return;
+            }
+            this.selection.Add(this.catalog, nuclide.Name, mode);
+            this.Rebuild();
+        }
+
+        CatalogNuclide CurrentCatalogNuclide()
+        {
+            int index = this.tableCatalog.SelectedIndicies.Length > 0
+                ? this.tableCatalog.SelectedIndicies[0]
+                : -1;
+            if (index < 0 || index >= this.tableModelCatalog.Rows.Count)
+            {
+                // если строка не выбрана — берём точное совпадение из поля поиска
+                return this.catalog.Find(this.textSearch.Text.Trim());
+            }
+            return this.tableModelCatalog.Rows[index].Tag as CatalogNuclide;
+        }
+
+        // Члены выбранной группы с галочками — как в веб-версии: галочка означает
+        // «нуклид взят», и она же выбирает цель для кнопок раскрытия.
+        void RefreshGroupList()
+        {
+            int index = this.comboGroup.SelectedIndex;
+            this.groupMembers.Clear();
+            if (index >= 0 && index < this.groupKeys.Count)
+            {
+                string key = this.groupKeys[index];
+                if (key.StartsWith("f:", StringComparison.Ordinal))
+                {
+                    foreach (CatalogNuclide nuclide in this.catalog.ByFamily(key.Substring(2)))
+                    {
+                        this.groupMembers.Add(nuclide.Name);
+                    }
+                }
+                else
+                {
+                    CatalogChain chain = this.catalog.FindChain(key.Substring(2));
+                    if (chain != null)
+                    {
+                        foreach (string member in chain.Members)
+                        {
+                            if (this.catalog.Find(member) != null)
+                            {
+                                this.groupMembers.Add(member);
+                            }
+                        }
+                    }
+                }
+            }
+
+            this.suppressGroupCheck = true;
+            this.checkedGroup.BeginUpdate();
+            this.checkedGroup.Items.Clear();
+            foreach (string member in this.groupMembers)
+            {
+                CatalogNuclide nuclide = this.catalog.Find(member);
+                string title = nuclide != null && !string.IsNullOrEmpty(nuclide.HalfLifeText)
+                    ? member + "   " + HalfLifeLabel(nuclide.HalfLifeText)
+                    : member;
+                this.checkedGroup.Items.Add(title, this.selection.Nuclides.ContainsKey(member));
+            }
+            this.checkedGroup.EndUpdate();
+            this.suppressGroupCheck = false;
+            this.SyncGroupButtons();
+        }
+
+        void OnGroupItemCheck(object sender, ItemCheckEventArgs e)
+        {
+            if (this.suppressGroupCheck || e.Index < 0 || e.Index >= this.groupMembers.Count)
+            {
+                return;
+            }
+            string name = this.groupMembers[e.Index];
+            if (e.NewValue == CheckState.Checked)
+            {
+                this.selection.AddGroupMember(this.catalog, name);
+            }
+            else
+            {
+                this.selection.Remove(name);
+            }
+            // область действия кнопок зависит от того, что отмечено
+            this.BeginInvoke((MethodInvoker)delegate { this.SyncGroupButtons(); this.Rebuild(); });
+        }
+
+        // Отмеченные члены текущей группы — цели для кнопок раскрытия.
+        List GroupPicked()
+        {
+            List picked = new List();
+            foreach (int index in this.checkedGroup.CheckedIndices)
+            {
+                if (index >= 0 && index < this.groupMembers.Count)
+                {
+                    picked.Add(this.groupMembers[index]);
+                }
+            }
+            return picked;
+        }
+
+        // Раскрытие («+ линии семейства», «+ цепочка») применяется к отмеченным; если
+        // не отмечено ничего — ко всей группе, и тогда оно осмысленно лишь там, где есть
+        // кого раскрывать. У члена ЕРН-ряда родитель задан самим рядом: подменять его
+        // предшественником нельзя, иначе цепочка развалится.
+        void SyncGroupButtons()
+        {
+            int index = this.comboGroup.SelectedIndex;
+            bool isChain = index >= 0 && index < this.groupKeys.Count &&
+                           this.groupKeys[index].StartsWith("c:", StringComparison.Ordinal);
+            List picked = this.GroupPicked();
+            bool expandable;
+            if (picked.Count > 0)
+            {
+                expandable = false;
+                foreach (string name in picked)
+                {
+                    if (this.HasDaughters(name))
+                    {
+                        expandable = true;
+                        break;
+                    }
+                }
+            }
+            else if (isChain)
+            {
+                expandable = true;
+            }
+            else
+            {
+                expandable = false;
+                foreach (string name in this.groupMembers)
+                {
+                    CatalogNuclide nuclide = this.catalog.Find(name);
+                    if (nuclide != null && string.IsNullOrEmpty(nuclide.Chain) && this.HasDaughters(name))
+                    {
+                        expandable = true;
+                        break;
+                    }
+                }
+            }
+            this.buttonGroupFamily.Enabled = expandable;
+            this.buttonGroupChain.Enabled = expandable;
+            this.labelGroupHint.Text = picked.Count > 0
+                ? string.Format(CultureInfo.CurrentCulture, this.hintPicked, picked.Count)
+                : this.hintNone;
+        }
+
+        bool HasDaughters(string name)
+        {
+            CatalogNuclide nuclide = this.catalog.Find(name);
+            if (nuclide == null || string.IsNullOrEmpty(nuclide.Chain))
+            {
+                return false;
+            }
+            CatalogChain chain = this.catalog.FindChain(nuclide.Chain);
+            if (chain == null)
+            {
+                return false;
+            }
+            int start = chain.Members.IndexOf(name);
+            return start >= 0 && start < chain.Members.Count - 1;
+        }
+
+        void AddFromGroup(AddMode mode)
+        {
+            int index = this.comboGroup.SelectedIndex;
+            if (index < 0 || index >= this.groupKeys.Count)
+            {
+                return;
+            }
+            // раскрытие — по отмеченным; «добавить все» всегда работает по группе
+            List picked = this.GroupPicked();
+            if (mode != AddMode.Single && picked.Count > 0)
+            {
+                foreach (string name in picked)
+                {
+                    this.selection.Add(this.catalog, name, mode);
+                }
+                this.RefreshGroupList();
+                this.Rebuild();
+                return;
+            }
+
+            string key = this.groupKeys[index];
+            if (key.StartsWith("f:", StringComparison.Ordinal))
+            {
+                foreach (CatalogNuclide nuclide in this.catalog.ByFamily(key.Substring(2)))
+                {
+                    this.selection.AddGroupMember(this.catalog, nuclide.Name);
+                }
+            }
+            else
+            {
+                CatalogChain chain = this.catalog.FindChain(key.Substring(2));
+                if (chain == null)
+                {
+                    return;
+                }
+                if (mode == AddMode.Single)
+                {
+                    foreach (string member in chain.Members)
+                    {
+                        this.selection.AddGroupMember(this.catalog, member);
+                    }
+                }
+                else
+                {
+                    this.selection.Add(this.catalog, chain.Root, mode);
+                }
+            }
+            this.RefreshGroupList();
+            this.Rebuild();
+        }
+
+        // Чип убирает свой источник по клику — крестик на странице делает то же самое.
+        protected override bool ProcessCmdKey(ref Message message, Keys key)
+        {
+            if (key == Keys.Escape && this.labelFamilyInfo.Visible)
+            {
+                this.labelFamilyInfo.Visible = false;   // Esc закрывает словарик, как на странице
+                return true;
+            }
+            return base.ProcessCmdKey(ref message, key);
+        }
+
+        void RemoveNuclide(string name)
+        {
+            this.selection.Remove(name);
+            this.RefreshGroupList();
+            this.Rebuild();
+        }
+
+        void RemoveXrf(string symbol)
+        {
+            for (int i = 0; i < this.xrfSymbols.Count; i++)
+            {
+                if (string.Equals(this.xrfSymbols[i], symbol, StringComparison.Ordinal))
+                {
+                    this.checkedXrf.SetItemChecked(i, false);   // снятие галки само уберёт элемент
+                    return;
+                }
+            }
+            this.selection.XrfElements.Remove(symbol);
+            this.Rebuild();
+        }
+
+        // ─── пересборка набора ──────────────────────────────────────────────
+
+        LineFilter CurrentFilter()
+        {
+            return new LineFilter
+            {
+                IntensityOn = this.checkIntensity.Checked,
+                MinIntensity = (double)this.numMinIntensity.Value,
+                RelativeIntensity = this.comboIntensityMode.SelectedIndex == 0,
+                EnergyOn = this.checkEnergy.Checked,
+                MinEnergy = (double)this.numMinEnergy.Value,
+                MaxEnergy = (double)this.numMaxEnergy.Value,
+                HalfLifeOn = this.checkHalfLife.Checked,
+                MinHalfLifeYears = HalfLifeYears(this.numMinHalfLife, this.comboMinHalfLifeUnit),
+                // пустое верхнее поле = «∞», как placeholder в вебе
+                MaxHalfLifeYears = this.numMaxHalfLife.Value > 0
+                    ? HalfLifeYears(this.numMaxHalfLife, this.comboMaxHalfLifeUnit)
+                    : double.PositiveInfinity
+            };
+        }
+
+        // единицы периода — те же, что в вебе: секунды, часы, сутки, годы
+        static readonly double[] HalfLifeUnits = { 1.0 / 31557600.0, 1.0 / 8766.0, 1.0 / 365.25, 1.0 };
+
+        static double HalfLifeYears(NumericUpDown value, ComboBox unit)
+        {
+            int index = unit.SelectedIndex >= 0 ? unit.SelectedIndex : HalfLifeUnits.Length - 1;
+            return (double)value.Value * HalfLifeUnits[index];
+        }
+
+        void Rebuild()
+        {
+            this.builder.ScaleToSeriesParent = this.checkEquilibrium.Checked;
+            this.lines = this.builder.Build(this.selection, this.CurrentFilter());
+            this.beforeMerge = null;
+
+            this.RefreshSelectedList();
+            this.RefreshLines();
+            this.RefreshColorChips();
+        }
+
+        // Полоса «Выбрано» — чипы .chip.on со страницы: фон --sel, рамка #7aa7ce,
+        // текст --accent-ink и крестик, снимающий источник.
+        void RefreshSelectedList()
+        {
+            this.panelSelected.SuspendLayout();
+            this.panelSelected.Controls.Clear();
+            foreach (KeyValuePair entry in this.selection.Nuclides)
+            {
+                string name = entry.Key;
+                this.panelSelected.Controls.Add(
+                    this.Chip(name + " ×", delegate { this.RemoveNuclide(name); }));
+            }
+            foreach (string symbol in this.selection.XrfElements)
+            {
+                string element = symbol;
+                this.panelSelected.Controls.Add(
+                    this.Chip(this.xrfChipPrefix + element + " ×", delegate { this.RemoveXrf(element); }));
+            }
+            if (this.panelSelected.Controls.Count == 0)
+            {
+                Label empty = new Label();
+                empty.Text = this.emptySelectionHint;
+                empty.AutoSize = true;
+                empty.ForeColor = WizardTheme.Muted;
+                empty.Margin = new Padding(2, 4, 4, 2);
+                this.panelSelected.Controls.Add(empty);
+            }
+            this.panelSelected.ResumeLayout();
+        }
+
+        Label Chip(string text, EventHandler onClick)
+        {
+            Label chip = new Label();
+            chip.Text = text;
+            chip.AutoSize = true;
+            chip.Padding = new Padding(7, 1, 7, 1);     // .chip{padding:1px 7px}
+            chip.Margin = new Padding(0, 2, 4, 2);      // .chipbar{gap:4px}
+            chip.BackColor = WizardTheme.Selection;
+            chip.ForeColor = WizardTheme.AccentInk;
+            chip.Cursor = Cursors.Hand;
+            chip.Click += onClick;
+            // рамку рисуем сами: BorderStyle у Label даёт системный цвет, а нужен #7aa7ce
+            chip.Paint += delegate(object sender, PaintEventArgs e)
+            {
+                Control control = (Control)sender;
+                using (Pen pen = new Pen(WizardTheme.ChipLine))
+                {
+                    e.Graphics.DrawRectangle(pen, 0, 0, control.Width - 1, control.Height - 1);
+                }
+            };
+            return chip;
+        }
+
+        string xrfChipPrefix { get { return RoiWizardStrings.xrfChipPrefix; } }
+        string emptySelectionHint { get { return RoiWizardStrings.emptySelectionHint; } }
+
+        void RefreshLines()
+        {
+            this.suspendEvents = true;
+            this.tableLines.SuspendLayout();
+            this.tableModelLines.Rows.Clear();
+            // «I отн.» — процент от сильнейшей линии того же нуклида, как в вебе
+            Dictionary strongest = new Dictionary();
+            foreach (SpectralLine line in this.lines)
+            {
+                double current;
+                if (!strongest.TryGetValue(line.Nuclide, out current) || line.Intensity > current)
+                {
+                    strongest[line.Nuclide] = line.Intensity;
+                }
+            }
+            bool hideUnselected = this.checkHideUnselected.Checked;
+            foreach (SpectralLine line in this.lines)
+            {
+                // галки типов управляют видимостью, а не выбором: снятая «ХРИ» убирает
+                // строки из таблицы, но линии остаются в наборе
+                if (!this.IsTypeVisible(line.Type) || (hideUnselected && !line.Selected))
+                {
+                    continue;
+                }
+                double max;
+                strongest.TryGetValue(line.Nuclide, out max);
+                double relative = max > 0 ? 100.0 * line.Intensity / max : 0;
+
+                Row row = new Row();
+                row.Cells.Add(new Cell { Checked = line.Selected });
+                row.Cells.Add(new Cell(line.Label));
+                row.Cells.Add(new Cell(line.Energy.ToString("0.00", CultureInfo.CurrentCulture), line.Energy));
+                Cell intensity = new Cell(line.Intensity.ToString("0.###", CultureInfo.CurrentCulture),
+                                          line.Intensity);
+                intensity.Tag = relative;          // Data занят сортировкой, доля бара — в Tag
+                row.Cells.Add(intensity);
+                row.Cells.Add(new Cell(relative.ToString("0.#", CultureInfo.CurrentCulture), relative));
+                row.Cells.Add(new Cell(HalfLifeLabel(line.HalfLifeText ?? ""), line.HalfLifeYears));
+                Cell kind = new Cell(this.TypeName(line.Type));
+                kind.Tag = TypeKind(line.Type);    // цвет бейджа — по коду, не по подписи
+                row.Cells.Add(kind);
+                row.Tag = line;
+                this.tableModelLines.Rows.Add(row);
+            }
+            this.tableLines.ResumeLayout();
+            this.suspendEvents = false;
+            this.UpdateStatus();
+        }
+
+        bool IsTypeVisible(LineType type)
+        {
+            switch (type)
+            {
+                case LineType.Gamma: return this.checkTypeGamma.Checked;
+                case LineType.Xray: return this.checkTypeXray.Checked;
+                case LineType.Xrf: return this.checkTypeXrf.Checked;
+                default: return this.checkTypeSecondary.Checked;
+            }
+        }
+
+        // T½ каталог хранит по-русски и в машинной записи («1.6e+03 лет»). Показывается
+        // он так же, как на странице: степень десятки верхним индексом, разделитель
+        // дробной части из культуры, единица на языке интерфейса. Сам каталог остаётся
+        // одноязычным — это данные, подписи к ним собирает форма.
+        static string HalfLifeLabel(string text)
+        {
+            if (string.IsNullOrEmpty(text))
+            {
+                return text;
+            }
+            int space = text.LastIndexOf(' ');
+            if (space <= 0)
+            {
+                return text;
+            }
+            return ScientificLabel(text.Substring(0, space)) + " "
+                   + HalfLifeUnit(text.Substring(space + 1));
+        }
+
+        static string HalfLifeUnit(string unit)
+        {
+            switch (unit)
+            {
+                case "с": return RoiWizardStrings.hlSeconds;
+                case "мин": return RoiWizardStrings.hlMinutes;
+                case "ч": return RoiWizardStrings.hlHours;
+                case "сут": return RoiWizardStrings.hlDays;
+                case "лет": return RoiWizardStrings.hlYears;
+                default: return unit;
+            }
+        }
+
+        static string ScientificLabel(string value)
+        {
+            string text = value;
+            int mark = value.IndexOf('e');
+            if (mark > 0)
+            {
+                string power = value.Substring(mark + 1);
+                bool negative = power.StartsWith("-", StringComparison.Ordinal);
+                power = power.TrimStart('+', '-').TrimStart('0');
+                if (power.Length == 0)
+                {
+                    power = "0";
+                }
+                text = value.Substring(0, mark) + "·10" + (negative ? "⁻" : "") + Superscript(power);
+            }
+            return text.Replace(".", CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator);
+        }
+
+        static string Superscript(string digits)
+        {
+            const string Plain = "0123456789";
+            const string Raised = "⁰¹²³⁴⁵⁶⁷⁸⁹";
+            StringBuilder builder = new StringBuilder(digits.Length);
+            foreach (char symbol in digits)
+            {
+                int index = Plain.IndexOf(symbol);
+                builder.Append(index >= 0 ? Raised[index] : symbol);
+            }
+            return builder.ToString();
+        }
+
+        string TypeName(LineType type)
+        {
+            switch (type)
+            {
+                case LineType.Gamma: return "γ";
+                case LineType.Xray: return "X";
+                case LineType.Xrf: return RoiWizardStrings.lineTypeXrf;
+                default: return RoiWizardStrings.lineTypeSecondary;
+            }
+        }
+
+        static string TypeKind(LineType type)
+        {
+            switch (type)
+            {
+                case LineType.Gamma: return "g";
+                case LineType.Xray: return "x";
+                case LineType.Xrf: return "xrf";
+                default: return "sec";
+            }
+        }
+
+        // Кнопки работают по ВИДИМЫМ строкам — как в вебе: при включённом «скрыть
+        // невыбранные» или фильтре типов «снять все» не должно трогать то, чего
+        // пользователь сейчас не видит.
+        // Виды особенностей — ровно те же восемь, что в вебе, и с теми же умолчаниями
+        SecondaryKind SelectedSecondaryKinds()
+        {
+            SecondaryKind kinds = SecondaryKind.None;
+            if (this.checkSecBackscatter.Checked) kinds |= SecondaryKind.Backscatter;
+            if (this.checkSecComptonEdge.Checked) kinds |= SecondaryKind.ComptonEdge;
+            if (this.checkSecSingleEscape.Checked) kinds |= SecondaryKind.SingleEscape;
+            if (this.checkSecDoubleEscape.Checked) kinds |= SecondaryKind.DoubleEscape;
+            if (this.checkSecIodine.Checked) kinds |= SecondaryKind.IodineEscape;
+            if (this.checkSecAnnihilation.Checked) kinds |= SecondaryKind.Annihilation;
+            if (this.checkSecSum.Checked) kinds |= SecondaryKind.CascadeSum;
+            if (this.checkSecPileUp.Checked) kinds |= SecondaryKind.PileUp;
+            return kinds;
+        }
+
+        void GenerateSecondary()
+        {
+            SecondaryKind kinds = this.SelectedSecondaryKinds();
+            if (kinds == SecondaryKind.None)
+            {
+                return;
+            }
+            // прежние маркеры заменяются: иначе повторное нажатие плодит дубли
+            this.lines.RemoveAll(delegate(SpectralLine line) { return line.Type == LineType.Secondary; });
+            List generated = SecondaryPeaks.Generate(
+                this.lines, this.Resolution, kinds, (double)this.numSecondaryMin.Value);
+            this.lines.AddRange(generated);
+            this.RefreshLines();
+            this.statusLabel.Text = string.Format(CultureInfo.CurrentCulture,
+                this.secondaryFormat, generated.Count);
+        }
+
+        // Кто ещё светит рядом: та же выборка, что на странице — γ и X всех нуклидов базы
+        // плюс линии ХРИ, отсортированные по удалённости от заданной энергии.
+        // Классический случай — 186 кэВ: Ra-226 3,6 % против U-235 57,2 %.
+        void SearchNearby()
+        {
+            double energy = (double)this.numNearEnergy.Value;
+            double window = (double)this.numNearWindow.Value;
+            double minIntensity = (double)this.numNearIntensity.Value;
+            double minHalfLife = HalfLifeYears(this.numNearHalfLife, this.comboNearHalfLifeUnit);
+
+            this.nearHits.Clear();
+            foreach (CatalogNuclide nuclide in this.catalog.Nuclides)
+            {
+                if (minHalfLife > 0 && nuclide.HalfLifeYears < minHalfLife)
+                {
+                    continue;
+                }
+                foreach (CatalogGammaLine gamma in nuclide.Gamma)
+                {
+                    if (Math.Abs(gamma.Energy - energy) <= window && gamma.Intensity >= minIntensity)
+                    {
+                        this.nearHits.Add(new NearHit(nuclide.Name, gamma.Energy, gamma.Intensity,
+                                                      "γ", "g", nuclide.HalfLifeText, null));
+                    }
+                }
+                foreach (CatalogXrayLine xray in nuclide.Xray)
+                {
+                    if (Math.Abs(xray.Energy - energy) <= window && xray.Intensity >= minIntensity)
+                    {
+                        this.nearHits.Add(new NearHit(nuclide.Name, xray.Energy, xray.Intensity,
+                                                      "X " + xray.Shell, "x",
+                                                      nuclide.HalfLifeText, null));
+                    }
+                }
+            }
+            foreach (XrfElement element in this.catalog.XrfElements)
+            {
+                foreach (XrfLine line in element.Lines)
+                {
+                    if (Math.Abs(line.Energy - energy) <= window)
+                    {
+                        this.nearHits.Add(new NearHit(
+                            RoiWizardStrings.lineTypeXrf + " " + element.Symbol,
+                            line.Energy, line.Intensity,
+                            RoiWizardStrings.lineTypeXrf + " " + line.Label, "xrf",
+                            "—", element.Symbol));
+                    }
+                }
+            }
+            double centre = energy;
+            this.nearHits.Sort(delegate(NearHit a, NearHit b)
+            {
+                return Math.Abs(a.Energy - centre).CompareTo(Math.Abs(b.Energy - centre));
+            });
+
+            this.tableNear.SuspendLayout();
+            this.tableModelNear.Rows.Clear();
+            int shown = Math.Min(this.nearHits.Count, NearHitLimit);
+            for (int i = 0; i < shown; i++)
+            {
+                NearHit hit = this.nearHits[i];
+                double delta = hit.Energy - energy;
+                bool added = hit.XrfSymbol != null
+                    ? this.selection.XrfElements.Contains(hit.XrfSymbol)
+                    : this.selection.Nuclides.ContainsKey(hit.Nuclide);
+
+                Row row = new Row();
+                row.Cells.Add(new Cell((delta >= 0 ? "+" : "")
+                    + delta.ToString("0.0", CultureInfo.CurrentCulture), delta));
+                row.Cells.Add(new Cell(hit.Nuclide));
+                row.Cells.Add(new Cell(hit.Energy.ToString("0.00", CultureInfo.CurrentCulture),
+                                       hit.Energy));
+                row.Cells.Add(new Cell(hit.Intensity.ToString("0.###", CultureInfo.CurrentCulture),
+                                       hit.Intensity));
+                Cell kind = new Cell(hit.TypeName);
+                kind.Tag = hit.TypeKind;           // цвет бейджа — по коду, не по подписи
+                row.Cells.Add(kind);
+                row.Cells.Add(new Cell(HalfLifeLabel(hit.HalfLife)));
+                // тег ячейки — признак «есть что нажимать»: у добавленного нуклида
+                // кнопки нет, как и на странице, вместо неё подпись «в наборе»
+                Cell action = new Cell(added ? RoiWizardStrings.nearAdded
+                                             : RoiWizardStrings.buttonNearAdd_Text);
+                action.Tag = added ? null : (object)hit;
+                row.Cells.Add(action);
+                row.Tag = hit;
+                this.tableModelNear.Rows.Add(row);
+            }
+            this.tableNear.ResumeLayout();
+            this.LayoutNearColumns();
+
+            if (this.nearHits.Count == 0)
+            {
+                this.labelNearHint.Text = string.Format(CultureInfo.CurrentCulture,
+                    this.nearEmptyFormat, energy, window);
+            }
+            else if (this.nearHits.Count > shown)
+            {
+                this.labelNearHint.Text = string.Format(CultureInfo.CurrentCulture,
+                    RoiWizardStrings.nearMoreFormat, shown, this.nearHits.Count);
+            }
+            else
+            {
+                this.labelNearHint.Text = "";
+            }
+        }
+
+        // Столько же строк, сколько показывает страница: дальше по списку идут линии,
+        // отстоящие от заданной энергии сильнее любой из показанных.
+        const int NearHitLimit = 40;
+
+        void AddFromNearby(NearHit hit)
+        {
+            if (hit.XrfSymbol != null)
+            {
+                this.selection.XrfElements.Add(hit.XrfSymbol);
+                for (int i = 0; i < this.xrfSymbols.Count; i++)
+                {
+                    if (string.Equals(this.xrfSymbols[i], hit.XrfSymbol, StringComparison.Ordinal))
+                    {
+                        this.checkedXrf.SetItemChecked(i, true);
+                    }
+                }
+            }
+            else
+            {
+                this.selection.Add(this.catalog, hit.Nuclide, AddMode.Single);
+            }
+            this.RefreshGroupList();
+            this.Rebuild();
+            this.SearchNearby();
+        }
+
+        sealed class NearHit
+        {
+            public readonly string Nuclide;
+            public readonly double Energy;
+            public readonly double Intensity;
+            public readonly string TypeName;     // подпись бейджа: она переводится
+            public readonly string TypeKind;     // код типа: по нему берётся цвет
+            public readonly string HalfLife;
+            public readonly string XrfSymbol;
+
+            public NearHit(string nuclide, double energy, double intensity,
+                           string typeName, string typeKind, string halfLife, string xrfSymbol)
+            {
+                this.Nuclide = nuclide;
+                this.Energy = energy;
+                this.Intensity = intensity;
+                this.TypeName = typeName;
+                this.TypeKind = typeKind;
+                this.HalfLife = string.IsNullOrEmpty(halfLife) ? "—" : halfLife;
+                this.XrfSymbol = xrfSymbol;
+            }
+        }
+
+        readonly List nearHits = new List();
+
+        void SetColorMode(bool byChain)
+        {
+            this.colorByChain = byChain;
+            this.buttonColorByChain.Enabled = !byChain;
+            this.buttonColorByNuclide.Enabled = byChain;
+            this.RefreshColorChips();
+        }
+
+        void SetVisibleSelected(bool value)
+        {
+            foreach (Row row in this.tableModelLines.Rows)
+            {
+                SpectralLine line = row.Tag as SpectralLine;
+                if (line != null)
+                {
+                    line.Selected = value;
+                }
+            }
+            this.RefreshLines();
+        }
+
+        // ─── слияние ────────────────────────────────────────────────────────
+
+        void MergeLines()
+        {
+            if (this.lines.Count == 0)
+            {
+                return;
+            }
+            if (this.beforeMerge == null)
+            {
+                this.beforeMerge = new List(this.lines);
+            }
+            LineMerger merger = new LineMerger(this.Resolution, (double)this.numFactor.Value);
+            this.lines = merger.Merge(this.beforeMerge);
+            this.RefreshLines();
+            this.statusLabel.Text = string.Format(CultureInfo.CurrentCulture,
+                "merged groups: {0}, lines absorbed: {1}", merger.MergedGroups, merger.AbsorbedLines);
+        }
+
+        void UnmergeLines()
+        {
+            if (this.beforeMerge == null)
+            {
+                return;
+            }
+            this.lines = new List(this.beforeMerge);
+            this.beforeMerge = null;
+            this.RefreshLines();
+        }
+
+        void UpdateMergeInfo()
+        {
+            if (this.suspendEvents)
+            {
+                return;
+            }
+            LineMerger merger = new LineMerger(this.Resolution, (double)this.numFactor.Value);
+            this.labelMergeInfo.Text = string.Format(CultureInfo.CurrentCulture, this.mergeInfoFormat,
+                this.numFactor.Value, merger.ThresholdAt(100), merger.ThresholdAt(662), merger.ThresholdAt(1500));
+        }
+
+        void TakeResolutionFromSpectrum()
+        {
+            if (this.resolutionProvider == null)
+            {
+                return;
+            }
+            double value = this.resolutionProvider();
+            if (value > 0)
+            {
+                this.numResolution.Value = Math.Min(this.numResolution.Maximum,
+                    Math.Max(this.numResolution.Minimum, (decimal)value));
+            }
+            else
+            {
+                MessageBox.Show(this, RoiWizardStrings.noResolutionFromSpectrum,
+                    this.Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
+            }
+        }
+
+        // ─── шаг 3 ──────────────────────────────────────────────────────────
+
+        void SyncZoneControls()
+        {
+            bool zones = this.comboStyle.SelectedIndex != 0;
+            this.comboWidthMode.Enabled = zones;
+            this.numZonePercent.Enabled = zones && this.comboWidthMode.SelectedIndex == 0;
+            this.numZoneFactor.Enabled = zones && this.comboWidthMode.SelectedIndex == 1;
+            this.ApplyExporterSettings();
+        }
+
+        void ApplyExporterSettings()
+        {
+            this.zones = new ZoneCalculator(this.Resolution);
+            this.zones.Style = (RoiStyle)this.comboStyle.SelectedIndex;
+            this.zones.WidthMode = (ZoneWidthMode)Math.Max(0, this.comboWidthMode.SelectedIndex);
+            this.zones.ZonePercent = (double)this.numZonePercent.Value;
+            this.zones.ZoneFwhmFactor = (double)this.numZoneFactor.Value;
+            this.exporter = new SetExporter(this.Resolution, this.zones);
+        }
+
+        void RefreshAnchorCombo()
+        {
+            this.comboAnchor.Items.Clear();
+            // Кандидаты держатся списком, а не вычисляются по индексу заново: список
+            // выбранных линий меняется галками, и индекс в комбобоксе иначе съезжает
+            // на соседнюю линию. ХРИ и вторичные маркеры в кандидаты не попадают —
+            // якорь на них означал бы опору с условным положением или интенсивностью.
+            this.anchorCandidates.Clear();
+            SpectralLine automatic = AnchorPicker.Pick(this.SelectedLines(), this.Resolution);
+            this.comboAnchor.Items.Add(automatic != null
+                ? string.Format(CultureInfo.CurrentCulture, RoiWizardStrings.anchorAuto,
+                    automatic.Label, automatic.Energy.ToString("0.0", CultureInfo.CurrentCulture))
+                : "auto");
+            foreach (SpectralLine line in this.SelectedLines())
+            {
+                if (!AnchorPicker.IsAcceptable(line))
+                {
+                    continue;
+                }
+                this.anchorCandidates.Add(line);
+                this.comboAnchor.Items.Add(line.Label + " " + line.Energy.ToString("0.0", CultureInfo.CurrentCulture));
+            }
+            this.comboAnchor.SelectedIndex = 0;
+        }
+
+        readonly List anchorCandidates = new List();
+        readonly List groupMembers = new List();
+        readonly bool russian =
+            Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "ru";
+        string mergeInfoFormat { get { return RoiWizardStrings.mergeInfoFormat; } }
+        string statusFormat { get { return RoiWizardStrings.statusFormat; } }
+        string secondaryFormat { get { return RoiWizardStrings.secondaryFormat; } }
+        string nearEmptyFormat { get { return RoiWizardStrings.nearEmptyFormat; } }
+        bool suppressGroupCheck;
+        string hintPicked { get { return RoiWizardStrings.hintPicked; } }
+        string hintNone { get { return RoiWizardStrings.hintNone; } }
+
+        void SyncSetControls()
+        {
+            this.comboAnchor.Enabled = !this.checkFullSet.Checked;
+            this.labelAnchor.Enabled = !this.checkFullSet.Checked;
+        }
+
+        List SelectedLines()
+        {
+            List result = new List();
+            foreach (SpectralLine line in this.lines)
+            {
+                if (line.Selected)
+                {
+                    result.Add(line);
+                }
+            }
+            return result;
+        }
+
+        SpectralLine CurrentAnchor()
+        {
+            int index = this.comboAnchor.SelectedIndex;
+            if (index <= 0)
+            {
+                return null;                       // 0 — автоматический выбор
+            }
+            return index - 1 < this.anchorCandidates.Count ? this.anchorCandidates[index - 1] : null;
+        }
+
+        // Ядро отдаёт код замечания и подстановки, фразу собирает форма — иначе
+        // тексты проверок пришлось бы держать в ядре и они не переводились бы.
+        static string Describe(SetIssue issue)
+        {
+            string format;
+            switch (issue.Kind)
+            {
+                case IssueKind.EqualEnergies: format = RoiWizardStrings.issueEqualEnergies; break;
+                case IssueKind.ZeroYield: format = RoiWizardStrings.issueZeroYield; break;
+                case IssueKind.AnchorIsXrf: format = RoiWizardStrings.issueAnchorIsXrf; break;
+                case IssueKind.AnchorIsSecondary: format = RoiWizardStrings.issueAnchorIsSecondary; break;
+                case IssueKind.NoAnchor: format = RoiWizardStrings.issueNoAnchor; break;
+                case IssueKind.AnchorIsXray: format = RoiWizardStrings.issueAnchorIsXray; break;
+                default: format = RoiWizardStrings.issueZonesOverlap; break;
+            }
+            return issue.Args == null || issue.Args.Length == 0
+                ? format
+                : string.Format(CultureInfo.CurrentCulture, format, issue.Args);
+        }
+
+        void RunChecks()
+        {
+            this.ApplyExporterSettings();
+            this.listIssues.BeginUpdate();
+            this.listIssues.Items.Clear();
+            foreach (SetIssue issue in SetChecker.Check(this.lines, false, this.zones))
+            {
+                this.listIssues.Items.Add(RoiWizardStrings.issuePrefixRoi + " · " + Describe(issue));
+            }
+            // проверяется то, что реально уйдёт в библиотеку: при «полном наборе» это не
+            // содержимое таблицы, а все линии источников
+            SpectralLine manual = this.checkFullSet.Checked ? null : this.CurrentAnchor();
+            List manualAnchors = null;
+            if (manual != null)
+            {
+                manualAnchors = new List();
+                manualAnchors.Add(manual);
+            }
+            foreach (SetIssue issue in SetChecker.Check(this.LibraryLines(), true, this.zones,
+                                                        this.Resolution, manualAnchors))
+            {
+                if (issue.Level == IssueLevel.Error)
+                {
+                    this.listIssues.Items.Add(RoiWizardStrings.issuePrefixSet + " · " + Describe(issue));
+                }
+            }
+            if (this.listIssues.Items.Count == 0)
+            {
+                this.listIssues.Items.Add(RoiWizardStrings.issueNone);
+            }
+            this.listIssues.EndUpdate();
+        }
+
+        // Что именно ляжет в файл — тем же сериализатором, каким пишет ROIConfigManager.
+        // Собирать «похожий» XML руками нельзя: предпросмотр показывал бы не тот текст.
+        void PreviewXml()
+        {
+            List selected = this.SelectedLines();
+            if (selected.Count == 0)
+            {
+                this.textPreview.Text = this.previewEmpty;
+                return;
+            }
+            this.ApplyExporterSettings();
+            ROIConfigData built = this.exporter.BuildRoiConfig(this.lines, this.textConfigName.Text,
+                                                              this.ColorOfLine);
+            // без шапки с замечаниями: они уже перечислены в панели «Проверка данных»
+            // прямо над этим полем, а на странице такой панели нет — там шапка и нужна
+            this.textPreview.Text = Serialize(built);
+            this.textPreview.Select(0, 0);
+        }
+
+        static string Serialize(ROIConfigData config)
+        {
+            XmlSerializer serializer = new XmlSerializer(typeof(ROIConfigData));
+            using (MemoryStream stream = new MemoryStream())
+            {
+                serializer.Serialize(stream, config);
+                // читаем байты, а не StringWriter: тот объявил бы кодировку utf-16,
+                // тогда как в файл уходит utf-8
+                return new UTF8Encoding(false).GetString(stream.ToArray()).TrimStart('\uFEFF');
+            }
+        }
+
+        string previewEmpty { get { return RoiWizardStrings.previewEmpty; } }
+
+        void CreateRoiConfig()
+        {
+            List selected = this.SelectedLines();
+            if (selected.Count == 0)
+            {
+                MessageBox.Show(this, RoiWizardStrings.noLinesSelected, this.Text,
+                    MessageBoxButtons.OK, MessageBoxIcon.Information);
+                return;
+            }
+            this.ApplyExporterSettings();
+
+            List issues = SetChecker.Check(this.lines, false, this.zones);
+            if (issues.Count > 0 && !this.Confirm(issues, false))
+            {
+                return;
+            }
+
+            ROIConfigData built = this.exporter.BuildRoiConfig(this.lines, this.textConfigName.Text,
+                                                              this.ColorOfLine);
+            // SaveConfig пишет файл по Filename, поэтому вторая конфигурация с тем же именем
+            // молча затрёт файл первой
+            if (!this.ConfirmOverwriteRoi(built.Name))
+            {
+                return;
+            }
+
+            // Регистрировать конфигурацию обязан сам менеджер: CreateConfig кладёт её и в
+            // ROIConfigList, и в ROIConfigMap, и поднимает ROIConfigListChanged. Простое
+            // добавление в список оставило бы карту пустой, а SaveConfig начинается с
+            // roiConfigMap[Guid] — то есть упал бы KeyNotFoundException.
+            ROIConfigManager manager = ROIConfigManager.GetInstance();
+            ROIConfigData config = manager.CreateConfig(SafeFileName(built.Name) + ".xml");
+            if (config == null)
+            {
+                return;                                  // менеджер уже показал сообщение об ошибке
+            }
+            config.Name = built.Name;
+            config.ROIDefinitions.Clear();
+            config.ROIDefinitions.AddRange(built.ROIDefinitions);
+            manager.SaveConfig(config);
+
+            this.statusLabel.Text = string.Format(CultureInfo.CurrentCulture,
+                "ROI configuration «{0}» created: {1} regions", config.Name, config.ROIDefinitions.Count);
+        }
+
+        // Что уходит в библиотеку: либо выбранное в таблице, либо полный набор — все линии
+        // источников минуя галки, фильтры и слияние (профиль для библиотечного фита).
+        List LibraryLines()
+        {
+            return this.checkFullSet.Checked
+                ? this.builder.BuildFullSet(this.selection)
+                : this.lines;
+        }
+
+        void CreateNuclideSet()
+        {
+            List library = this.LibraryLines();
+            if (Count(library) == 0)
+            {
+                MessageBox.Show(this, RoiWizardStrings.noLinesSelected, this.Text,
+                    MessageBoxButtons.OK, MessageBoxIcon.Information);
+                return;
+            }
+
+            // Ручной якорь уходит один — его выбрал пользователь. При автовыборе
+            // помечается несколько линий: LibraryPeakFitter требует, чтобы с найденным
+            // пиком совпала хотя бы одна, и единственный якорь делает набор хрупким.
+            SpectralLine manual = this.checkFullSet.Checked ? null : this.CurrentAnchor();
+            List anchors = null;
+            if (manual != null)
+            {
+                anchors = new List();
+                anchors.Add(manual);
+            }
+            int anchorCount = (int)this.numAnchors.Value;
+
+            // для набора совпавшие энергии и нулевая интенсивность — ошибки: две линии на
+            // одной позиции вырождают подгонку амплитуд, а Intencity = 0 выбрасывает линию
+            // из связки по цепочке
+            List issues = SetChecker.Check(library, true, this.zones, this.Resolution, anchors);
+            List errors = issues.FindAll(delegate(SetIssue i) { return i.Level == IssueLevel.Error; });
+            if (errors.Count > 0)
+            {
+                this.Confirm(errors, true);
+                return;
+            }
+
+            // повторное нажатие добавило бы в библиотеку полный дубль записей
+            if (!this.ConfirmDuplicateSet(this.textSetName.Text))
+            {
+                return;
+            }
+
+            List definitions;
+            NuclideSet set = this.exporter.BuildNuclideSet(library, this.textSetName.Text, this.ColorOfLine,
+                                                           anchors, anchorCount, out definitions);
+
+            NuclideDefinitionManager manager = NuclideDefinitionManager.GetInstance();
+            manager.NuclideSets.Add(set);
+            manager.NuclideDefinitions.AddRange(definitions);
+            manager.SaveDefinitionFile();
+
+            int marked = definitions.FindAll(delegate(NuclideDefinition d) { return d.IsAnchor; }).Count;
+            this.statusLabel.Text = string.Format(CultureInfo.CurrentCulture,
+                "set «{0}» added to the library: {1} lines, {2} anchor(s)",
+                set.Name, definitions.Count, marked);
+        }
+
+        static int Count(List lines)
+        {
+            int count = 0;
+            foreach (SpectralLine line in lines)
+            {
+                if (line.Selected)
+                {
+                    count++;
+                }
+            }
+            return count;
+        }
+
+        bool ConfirmOverwriteRoi(string name)
+        {
+            string filename = SafeFileName(name) + ".xml";
+            foreach (ROIConfigData existing in ROIConfigManager.GetInstance().ROIConfigList)
+            {
+                if (string.Equals(existing.Filename, filename, StringComparison.OrdinalIgnoreCase))
+                {
+                    return MessageBox.Show(this,
+                        string.Format(CultureInfo.CurrentCulture,
+                            RoiWizardStrings.confirmRoiOverwrite, name),
+                        this.Text, MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.Yes;
+                }
+            }
+            return true;
+        }
+
+        bool ConfirmDuplicateSet(string name)
+        {
+            foreach (NuclideSet existing in NuclideDefinitionManager.GetInstance().NuclideSets)
+            {
+                if (string.Equals(existing.Name, name, StringComparison.OrdinalIgnoreCase))
+                {
+                    return MessageBox.Show(this,
+                        string.Format(CultureInfo.CurrentCulture,
+                            RoiWizardStrings.confirmSetDuplicate, name),
+                        this.Text, MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.Yes;
+                }
+            }
+            return true;
+        }
+
+        bool Confirm(List issues, bool blocking)
+        {
+            StringBuilder text = new StringBuilder();
+            text.AppendLine(blocking
+                ? RoiWizardStrings.confirmErrorsHead
+                : RoiWizardStrings.confirmIssuesHead);
+            text.AppendLine();
+            for (int i = 0; i < issues.Count && i < 8; i++)
+            {
+                text.AppendLine("• " + Describe(issues[i]));
+            }
+            if (issues.Count > 8)
+            {
+                text.AppendLine("…");
+            }
+            if (blocking)
+            {
+                text.AppendLine();
+                text.AppendLine(RoiWizardStrings.confirmErrorsTail);
+                MessageBox.Show(this, text.ToString(), this.Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
+                return false;
+            }
+            text.AppendLine();
+            text.Append(RoiWizardStrings.confirmSaveAnyway);
+            return MessageBox.Show(this, text.ToString(), this.Text,
+                MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes;
+        }
+
+        // цвет по нуклиду: одинаковые для линий одного источника, чтобы набор читался
+        // цвет назначается «владельцу»: цепочке или нуклиду — как в вебе
+        readonly Dictionary colors = new Dictionary();
+        bool colorByChain = true;
+
+        string OwnerOf(SpectralLine line)
+        {
+            if (line.Type == LineType.Xrf)
+            {
+                return line.Nuclide;                       // ХРИ всегда красятся по элементу
+            }
+            if (!this.colorByChain)
+            {
+                return line.Nuclide;
+            }
+            CatalogNuclide nuclide = this.catalog.Find(line.Nuclide);
+            string root = nuclide != null ? this.catalog.ChainRoot(nuclide) : null;
+            return string.IsNullOrEmpty(root) ? line.Nuclide : root;
+        }
+
+        Color ColorForOwner(string owner)
+        {
+            Color color;
+            if (!this.colors.TryGetValue(owner, out color))
+            {
+                color = Palette[this.colors.Count % Palette.Length];
+                this.colors[owner] = color;
+            }
+            return color;
+        }
+
+        // Чипы владельцев: квадрат цвета и подпись; клик по квадрату — выбор цвета,
+        // как «input type=color» на странице.
+        void RefreshColorChips()
+        {
+            List owners = new List();
+            foreach (SpectralLine line in this.lines)
+            {
+                string owner = this.OwnerOf(line);
+                if (!owners.Contains(owner))
+                {
+                    owners.Add(owner);
+                }
+            }
+            // Полоса чипов живёт строкой кнопок режима: размеры задаются здесь, уже после
+            // автомасштаба формы, поэтому считаются от фактической высоты кнопки, а не
+            // от чисел разметки — иначе чипы встают выше центра.
+            int row = this.buttonColorByChain.Height;
+            this.panelColors.Top = this.buttonColorByChain.Top;
+            this.panelColors.WrapContents = true;      // .colorchips{flex-wrap:wrap}
+            this.panelColors.AutoScroll = false;
+            this.panelColors.Height = row * MaxColorRows;
+
+            this.panelColors.SuspendLayout();
+            this.panelColors.Controls.Clear();
+            foreach (string owner in owners)
+            {
+                this.panelColors.Controls.Add(this.ColorChip(owner, row - 4));
+            }
+            this.panelColors.ResumeLayout(true);
+
+            // высота — по факту раскладки: сколько строк заняли чипы, столько и берём
+            int bottom = row;
+            foreach (Control chip in this.panelColors.Controls)
+            {
+                bottom = Math.Max(bottom, chip.Bottom + chip.Margin.Bottom);
+            }
+            if (bottom > row * MaxColorRows)
+            {
+                bottom = row * MaxColorRows;
+                this.panelColors.AutoScroll = true;    // владельцев больше, чем строк
+            }
+            this.panelColors.Height = bottom;
+            this.LayoutExport();
+        }
+
+        const int MaxColorRows = 3;
+
+        // Чип цвета — коробочка .cchip: фон --chip, рамка --line, квадрат цвета
+        // и подпись владельца. Щелчок по чипу открывает выбор цвета.
+        Control ColorChip(string owner, int height)
+        {
+            const int Side = 18;
+            int textWidth = TextRenderer.MeasureText(owner, this.panelColors.Font).Width;
+
+            Panel chip = new Panel();
+            chip.Size = new Size(6 + Side + 6 + textWidth + 6, height);
+            chip.Margin = new Padding(0, 2, 5, 2);
+            chip.BackColor = WizardTheme.Chip;
+            chip.Cursor = Cursors.Hand;
+            string captured = owner;
+            chip.Click += delegate { this.PickColor(captured, chip); };
+            chip.Paint += delegate(object sender, PaintEventArgs e)
+            {
+                Control box = (Control)sender;
+                using (Pen pen = new Pen(WizardTheme.Line))
+                {
+                    e.Graphics.DrawRectangle(pen, 0, 0, box.Width - 1, box.Height - 1);
+                }
+                Rectangle square = new Rectangle(6, (box.Height - Side) / 2, Side, Side);
+                using (SolidBrush brush = new SolidBrush(this.ColorForOwner(captured)))
+                {
+                    e.Graphics.FillRectangle(brush, square);
+                }
+                using (Pen pen = new Pen(WizardTheme.Line))
+                {
+                    e.Graphics.DrawRectangle(pen, square);
+                }
+                TextRenderer.DrawText(e.Graphics, captured, this.panelColors.Font,
+                    new Rectangle(square.Right + 6, 0, box.Width - square.Right - 8, box.Height),
+                    WizardTheme.Ink, TextFormatFlags.Left | TextFormatFlags.VerticalCenter);
+            };
+            return chip;
+        }
+
+        // Вкладка «Оформление и экспорт» складывается сверху вниз: полоса цветов
+        // растёт на вторую строку, и всё под ней должно съезжать.
+        void LayoutExport()
+        {
+            int width = this.tabExport.ClientSize.Width;
+            int height = this.tabExport.ClientSize.Height;
+            if (width < 120 || height < 160)
+            {
+                return;
+            }
+            const int Pad = 8;
+            const int Gap = 6;
+            this.groupStyle.Height = this.panelColors.Bottom + this.panelColors.Margin.Bottom + 12;
+            int y = 6;
+            this.groupStyle.SetBounds(Pad, y, width - Pad * 2, this.groupStyle.Height);
+            y += this.groupStyle.Height + Gap;
+            this.groupExport.SetBounds(Pad, y, width - Pad * 2, this.groupExport.Height);
+            y += this.groupExport.Height + Gap;
+            this.labelIssues.Location = new Point(Pad + 4, y);
+            y += this.labelIssues.Height + 2;
+            this.listIssues.SetBounds(Pad, y, width - Pad * 2, this.listIssues.Height);
+            y += this.listIssues.Height + Gap;
+            int rest = height - Pad - y;
+            if (rest < 60)
+            {
+                rest = 60;
+            }
+            this.textPreview.SetBounds(Pad, y, width - Pad * 2, rest);
+        }
+
+        void PickColor(string owner, Panel swatch)
+        {
+            using (ColorDialog dialog = new ColorDialog())
+            {
+                dialog.Color = this.ColorForOwner(owner);
+                dialog.FullOpen = true;
+                if (dialog.ShowDialog(this) == DialogResult.OK)
+                {
+                    this.colors[owner] = dialog.Color;
+                    swatch.BackColor = dialog.Color;
+                }
+            }
+        }
+
+        static readonly Color[] Palette = {
+            Color.FromArgb(230, 130, 30), Color.FromArgb(192, 53, 53), Color.FromArgb(46, 125, 50),
+            Color.FromArgb(21, 101, 192), Color.FromArgb(123, 31, 162), Color.FromArgb(0, 131, 143),
+            Color.FromArgb(158, 122, 16), Color.FromArgb(216, 27, 96), Color.FromArgb(93, 64, 55)
+        };
+
+        Color ColorOfLine(SpectralLine line)
+        {
+            return this.ColorForOwner(this.OwnerOf(line));
+        }
+
+        static Color ColorOf(SpectralLine line)
+        {
+            int hash = 0;
+            string key = line.Nuclide ?? "";
+            for (int i = 0; i < key.Length; i++)
+            {
+                hash = (hash * 31 + key[i]) & 0x7fffffff;
+            }
+            return Palette[hash % Palette.Length];
+        }
+
+        static string SafeFileName(string name)
+        {
+            StringBuilder result = new StringBuilder();
+            foreach (char c in name ?? "")
+            {
+                result.Append(Array.IndexOf(System.IO.Path.GetInvalidFileNameChars(), c) >= 0 ? '_' : c);
+            }
+            string text = result.ToString().Trim();
+            return text.Length > 0 ? text : "ROI set";
+        }
+
+        // Справка — тот же текст, что в модальном окне страницы; он лежит ресурсом,
+        // выгруженным из index.html, поэтому расходиться версиям негде.
+        void ShowHelp()
+        {
+            using (HelpForm help = new HelpForm())
+            {
+                help.ShowDialog(this);
+            }
+        }
+
+        // Кнопки внизу подписываются именами соседних шагов, а на краях — обобщённо
+        // и выключены. То же поведение, что у пары кнопок в строке состояния страницы.
+        void GoStep(int delta)
+        {
+            int target = this.tabs.SelectedIndex + delta;
+            if (target >= 0 && target < this.tabs.TabCount)
+            {
+                this.tabs.SelectedIndex = target;
+            }
+        }
+
+        void UpdateStepButtons()
+        {
+            int current = this.tabs.SelectedIndex;
+            this.buttonStepPrev.Enabled = current > 0;
+            this.buttonStepNext.Enabled = current < this.tabs.TabCount - 1;
+            this.buttonStepPrev.Text = current > 0
+                ? "◂ " + this.stepNames[current - 1]
+                : this.stepBack;
+            this.buttonStepNext.Text = current < this.tabs.TabCount - 1
+                ? this.stepNames[current + 1] + " ▸"
+                : this.stepForward;
+        }
+
+        string[] stepNames
+        {
+            get
+            {
+                return new string[] {
+                    RoiWizardStrings.stepNuclides, RoiWizardStrings.stepLines,
+                    RoiWizardStrings.stepExport };
+            }
+        }
+        string stepBack { get { return RoiWizardStrings.stepBack; } }
+        string stepForward { get { return RoiWizardStrings.stepForward; } }
+
+        void UpdateStatus()
+        {
+            int selected = 0;
+            Dictionary nuclides = new Dictionary();
+            foreach (SpectralLine line in this.lines)
+            {
+                if (line.Selected)
+                {
+                    selected++;
+                    nuclides[line.Nuclide] = true;
+                }
+            }
+            this.statusLabel.Text = string.Format(CultureInfo.CurrentCulture, this.statusFormat,
+                selected, this.lines.Count, nuclides.Count);
+        }
+
+    }
+}
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs b/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs
new file mode 100644
index 0000000..406863b
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs
@@ -0,0 +1,821 @@
+//------------------------------------------------------------------------------
+// Сгенерировано integration/tools/gen_strings.py из RoiWizardStrings.resx.
+// Править этот файл руками не нужно: правьте таблицу в gen_strings.py.
+//------------------------------------------------------------------------------
+
+using System.Globalization;
+using System.Resources;
+
+namespace BecquerelMonitor.RoiWizard
+{
+    // Подписи интерфейса модуля. Нейтральная таблица английская, рядом
+    // RoiWizardStrings.ru.resx; MSBuild собирает сателлит, как и для остальных
+    // форм BecqMoni. Новый язык — ещё один .resx, код не трогается.
+    //
+    // Если ресурс недоступен (тесты ядра собираются консольным компилятором
+    // без ресурсов), возвращается английский текст, зашитый сюда генератором.
+    internal static class RoiWizardStrings
+    {
+        static ResourceManager manager;
+
+        static string Get(string key, string fallback)
+        {
+            try
+            {
+                if (manager == null)
+                {
+                    manager = new ResourceManager(
+                        "BecquerelMonitor.RoiWizard.RoiWizardStrings",
+                        typeof(RoiWizardStrings).Assembly);
+                }
+                string value = manager.GetString(key, CultureInfo.CurrentUICulture);
+                return value ?? fallback;
+            }
+            catch (MissingManifestResourceException)
+            {
+                return fallback;
+            }
+        }
+
+        public static string form_Title
+        {
+            get { return Get("form_Title", "ROI and nuclide set builder"); }
+        }
+
+        public static string tabSources_Text
+        {
+            get { return Get("tabSources_Text", "1 · Nuclides"); }
+        }
+
+        public static string tabLines_Text
+        {
+            get { return Get("tabLines_Text", "2 · Lines"); }
+        }
+
+        public static string tabExport_Text
+        {
+            get { return Get("tabExport_Text", "3 · Styling and export"); }
+        }
+
+        public static string statusFormat
+        {
+            get { return Get("statusFormat", "lines: {0} of {1} · nuclides: {2}"); }
+        }
+
+        public static string buttonHelp_Text
+        {
+            get { return Get("buttonHelp_Text", "Help"); }
+        }
+
+        public static string stepBack
+        {
+            get { return Get("stepBack", "◂ Back"); }
+        }
+
+        public static string stepForward
+        {
+            get { return Get("stepForward", "Next ▸"); }
+        }
+
+        public static string stepNuclides
+        {
+            get { return Get("stepNuclides", "Nuclides"); }
+        }
+
+        public static string stepLines
+        {
+            get { return Get("stepLines", "Lines"); }
+        }
+
+        public static string stepExport
+        {
+            get { return Get("stepExport", "Styling and export"); }
+        }
+
+        public static string groupSearch_Text
+        {
+            get { return Get("groupSearch_Text", "Nuclide search"); }
+        }
+
+        public static string buttonAddSingle_Text
+        {
+            get { return Get("buttonAddSingle_Text", "Add"); }
+        }
+
+        public static string buttonAddFamily_Text
+        {
+            get { return Get("buttonAddFamily_Text", "+ family"); }
+        }
+
+        public static string buttonAddChain_Text
+        {
+            get { return Get("buttonAddChain_Text", "+ chain"); }
+        }
+
+        public static string columnCatalogName_Text
+        {
+            get { return Get("columnCatalogName_Text", "Nuclide"); }
+        }
+
+        public static string columnCatalogFamilies_Text
+        {
+            get { return Get("columnCatalogFamilies_Text", "Families"); }
+        }
+
+        public static string columnCatalogLines_Text
+        {
+            get { return Get("columnCatalogLines_Text", "Lines"); }
+        }
+
+        public static string labelSearchHint_Text
+        {
+            get { return Get("labelSearchHint_Text", "Typing narrows the list: by name or by family code."); }
+        }
+
+        public static string presetsCaption
+        {
+            get { return Get("presetsCaption", "Presets:"); }
+        }
+
+        public static string preset1_Title
+        {
+            get { return Get("preset1_Title", "NORM background"); }
+        }
+
+        public static string preset1_Hint
+        {
+            get { return Get("preset1_Hint", "Th-232 + U-238 as chains + K-40"); }
+        }
+
+        public static string preset2_Title
+        {
+            get { return Get("preset2_Title", "Cs-137 / Co-60 check"); }
+        }
+
+        public static string preset2_Hint
+        {
+            get { return Get("preset2_Hint", "Reference check sources"); }
+        }
+
+        public static string preset3_Title
+        {
+            get { return Get("preset3_Title", "Calibration set"); }
+        }
+
+        public static string preset3_Hint
+        {
+            get { return Get("preset3_Hint", "Am-241, Ba-133, Eu-152, Cs-137, Co-60"); }
+        }
+
+        public static string preset4_Title
+        {
+            get { return Get("preset4_Title", "Medical"); }
+        }
+
+        public static string preset4_Hint
+        {
+            get { return Get("preset4_Hint", "MED family"); }
+        }
+
+        public static string preset5_Title
+        {
+            get { return Get("preset5_Title", "Detector and shield XRF"); }
+        }
+
+        public static string preset5_Hint
+        {
+            get { return Get("preset5_Hint", "Pb, W, La, Ba, I"); }
+        }
+
+        public static string groupGroup_Text
+        {
+            get { return Get("groupGroup_Text", "Group"); }
+        }
+
+        public static string buttonGroupAll_Text
+        {
+            get { return Get("buttonGroupAll_Text", "add all"); }
+        }
+
+        public static string buttonGroupFamily_Text
+        {
+            get { return Get("buttonGroupFamily_Text", "+ family lines"); }
+        }
+
+        public static string buttonGroupChain_Text
+        {
+            get { return Get("buttonGroupChain_Text", "+ chain"); }
+        }
+
+        public static string hintNone
+        {
+            get { return Get("hintNone", "Tick a nuclide — the buttons apply to it."); }
+        }
+
+        public static string hintPicked
+        {
+            get { return Get("hintPicked", "Applies to the ticked ones ({0})."); }
+        }
+
+        public static string groupXrf_Text
+        {
+            get { return Get("groupXrf_Text", "XRF elements"); }
+        }
+
+        public static string labelXrf_Text
+        {
+            get { return Get("labelXrf_Text", "Shielding and detector materials:"); }
+        }
+
+        public static string labelXrfHint_Text
+        {
+            get { return Get("labelXrfHint_Text", "Kα/Kβ (+L for heavy elements). Intensities are nominal (Kα1 = 100) — markers only."); }
+        }
+
+        public static string groupSelected_Text
+        {
+            get { return Get("groupSelected_Text", "Selected"); }
+        }
+
+        public static string buttonClear_Text
+        {
+            get { return Get("buttonClear_Text", "clear all"); }
+        }
+
+        public static string xrfChipPrefix
+        {
+            get { return Get("xrfChipPrefix", "XRF "); }
+        }
+
+        public static string emptySelectionHint
+        {
+            get { return Get("emptySelectionHint", "empty — start with a group above"); }
+        }
+
+        public static string groupResolution_Text
+        {
+            get { return Get("groupResolution_Text", "Detector-resolution adaptation"); }
+        }
+
+        public static string labelResolution_Text
+        {
+            get { return Get("labelResolution_Text", "R, % at 662 keV"); }
+        }
+
+        public static string buttonFromSpectrum_Text
+        {
+            get { return Get("buttonFromSpectrum_Text", "from spectrum"); }
+        }
+
+        public static string labelCriterion_Text
+        {
+            get { return Get("labelCriterion_Text", "criterion"); }
+        }
+
+        public static string labelFactor_Text
+        {
+            get { return Get("labelFactor_Text", "× FWHM"); }
+        }
+
+        public static string buttonMerge_Text
+        {
+            get { return Get("buttonMerge_Text", "Merge close lines"); }
+        }
+
+        public static string buttonUnmerge_Text
+        {
+            get { return Get("buttonUnmerge_Text", "Restore originals"); }
+        }
+
+        public static string mergeInfoFormat
+        {
+            get { return Get("mergeInfoFormat", "threshold {0:0.##}·FWHM: lines merge closer than {1:0.#} keV at 100, {2:0.#} at 662, {3:0.#} at 1500"); }
+        }
+
+        public static string criterionSparrow
+        {
+            get { return Get("criterionSparrow", "Sparrow limit — ROI markers (0.85·FWHM)"); }
+        }
+
+        public static string criterionAnchored
+        {
+            get { return Get("criterionAnchored", "anchored set — library fit (0.25·FWHM)"); }
+        }
+
+        public static string criterionManual
+        {
+            get { return Get("criterionManual", "manual"); }
+        }
+
+        public static string groupFilters_Text
+        {
+            get { return Get("groupFilters_Text", "Filters and selection"); }
+        }
+
+        public static string checkIntensity_Text
+        {
+            get { return Get("checkIntensity_Text", "intensity ≥, %"); }
+        }
+
+        public static string intensityRelative
+        {
+            get { return Get("intensityRelative", "relative (within nuclide, max = 100)"); }
+        }
+
+        public static string intensityAbsolute
+        {
+            get { return Get("intensityAbsolute", "absolute (per decay)"); }
+        }
+
+        public static string checkEnergy_Text
+        {
+            get { return Get("checkEnergy_Text", "energy, keV"); }
+        }
+
+        public static string checkHalfLife_Text
+        {
+            get { return Get("checkHalfLife_Text", "T½"); }
+        }
+
+        public static string buttonSelectAll_Text
+        {
+            get { return Get("buttonSelectAll_Text", "✓ select all visible"); }
+        }
+
+        public static string buttonSelectNone_Text
+        {
+            get { return Get("buttonSelectNone_Text", "✗ deselect all visible"); }
+        }
+
+        public static string labelTopN_Text
+        {
+            get { return Get("labelTopN_Text", "top-N by I per nuclide"); }
+        }
+
+        public static string buttonSelectTop_Text
+        {
+            get { return Get("buttonSelectTop_Text", "Select top-N"); }
+        }
+
+        public static string checkHideUnselected_Text
+        {
+            get { return Get("checkHideUnselected_Text", "hide unselected"); }
+        }
+
+        public static string labelTypes_Text
+        {
+            get { return Get("labelTypes_Text", "Line types"); }
+        }
+
+        public static string checkTypeXray_Text
+        {
+            get { return Get("checkTypeXray_Text", "X (decay)"); }
+        }
+
+        public static string checkTypeXrf_Text
+        {
+            get { return Get("checkTypeXrf_Text", "XRF"); }
+        }
+
+        public static string checkTypeSecondary_Text
+        {
+            get { return Get("checkTypeSecondary_Text", "secondary"); }
+        }
+
+        public static string checkEquilibrium_Text
+        {
+            get { return Get("checkEquilibrium_Text", "series equilibrium (intensities per parent decay)"); }
+        }
+
+        public static string unitSeconds
+        {
+            get { return Get("unitSeconds", "s"); }
+        }
+
+        public static string unitHours
+        {
+            get { return Get("unitHours", "h"); }
+        }
+
+        public static string unitDays
+        {
+            get { return Get("unitDays", "d"); }
+        }
+
+        public static string unitYears
+        {
+            get { return Get("unitYears", "y"); }
+        }
+
+        public static string hlSeconds
+        {
+            get { return Get("hlSeconds", "s"); }
+        }
+
+        public static string hlMinutes
+        {
+            get { return Get("hlMinutes", "min"); }
+        }
+
+        public static string hlHours
+        {
+            get { return Get("hlHours", "h"); }
+        }
+
+        public static string hlDays
+        {
+            get { return Get("hlDays", "d"); }
+        }
+
+        public static string hlYears
+        {
+            get { return Get("hlYears", "y"); }
+        }
+
+        public static string columnLineName_Text
+        {
+            get { return Get("columnLineName_Text", "Nuclide"); }
+        }
+
+        public static string columnLineEnergy_Text
+        {
+            get { return Get("columnLineEnergy_Text", "E, keV"); }
+        }
+
+        public static string columnLineIntensity_Text
+        {
+            get { return Get("columnLineIntensity_Text", "I, %"); }
+        }
+
+        public static string columnLineRelative_Text
+        {
+            get { return Get("columnLineRelative_Text", "I rel., %"); }
+        }
+
+        public static string columnLineHalfLife_Text
+        {
+            get { return Get("columnLineHalfLife_Text", "T½"); }
+        }
+
+        public static string columnLineType_Text
+        {
+            get { return Get("columnLineType_Text", "Type"); }
+        }
+
+        public static string lineTypeXrf
+        {
+            get { return Get("lineTypeXrf", "XRF"); }
+        }
+
+        public static string lineTypeSecondary
+        {
+            get { return Get("lineTypeSecondary", "sec"); }
+        }
+
+        public static string groupSecondary_Text
+        {
+            get { return Get("groupSecondary_Text", "Secondary peaks (computed from selected γ lines)"); }
+        }
+
+        public static string labelSecondaryMin_Text
+        {
+            get { return Get("labelSecondaryMin_Text", "for γ lines with I ≥, %"); }
+        }
+
+        public static string checkSecBackscatter_Text
+        {
+            get { return Get("checkSecBackscatter_Text", "backscatter (BS)"); }
+        }
+
+        public static string checkSecComptonEdge_Text
+        {
+            get { return Get("checkSecComptonEdge_Text", "Compton edge (CE)"); }
+        }
+
+        public static string checkSecSingleEscape_Text
+        {
+            get { return Get("checkSecSingleEscape_Text", "escape 511 (SE)"); }
+        }
+
+        public static string checkSecDoubleEscape_Text
+        {
+            get { return Get("checkSecDoubleEscape_Text", "escape 1022 (DE)"); }
+        }
+
+        public static string checkSecIodine_Text
+        {
+            get { return Get("checkSecIodine_Text", "I-K escape (NaI, −28.6)"); }
+        }
+
+        public static string checkSecAnnihilation_Text
+        {
+            get { return Get("checkSecAnnihilation_Text", "annihilation 511"); }
+        }
+
+        public static string checkSecSum_Text
+        {
+            get { return Get("checkSecSum_Text", "cascade sum (E1+E2)"); }
+        }
+
+        public static string checkSecPileUp_Text
+        {
+            get { return Get("checkSecPileUp_Text", "pile-up 2×E"); }
+        }
+
+        public static string buttonGenerateSecondary_Text
+        {
+            get { return Get("buttonGenerateSecondary_Text", "Generate"); }
+        }
+
+        public static string secondaryFormat
+        {
+            get { return Get("secondaryFormat", "secondary markers added: {0}"); }
+        }
+
+        public static string annihilationLabel
+        {
+            get { return Get("annihilationLabel", "Annihilation 511"); }
+        }
+
+        public static string groupNear_Text
+        {
+            get { return Get("groupNear_Text", "Nearby-line search (whole database — who else emits here)"); }
+        }
+
+        public static string labelNearEnergy_Text
+        {
+            get { return Get("labelNearEnergy_Text", "energy, keV"); }
+        }
+
+        public static string labelNearWindow_Text
+        {
+            get { return Get("labelNearWindow_Text", "± window"); }
+        }
+
+        public static string labelNearIntensity_Text
+        {
+            get { return Get("labelNearIntensity_Text", "I ≥, %"); }
+        }
+
+        public static string labelNearHalfLife_Text
+        {
+            get { return Get("labelNearHalfLife_Text", "T½ ≥"); }
+        }
+
+        public static string buttonNearSearch_Text
+        {
+            get { return Get("buttonNearSearch_Text", "Search"); }
+        }
+
+        public static string buttonNearAdd_Text
+        {
+            get { return Get("buttonNearAdd_Text", "+ add"); }
+        }
+
+        public static string columnNearDelta_Text
+        {
+            get { return Get("columnNearDelta_Text", "ΔE"); }
+        }
+
+        public static string nearAdded
+        {
+            get { return Get("nearAdded", "added"); }
+        }
+
+        public static string nearMoreFormat
+        {
+            get { return Get("nearMoreFormat", "showing the first {0} of {1}"); }
+        }
+
+        public static string nearEmptyFormat
+        {
+            get { return Get("nearEmptyFormat", "nothing found within {0} ± {1} keV"); }
+        }
+
+        public static string groupStyle_Text
+        {
+            get { return Get("groupStyle_Text", "ROI styling"); }
+        }
+
+        public static string labelStyle_Text
+        {
+            get { return Get("labelStyle_Text", "mode"); }
+        }
+
+        public static string labelWidth_Text
+        {
+            get { return Get("labelWidth_Text", "zone width"); }
+        }
+
+        public static string labelColors_Text
+        {
+            get { return Get("labelColors_Text", "Colours"); }
+        }
+
+        public static string buttonColorByChain_Text
+        {
+            get { return Get("buttonColorByChain_Text", "by chain"); }
+        }
+
+        public static string buttonColorByNuclide_Text
+        {
+            get { return Get("buttonColorByNuclide_Text", "by nuclide"); }
+        }
+
+        public static string roiStyleMarkers
+        {
+            get { return Get("roiStyleMarkers", "marker lines (height ∝ I, no zones)"); }
+        }
+
+        public static string roiStyleZones
+        {
+            get { return Get("roiStyleZones", "zones (limits around the peak)"); }
+        }
+
+        public static string roiStyleBoth
+        {
+            get { return Get("roiStyleBoth", "zones + intensity markers"); }
+        }
+
+        public static string widthModePercent
+        {
+            get { return Get("widthModePercent", "% of energy (BecqMoni style)"); }
+        }
+
+        public static string widthModeFwhm
+        {
+            get { return Get("widthModeFwhm", "k × FWHM (scintillator)"); }
+        }
+
+        public static string groupExport_Text
+        {
+            get { return Get("groupExport_Text", "Export"); }
+        }
+
+        public static string labelConfigName_Text
+        {
+            get { return Get("labelConfigName_Text", "ROI configuration name"); }
+        }
+
+        public static string buttonCreateRoi_Text
+        {
+            get { return Get("buttonCreateRoi_Text", "Create ROI configuration"); }
+        }
+
+        public static string buttonPreview_Text
+        {
+            get { return Get("buttonPreview_Text", "Preview"); }
+        }
+
+        public static string labelSetName_Text
+        {
+            get { return Get("labelSetName_Text", "set name (NuclideSet)"); }
+        }
+
+        public static string textSetName_Text
+        {
+            get { return Get("textSetName_Text", "IAEA set"); }
+        }
+
+        public static string labelAnchor_Text
+        {
+            get { return Get("labelAnchor_Text", "anchor line"); }
+        }
+
+        public static string buttonCreateSet_Text
+        {
+            get { return Get("buttonCreateSet_Text", "Add set to the library"); }
+        }
+
+        public static string checkFullSet_Text
+        {
+            get { return Get("checkFullSet_Text", "full set (all lines, for fitting)"); }
+        }
+
+        public static string labelAnchorCount_Text
+        {
+            get { return Get("labelAnchorCount_Text", "anchor lines"); }
+        }
+
+        public static string labelIssues_Text
+        {
+            get { return Get("labelIssues_Text", "Data check:"); }
+        }
+
+        public static string previewEmpty
+        {
+            get { return Get("previewEmpty", "no lines selected"); }
+        }
+
+        public static string anchorAuto
+        {
+            get { return Get("anchorAuto", "auto — {0} {1}"); }
+        }
+
+        public static string issuePrefixRoi
+        {
+            get { return Get("issuePrefixRoi", "ROI"); }
+        }
+
+        public static string issuePrefixSet
+        {
+            get { return Get("issuePrefixSet", "SET"); }
+        }
+
+        public static string issueNone
+        {
+            get { return Get("issueNone", "no issues"); }
+        }
+
+        public static string issueEqualEnergies
+        {
+            get { return Get("issueEqualEnergies", "equal energies: “{0}” and “{1}” ({2} / {3} keV) — the amplitude fit degenerates here"); }
+        }
+
+        public static string issueZeroYield
+        {
+            get { return Get("issueZeroYield", "zero yield: “{0}” ({1} keV)"); }
+        }
+
+        public static string issueAnchorIsXrf
+        {
+            get { return Get("issueAnchorIsXrf", "the anchor “{0}” ({1} keV) is a characteristic X-ray of a material, not a decay line: the fit would rest on a line whose position or intensity is nominal"); }
+        }
+
+        public static string issueAnchorIsSecondary
+        {
+            get { return Get("issueAnchorIsSecondary", "the anchor “{0}” ({1} keV) is a computed secondary marker, not a decay line: the fit would rest on a line whose position or intensity is nominal"); }
+        }
+
+        public static string issueNoAnchor
+        {
+            get { return Get("issueNoAnchor", "no anchor line: the set holds no decay line at all (XRF and secondary markers cannot be anchors) — the library fit does not start without one"); }
+        }
+
+        public static string issueAnchorIsXray
+        {
+            get { return Get("issueAnchorIsXray", "the anchor is the X-ray line “{0}” ({1} keV): a γ line is a firmer footing for the fit"); }
+        }
+
+        public static string issueZonesOverlap
+        {
+            get { return Get("issueZonesOverlap", "zones overlap: “{0}” [{1}–{2}] and “{3}” [{4}–{5}]"); }
+        }
+
+        public static string confirmTitle
+        {
+            get { return Get("confirmTitle", "ROI and nuclide set builder"); }
+        }
+
+        public static string confirmRoiOverwrite
+        {
+            get { return Get("confirmRoiOverwrite", "A configuration named “{0}” already exists — its file will be overwritten. Continue?"); }
+        }
+
+        public static string confirmSetDuplicate
+        {
+            get { return Get("confirmSetDuplicate", "The library already holds a set named “{0}”. Add another one with the same name?"); }
+        }
+
+        public static string noLinesSelected
+        {
+            get { return Get("noLinesSelected", "No lines selected."); }
+        }
+
+        public static string noResolutionFromSpectrum
+        {
+            get { return Get("noResolutionFromSpectrum", "The resolution could not be taken from the active spectrum."); }
+        }
+
+        public static string confirmErrorsHead
+        {
+            get { return Get("confirmErrorsHead", "The set cannot be saved — the data check found errors:"); }
+        }
+
+        public static string confirmIssuesHead
+        {
+            get { return Get("confirmIssuesHead", "The data check found issues:"); }
+        }
+
+        public static string confirmErrorsTail
+        {
+            get { return Get("confirmErrorsTail", "Two lines at the same energy make the amplitude fit degenerate, and zero intensity drops a line out of the chain coupling."); }
+        }
+
+        public static string confirmSaveAnyway
+        {
+            get { return Get("confirmSaveAnyway", "Save anyway?"); }
+        }
+
+        public static string helpTitle
+        {
+            get { return Get("helpTitle", "Help: ROI and nuclide set builder"); }
+        }
+
+        public static string helpSourcesArrow
+        {
+            get { return Get("helpSourcesArrow", "   →   "); }
+        }
+
+    }
+}
\ No newline at end of file
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx b/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx
new file mode 100644
index 0000000..2b71453
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx
@@ -0,0 +1,529 @@
+
+
+  
+    
+    
+      
+        
+          
+            
+              
+                
+              
+              
+              
+              
+              
+            
+          
+          
+            
+              
+              
+            
+          
+          
+            
+              
+                
+                
+              
+              
+              
+              
+              
+            
+          
+          
+            
+              
+                
+              
+              
+            
+          
+        
+      
+    
+  
+  
+    text/microsoft-resx
+  
+  
+    2.0
+  
+  
+    System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+  
+  
+    System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+  
+  
+    ROI and nuclide set builder
+  
+  
+    1 · Nuclides
+  
+  
+    2 · Lines
+  
+  
+    3 · Styling and export
+  
+  
+    lines: {0} of {1} · nuclides: {2}
+  
+  
+    Help
+  
+  
+    ◂ Back
+  
+  
+    Next ▸
+  
+  
+    Nuclides
+  
+  
+    Lines
+  
+  
+    Styling and export
+  
+  
+    Nuclide search
+  
+  
+    Add
+  
+  
+    + family
+  
+  
+    + chain
+  
+  
+    Nuclide
+  
+  
+    Families
+  
+  
+    Lines
+  
+  
+    Typing narrows the list: by name or by family code.
+  
+  
+    Presets:
+  
+  
+    NORM background
+  
+  
+    Th-232 + U-238 as chains + K-40
+  
+  
+    Cs-137 / Co-60 check
+  
+  
+    Reference check sources
+  
+  
+    Calibration set
+  
+  
+    Am-241, Ba-133, Eu-152, Cs-137, Co-60
+  
+  
+    Medical
+  
+  
+    MED family
+  
+  
+    Detector and shield XRF
+  
+  
+    Pb, W, La, Ba, I
+  
+  
+    Group
+  
+  
+    add all
+  
+  
+    + family lines
+  
+  
+    + chain
+  
+  
+    Tick a nuclide — the buttons apply to it.
+  
+  
+    Applies to the ticked ones ({0}).
+  
+  
+    XRF elements
+  
+  
+    Shielding and detector materials:
+  
+  
+    Kα/Kβ (+L for heavy elements). Intensities are nominal (Kα1 = 100) — markers only.
+  
+  
+    Selected
+  
+  
+    clear all
+  
+  
+    XRF 
+  
+  
+    empty — start with a group above
+  
+  
+    Detector-resolution adaptation
+  
+  
+    R, % at 662 keV
+  
+  
+    from spectrum
+  
+  
+    criterion
+  
+  
+    × FWHM
+  
+  
+    Merge close lines
+  
+  
+    Restore originals
+  
+  
+    threshold {0:0.##}·FWHM: lines merge closer than {1:0.#} keV at 100, {2:0.#} at 662, {3:0.#} at 1500
+  
+  
+    Sparrow limit — ROI markers (0.85·FWHM)
+  
+  
+    anchored set — library fit (0.25·FWHM)
+  
+  
+    manual
+  
+  
+    Filters and selection
+  
+  
+    intensity ≥, %
+  
+  
+    relative (within nuclide, max = 100)
+  
+  
+    absolute (per decay)
+  
+  
+    energy, keV
+  
+  
+    
+  
+  
+    ✓ select all visible
+  
+  
+    ✗ deselect all visible
+  
+  
+    top-N by I per nuclide
+  
+  
+    Select top-N
+  
+  
+    hide unselected
+  
+  
+    Line types
+  
+  
+    X (decay)
+  
+  
+    XRF
+  
+  
+    secondary
+  
+  
+    series equilibrium (intensities per parent decay)
+  
+  
+    s
+  
+  
+    h
+  
+  
+    d
+  
+  
+    y
+  
+  
+    s
+  
+  
+    min
+  
+  
+    h
+  
+  
+    d
+  
+  
+    y
+  
+  
+    Nuclide
+  
+  
+    E, keV
+  
+  
+    I, %
+  
+  
+    I rel., %
+  
+  
+    
+  
+  
+    Type
+  
+  
+    XRF
+  
+  
+    sec
+  
+  
+    Secondary peaks (computed from selected γ lines)
+  
+  
+    for γ lines with I ≥, %
+  
+  
+    backscatter (BS)
+  
+  
+    Compton edge (CE)
+  
+  
+    escape 511 (SE)
+  
+  
+    escape 1022 (DE)
+  
+  
+    I-K escape (NaI, −28.6)
+  
+  
+    annihilation 511
+  
+  
+    cascade sum (E1+E2)
+  
+  
+    pile-up 2×E
+  
+  
+    Generate
+  
+  
+    secondary markers added: {0}
+  
+  
+    Annihilation 511
+  
+  
+    Nearby-line search (whole database — who else emits here)
+  
+  
+    energy, keV
+  
+  
+    ± window
+  
+  
+    I ≥, %
+  
+  
+    T½ ≥
+  
+  
+    Search
+  
+  
+    + add
+  
+  
+    ΔE
+  
+  
+    added
+  
+  
+    showing the first {0} of {1}
+  
+  
+    nothing found within {0} ± {1} keV
+  
+  
+    ROI styling
+  
+  
+    mode
+  
+  
+    zone width
+  
+  
+    Colours
+  
+  
+    by chain
+  
+  
+    by nuclide
+  
+  
+    marker lines (height ∝ I, no zones)
+  
+  
+    zones (limits around the peak)
+  
+  
+    zones + intensity markers
+  
+  
+    % of energy (BecqMoni style)
+  
+  
+    k × FWHM (scintillator)
+  
+  
+    Export
+  
+  
+    ROI configuration name
+  
+  
+    Create ROI configuration
+  
+  
+    Preview
+  
+  
+    set name (NuclideSet)
+  
+  
+    IAEA set
+  
+  
+    anchor line
+  
+  
+    Add set to the library
+  
+  
+    full set (all lines, for fitting)
+  
+  
+    anchor lines
+  
+  
+    Data check:
+  
+  
+    no lines selected
+  
+  
+    auto — {0} {1}
+  
+  
+    ROI
+  
+  
+    SET
+  
+  
+    no issues
+  
+  
+    equal energies: “{0}” and “{1}” ({2} / {3} keV) — the amplitude fit degenerates here
+  
+  
+    zero yield: “{0}” ({1} keV)
+  
+  
+    the anchor “{0}” ({1} keV) is a characteristic X-ray of a material, not a decay line: the fit would rest on a line whose position or intensity is nominal
+  
+  
+    the anchor “{0}” ({1} keV) is a computed secondary marker, not a decay line: the fit would rest on a line whose position or intensity is nominal
+  
+  
+    no anchor line: the set holds no decay line at all (XRF and secondary markers cannot be anchors) — the library fit does not start without one
+  
+  
+    the anchor is the X-ray line “{0}” ({1} keV): a γ line is a firmer footing for the fit
+  
+  
+    zones overlap: “{0}” [{1}–{2}] and “{3}” [{4}–{5}]
+  
+  
+    ROI and nuclide set builder
+  
+  
+    A configuration named “{0}” already exists — its file will be overwritten. Continue?
+  
+  
+    The library already holds a set named “{0}”. Add another one with the same name?
+  
+  
+    No lines selected.
+  
+  
+    The resolution could not be taken from the active spectrum.
+  
+  
+    The set cannot be saved — the data check found errors:
+  
+  
+    The data check found issues:
+  
+  
+    Two lines at the same energy make the amplitude fit degenerate, and zero intensity drops a line out of the chain coupling.
+  
+  
+    Save anyway?
+  
+  
+    Help: ROI and nuclide set builder
+  
+  
+    
+  
+
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx b/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx
new file mode 100644
index 0000000..6f91fc1
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx
@@ -0,0 +1,529 @@
+
+
+  
+    
+    
+      
+        
+          
+            
+              
+                
+              
+              
+              
+              
+              
+            
+          
+          
+            
+              
+              
+            
+          
+          
+            
+              
+                
+                
+              
+              
+              
+              
+              
+            
+          
+          
+            
+              
+                
+              
+              
+            
+          
+        
+      
+    
+  
+  
+    text/microsoft-resx
+  
+  
+    2.0
+  
+  
+    System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+  
+  
+    System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+  
+  
+    Конструктор ROI и наборов нуклидов
+  
+  
+    1 · Изотопы
+  
+  
+    2 · Линии
+  
+  
+    3 · Оформление и экспорт
+  
+  
+    линий: {0} из {1} · нуклидов: {2}
+  
+  
+    Справка
+  
+  
+    ◂ Назад
+  
+  
+    Вперёд ▸
+  
+  
+    Изотопы
+  
+  
+    Линии
+  
+  
+    Оформление и экспорт
+  
+  
+    Поиск изотопа
+  
+  
+    Добавить
+  
+  
+    + семейство
+  
+  
+    + цепочка
+  
+  
+    Нуклид
+  
+  
+    Семейства
+  
+  
+    Линий
+  
+  
+    Ввод сужает список: по имени или по коду семейства.
+  
+  
+    Пресеты:
+  
+  
+    ЕРН-фон
+  
+  
+    Th-232 + U-238 цепочками + K-40
+  
+  
+    Поверка Cs-137 / Co-60
+  
+  
+    Контрольные источники
+  
+  
+    ОСГИ / калибровка
+  
+  
+    Am-241, Ba-133, Eu-152, Cs-137, Co-60
+  
+  
+    Медицинские
+  
+  
+    Семейство MED
+  
+  
+    ХРИ детектора и защиты
+  
+  
+    Pb, W, La, Ba, I
+  
+  
+    Группа
+  
+  
+    добавить все
+  
+  
+    + линии семейства
+  
+  
+    + цепочкой
+  
+  
+    Отметьте нуклид — кнопки применятся к нему.
+  
+  
+    Применяется к отмеченным ({0}).
+  
+  
+    ХРИ — элементы
+  
+  
+    Материалы защиты и детектора:
+  
+  
+    Kα/Kβ (+L для тяжёлых). Интенсивности относительные (Kα1 = 100) — только маркеры.
+  
+  
+    Выбрано
+  
+  
+    очистить всё
+  
+  
+    ХРИ 
+  
+  
+    пусто — начните с группы выше
+  
+  
+    Адаптация под разрешение детектора
+  
+  
+    R, % на 662 кэВ
+  
+  
+    из спектра
+  
+  
+    критерий
+  
+  
+    × FWHM
+  
+  
+    Объединить близкие
+  
+  
+    Вернуть исходные
+  
+  
+    порог {0:0.##}·FWHM: сливаются линии ближе {1:0.#} кэВ на 100, {2:0.#} на 662, {3:0.#} на 1500
+  
+  
+    предел Sparrow — маркеры ROI (0,85·FWHM)
+  
+  
+    якорный набор — библиотечный фит (0,25·FWHM)
+  
+  
+    вручную
+  
+  
+    Фильтры и выбор
+  
+  
+    интенсивность ≥,
+  
+  
+    относительная (внутри изотопа, макс = 100)
+  
+  
+    абсолютная (на распад)
+  
+  
+    энергия, кэВ
+  
+  
+    
+  
+  
+    ✓ выбрать все видимые
+  
+  
+    ✗ снять все видимые
+  
+  
+    топ-N по I на нуклид
+  
+  
+    Выбрать топ-N
+  
+  
+    скрыть невыбранные
+  
+  
+    Тип линий
+  
+  
+    X (распад)
+  
+  
+    ХРИ
+  
+  
+    вторичные
+  
+  
+    равновесие ряда (интенсивности на распад родителя)
+  
+  
+    сек
+  
+  
+    ч
+  
+  
+    сут
+  
+  
+    лет
+  
+  
+    с
+  
+  
+    мин
+  
+  
+    ч
+  
+  
+    сут
+  
+  
+    лет
+  
+  
+    Нуклид
+  
+  
+    E, кэВ
+  
+  
+    I, %
+  
+  
+    I отн., %
+  
+  
+    
+  
+  
+    Тип
+  
+  
+    ХРИ
+  
+  
+    втор
+  
+  
+    Вторичные пики (расчёт по выбранным γ-линиям)
+  
+  
+    для γ-линий с I ≥, %
+  
+  
+    рассеяние назад (BS)
+  
+  
+    комптон-край (CE)
+  
+  
+    вылет 511 (SE)
+  
+  
+    вылет 1022 (DE)
+  
+  
+    вылет I-K (NaI, −28.6)
+  
+  
+    аннигиляция 511
+  
+  
+    суммирование каскадное
+  
+  
+    наложение 2×E
+  
+  
+    Сгенерировать
+  
+  
+    добавлено вторичных маркеров: {0}
+  
+  
+    Аннигиляция 511
+  
+  
+    Поиск близких линий (по всей базе — кто ещё светит рядом)
+  
+  
+    энергия, кэВ
+  
+  
+    ± окно
+  
+  
+    I ≥, %
+  
+  
+    T½ ≥
+  
+  
+    Искать
+  
+  
+    + добавить
+  
+  
+    ΔE
+  
+  
+    в наборе
+  
+  
+    показаны первые {0} из {1}
+  
+  
+    в окне {0} ± {1} кэВ ничего не найдено
+  
+  
+    Оформление ROI
+  
+  
+    режим
+  
+  
+    ширина зоны
+  
+  
+    Цвета
+  
+  
+    по цепочке
+  
+  
+    по нуклиду
+  
+  
+    линии-маркеры (высота ∝ I, без зон)
+  
+  
+    зоны (границы вокруг пика)
+  
+  
+    зоны + маркеры интенсивности
+  
+  
+    % от энергии (как в BecqMoni)
+  
+  
+    k × FWHM (сцинтиллятор)
+  
+  
+    Экспорт
+  
+  
+    имя ROI-конфигурации
+  
+  
+    Создать ROI-конфигурацию
+  
+  
+    Предпросмотр
+  
+  
+    имя набора (NuclideSet)
+  
+  
+    Набор IAEA
+  
+  
+    якорная линия
+  
+  
+    Добавить набор в библиотеку
+  
+  
+    полный набор (все линии, для фита)
+  
+  
+    якорей
+  
+  
+    Проверка данных:
+  
+  
+    линии не выбраны
+  
+  
+    auto — {0} {1}
+  
+  
+    ROI
+  
+  
+    SET
+  
+  
+    замечаний нет
+  
+  
+    равные энергии: «{0}» и «{1}» ({2} / {3} кэВ) — подгонка амплитуд на этой позиции вырождается
+  
+  
+    нулевой выход: «{0}» ({1} кэВ)
+  
+  
+    якорем выбрана линия «{0}» ({1} кэВ): это характеристический рентген материала, а не линия распада. Фит сел бы на опору, положение или интенсивность которой условны
+  
+  
+    якорем выбрана линия «{0}» ({1} кэВ): это расчётный вторичный маркер, а не линия распада. Фит сел бы на опору, положение или интенсивность которой условны
+  
+  
+    нет якорной линии: в наборе нет ни одной линии распада (ХРИ и вторичные маркеры якорем быть не могут) — библиотечный фит без якоря не запускается
+  
+  
+    якорь — рентгеновская линия «{0}» ({1} кэВ): для опоры фита надёжнее γ-линия
+  
+  
+    перекрытие зон: «{0}» [{1}–{2}] и «{3}» [{4}–{5}]
+  
+  
+    Конструктор ROI и наборов нуклидов
+  
+  
+    Конфигурация «{0}» уже есть — её файл будет перезаписан. Продолжить?
+  
+  
+    Набор «{0}» в библиотеке уже есть. Добавить ещё один с тем же именем?
+  
+  
+    Линии не выбраны.
+  
+  
+    Не удалось взять разрешение из активного спектра.
+  
+  
+    Набор сохранить нельзя — проверка данных нашла ошибки:
+  
+  
+    Проверка данных нашла замечания:
+  
+  
+    Две линии на одной энергии вырождают подгонку амплитуд, а нулевая интенсивность выбрасывает линию из связки по цепочке.
+  
+  
+    Всё равно сохранить?
+  
+  
+    Справка: конструктор ROI и наборов нуклидов
+  
+  
+    
+  
+
diff --git a/BecquerelMonitor/RoiWizard/SecondaryPeaks.cs b/BecquerelMonitor/RoiWizard/SecondaryPeaks.cs
new file mode 100644
index 0000000..6329187
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/SecondaryPeaks.cs
@@ -0,0 +1,228 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace BecquerelMonitor.RoiWizard
+{
+    [Flags]
+    public enum SecondaryKind
+    {
+        None = 0,
+        Backscatter = 1,
+        ComptonEdge = 2,
+        SingleEscape = 4,
+        DoubleEscape = 8,
+        IodineEscape = 16,
+        Annihilation = 32,
+        CascadeSum = 64,
+        PileUp = 128
+    }
+
+    // Расчёт вторичных особенностей спектра: того, что видно в спектре, но не является
+    // фотопиком реальной линии. Маркеры нужны, чтобы такие особенности не приписывали
+    // лишним нуклидам.
+    //
+    // Формулы — Knoll, Radiation Detection and Measurement, гл. 10: комптоновский край
+    // разд. II.B, пик обратного рассеяния и пики вылета разд. III, вылет K-рентгена иода
+    // рис. 10.10 (28 кэВ ниже фотопика), суммирование каскадов разд. III.E.
+    //
+    // Аналитическая формула даёт край ступеньки, а в спектре виден центроид особенности,
+    // размытой разрешением: поиск идёт методом второй производной (алгоритм Марискотти,
+    // Gilmore, Practical Gamma-ray Spectrometry, разд. 9.2.3), поэтому найденное положение
+    // систематически смещено. Величины поправок получены из измерений комплекса Gamma-1C
+    // (NaI(Tl) 63×63, защита Pb 50 мм с вкладышем Cd/Cu): 9 нуклидов,
+    // 18 первичных линий, 31 надёжная запись из 41.
+    public static class SecondaryPeaks
+    {
+        public const double ElectronMassKeV = 510.999;
+
+        // |сдвиг| комптоновского края = k·FWHM(E_края). Медиана по 12 записям — 0.78,
+        // кучно 0.69…0.84 на 340–1250 кэВ. Источник правила даёт 0.7 по двум нуклидам.
+        public const double ComptonEdgeFwhmFactor = 0.8;
+
+        // Пик обратного рассеяния смещён вверх: многократное рассеяние добавляется к
+        // однократному 180°. Медиана 8 записей +9.9 кэВ, разброс +0.3…+18.4 — величина
+        // задаётся геометрией источника и защиты, через FWHM не выражается.
+        public const double BackscatterShiftKeV = 10.0;
+
+        // Доли от площади родительского фотопика (медианы измеренных отношений).
+        // Прежнее общее «10 %» расходилось с измерениями почти втрое.
+        public const double BackscatterFraction = 0.08;
+        public const double ComptonEdgeFraction = 0.06;
+        public const double EscapeFraction = 0.06;
+        public const double DefaultFraction = 0.10;
+
+        public static double BackscatterEnergy(double energy)
+        {
+            return energy / (1.0 + 2.0 * energy / ElectronMassKeV);
+        }
+
+        public static double ComptonEdgeEnergy(double energy)
+        {
+            return energy - BackscatterEnergy(energy);
+        }
+
+        // Подпись маркера аннигиляции приходит снаружи: она попадает в набор и видна
+        // оператору, а язык интерфейса ядру знать неоткуда.
+        public const string DefaultAnnihilationLabel = "Annihilation 511";
+
+        public static List Generate(IEnumerable lines,
+                                                  ResolutionModel resolution,
+                                                  SecondaryKind kinds,
+                                                  double minParentIntensity)
+        {
+            return Generate(lines, resolution, kinds, minParentIntensity, DefaultAnnihilationLabel);
+        }
+
+        public static List Generate(IEnumerable lines,
+                                                  ResolutionModel resolution,
+                                                  SecondaryKind kinds,
+                                                  double minParentIntensity,
+                                                  string annihilationLabel)
+        {
+            List result = new List();
+            List parents = new List();
+            bool wantAnnihilation = (kinds & SecondaryKind.Annihilation) != 0;
+
+            foreach (SpectralLine line in lines)
+            {
+                if (line.Type != LineType.Gamma || !line.Selected || line.Intensity < minParentIntensity)
+                {
+                    continue;
+                }
+                parents.Add(line);
+                string origin = line.Nuclide + " " + Math.Round(line.Energy);
+
+                double backscatter = BackscatterEnergy(line.Energy);
+                double edge = line.Energy - backscatter;
+
+                if ((kinds & SecondaryKind.Backscatter) != 0 && line.Energy >= 200)
+                {
+                    Add(result, line, "BS", backscatter + BackscatterShiftKeV, BackscatterFraction, origin);
+                }
+                if ((kinds & SecondaryKind.ComptonEdge) != 0 && line.Energy >= 200)
+                {
+                    Add(result, line, "CE", edge - ComptonEdgeFwhmFactor * resolution.Fwhm(edge),
+                        ComptonEdgeFraction, origin);
+                }
+                // порог образования пар 1022 кэВ; берём с запасом, чтобы маркер не лез туда,
+                // где пик вылета формально возможен, но неразличим
+                if ((kinds & SecondaryKind.SingleEscape) != 0 && line.Energy > 1122)
+                {
+                    Add(result, line, "SE", line.Energy - ElectronMassKeV, EscapeFraction, origin);
+                }
+                if ((kinds & SecondaryKind.DoubleEscape) != 0 && line.Energy > 1222)
+                {
+                    Add(result, line, "DE", line.Energy - 2 * ElectronMassKeV, DefaultFraction, origin);
+                }
+                if ((kinds & SecondaryKind.IodineEscape) != 0 && line.Energy < 200)
+                {
+                    // поглощение в NaI идёт в основном на атомах иода; пик на 28 кэВ ниже
+                    Add(result, line, "I-esc", line.Energy - 28.6, DefaultFraction, origin);
+                }
+                if ((kinds & SecondaryKind.PileUp) != 0)
+                {
+                    Add(result, line, "PU", 2 * line.Energy, DefaultFraction, origin);
+                }
+            }
+
+            if ((kinds & SecondaryKind.CascadeSum) != 0)
+            {
+                AddCascadeSums(result, parents);
+            }
+            if (wantAnnihilation)
+            {
+                AddAnnihilation(result, lines, annihilationLabel);
+            }
+            return result;
+        }
+
+        // Истинные совпадения: оба кванта каскада одного распада поглощены за время,
+        // меньшее отклика детектора. Суммирование не только создаёт суммарный пик, но и
+        // изымает отсчёты из одиночных фотопиков — поэтому такие линии не годятся опорой
+        // для библиотечного фита (Knoll, гл. 10, разд. III.E).
+        static void AddCascadeSums(List result, List parents)
+        {
+            Dictionary> byNuclide = new Dictionary>();
+            foreach (SpectralLine line in parents)
+            {
+                List list;
+                if (!byNuclide.TryGetValue(line.Nuclide, out list))
+                {
+                    list = new List();
+                    byNuclide[line.Nuclide] = list;
+                }
+                list.Add(line);
+            }
+            foreach (KeyValuePair> entry in byNuclide)
+            {
+                List list = entry.Value;
+                for (int i = 0; i < list.Count; i++)
+                {
+                    for (int j = i + 1; j < list.Count; j++)
+                    {
+                        double energy = list[i].Energy + list[j].Energy;
+                        result.Add(new SpectralLine
+                        {
+                            Key = "sec|sum|" + entry.Key + "|" + Math.Round(energy),
+                            Nuclide = entry.Key,
+                            Label = "SUM (" + entry.Key + " " + Math.Round(list[i].Energy) + "+" +
+                                    Math.Round(list[j].Energy) + ")",
+                            Energy = Math.Round(energy, 2),
+                            Intensity = Math.Round(DefaultFraction * Math.Min(list[i].Intensity, list[j].Intensity), 3),
+                            Type = LineType.Secondary,
+                            HalfLifeYears = list[i].HalfLifeYears,
+                            HalfLifeText = list[i].HalfLifeText
+                        });
+                    }
+                }
+            }
+        }
+
+        // 511 кэВ появляется и от образования пар в окружающих материалах, а не только
+        // от позитронного источника — Knoll предупреждает не путать эти случаи
+        static void AddAnnihilation(List result, IEnumerable lines,
+                                    string label)
+        {
+            foreach (SpectralLine line in lines)
+            {
+                if (line.Selected && Math.Abs(line.Energy - 511.0) < 1.5)
+                {
+                    return;                      // настоящая линия рядом — маркер не нужен
+                }
+            }
+            result.Add(new SpectralLine
+            {
+                Key = "sec|ann|511",
+                Nuclide = "—",
+                Label = string.IsNullOrEmpty(label) ? DefaultAnnihilationLabel : label,
+                Energy = 511.0,
+                Intensity = 5.0,
+                Type = LineType.Secondary,
+                HalfLifeYears = 1e9,
+                HalfLifeText = "—"
+            });
+        }
+
+        static void Add(List result, SpectralLine parent, string tag,
+                        double energy, double fraction, string origin)
+        {
+            if (energy < 4)
+            {
+                return;
+            }
+            result.Add(new SpectralLine
+            {
+                Key = "sec|" + tag + "|" + parent.Nuclide + "|" + Math.Round(energy),
+                Nuclide = parent.Nuclide,
+                Label = tag + " (" + origin + ")",
+                Energy = Math.Round(energy, 2),
+                Intensity = Math.Round(parent.Intensity * fraction, 3),
+                Type = LineType.Secondary,
+                HalfLifeYears = parent.HalfLifeYears,
+                HalfLifeText = parent.HalfLifeText
+            });
+        }
+
+   }
+}
diff --git a/BecquerelMonitor/RoiWizard/SetChecker.cs b/BecquerelMonitor/RoiWizard/SetChecker.cs
new file mode 100644
index 0000000..0f56cbc
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/SetChecker.cs
@@ -0,0 +1,207 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace BecquerelMonitor.RoiWizard
+{
+    public enum IssueLevel
+    {
+        Warning,
+        Error
+    }
+
+    // Что именно не так. Ядро не собирает фразу: текст зависит от языка интерфейса,
+    // а язык — дело формы. Здесь только код замечания и подстановки к нему.
+    public enum IssueKind
+    {
+        EqualEnergies,
+        ZeroYield,
+        AnchorIsXrf,
+        AnchorIsSecondary,
+        NoAnchor,
+        AnchorIsXray,
+        ZonesOverlap
+    }
+
+    public class SetIssue
+    {
+        public IssueLevel Level { get; set; }
+        public IssueKind Kind { get; set; }
+        public object[] Args { get; set; }
+
+        public SetIssue()
+        {
+            this.Args = new object[0];
+        }
+    }
+
+    // Проверки перед сохранением. Для ROI всё совещательное, для набора совпавшие энергии
+    // и нулевая интенсивность — ошибки: две линии на одной позиции вырождают подгонку
+    // амплитуд (два параметра на один пик), а Intencity = 0 выбрасывает линию из связки
+    // по цепочке.
+    public static class SetChecker
+    {
+        static bool HasGamma(List lines)
+        {
+            foreach (SpectralLine line in lines)
+            {
+                if (line.Type == LineType.Gamma)
+                {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        public static List Check(IEnumerable lines, bool forLibrary,
+                                           ZoneCalculator zones)
+        {
+            return Check(lines, forLibrary, zones, null);
+        }
+
+        // resolution нужен, чтобы проверить якорь ровно так же, как его выберет
+        // BuildNuclideSet; без модели разрешения проверка якоря пропускается
+        public static List Check(IEnumerable lines, bool forLibrary,
+                                           ZoneCalculator zones, ResolutionModel resolution)
+        {
+            // приведение обязательно: без него null подходит обеим перегрузкам (CS0121)
+            return Check(lines, forLibrary, zones, resolution, (IList)null);
+        }
+
+        // anchorOverride — якорь, выбранный руками. Проверять надо именно его: в набор
+        // уйдёт он, а не тот, что предложил бы AnchorPicker.
+        public static List Check(IEnumerable lines, bool forLibrary,
+                                           ZoneCalculator zones, ResolutionModel resolution,
+                                           SpectralLine anchorOverride)
+        {
+            List anchors = null;
+            if (anchorOverride != null)
+            {
+                anchors = new List();
+                anchors.Add(anchorOverride);
+            }
+            return Check(lines, forLibrary, zones, resolution, anchors);
+        }
+
+        // Якорей может быть несколько: LibraryPeakFitter перебирает все записи с IsAnchor
+        // и требует совпадения с найденным пиком хотя бы одной из них.
+        public static List Check(IEnumerable lines, bool forLibrary,
+                                           ZoneCalculator zones, ResolutionModel resolution,
+                                           IList anchorOverride)
+        {
+            List issues = new List();
+            List sorted = new List();
+            foreach (SpectralLine line in lines)
+            {
+                if (line.Selected)
+                {
+                    sorted.Add(line);
+                }
+            }
+            sorted.Sort(delegate(SpectralLine a, SpectralLine b) { return a.Energy.CompareTo(b.Energy); });
+
+            IssueLevel level = forLibrary ? IssueLevel.Error : IssueLevel.Warning;
+            for (int i = 1; i < sorted.Count; i++)
+            {
+                if (Math.Abs(sorted[i].Energy - sorted[i - 1].Energy) < 1.0)
+                {
+                    // Совпавшие энергии подгонку вырождают, но запрещать из-за них экспорт
+                    // нельзя: пара «рентген распада + ХРИ того же элемента» — физически одна
+                    // линия (K-серия свинца у Tl-208 и защиты), и снимать её или оставить —
+                    // решение оператора. Поэтому предупреждение, а не ошибка.
+                    issues.Add(new SetIssue
+                    {
+                        Level = IssueLevel.Warning,
+                        Kind = IssueKind.EqualEnergies,
+                        Args = new object[] {
+                            Name(sorted[i - 1], forLibrary), Name(sorted[i], forLibrary),
+                            sorted[i - 1].Energy, sorted[i].Energy }
+                    });
+                }
+            }
+            foreach (SpectralLine line in sorted)
+            {
+                if (!(line.Intensity > 0))
+                {
+                    issues.Add(new SetIssue
+                    {
+                        Level = level,
+                        Kind = IssueKind.ZeroYield,
+                        Args = new object[] { Name(line, forLibrary), line.Energy }
+                    });
+                }
+            }
+            if (forLibrary && (resolution != null || (anchorOverride != null && anchorOverride.Count > 0)))
+            {
+                List anchors = anchorOverride != null && anchorOverride.Count > 0
+                    ? new List(anchorOverride)
+                    : AnchorPicker.PickMany(sorted, resolution, AnchorPicker.DefaultCount);
+
+                bool manual = anchorOverride != null && anchorOverride.Count > 0;
+                bool rejected = false;
+                if (manual)
+                {
+                    foreach (SpectralLine chosen in anchors)
+                    {
+                        if (AnchorPicker.IsAcceptable(chosen))
+                        {
+                            continue;
+                        }
+                        rejected = true;
+                        issues.Add(new SetIssue
+                        {
+                            Level = IssueLevel.Error,
+                            Kind = chosen.Type == LineType.Xrf
+                                ? IssueKind.AnchorIsXrf
+                                : IssueKind.AnchorIsSecondary,
+                            Args = new object[] { chosen.LibraryName, chosen.Energy }
+                        });
+                    }
+                }
+                if (!rejected && anchors.Count == 0)
+                {
+                    issues.Add(new SetIssue
+                    {
+                        Level = IssueLevel.Error,
+                        Kind = IssueKind.NoAnchor
+                    });
+                }
+                else if (!rejected && !HasGamma(anchors))
+                {
+                    issues.Add(new SetIssue
+                    {
+                        Level = IssueLevel.Warning,
+                        Kind = IssueKind.AnchorIsXray,
+                        Args = new object[] { anchors[0].LibraryName, anchors[0].Energy }
+                    });
+                }
+            }
+            if (!forLibrary && zones != null && zones.Style != RoiStyle.Markers)
+            {
+                for (int i = 1; i < sorted.Count; i++)
+                {
+                    double lowerA, upperA, lowerB, upperB;
+                    zones.LimitsFor(sorted[i - 1], out lowerA, out upperA);
+                    zones.LimitsFor(sorted[i], out lowerB, out upperB);
+                    if (lowerB < upperA)
+                    {
+                        issues.Add(new SetIssue
+                        {
+                            Level = IssueLevel.Warning,
+                            Kind = IssueKind.ZonesOverlap,
+                            Args = new object[] {
+                                sorted[i - 1].Label, lowerA, upperA,
+                                sorted[i].Label, lowerB, upperB }
+                        });
+                    }
+                }
+            }
+            return issues;
+        }
+
+        static string Name(SpectralLine line, bool forLibrary)
+        {
+            return forLibrary ? line.LibraryName : line.Label;
+        }
+    }
+}
diff --git a/BecquerelMonitor/RoiWizard/SetExporter.cs b/BecquerelMonitor/RoiWizard/SetExporter.cs
new file mode 100644
index 0000000..915927e
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/SetExporter.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+
+namespace BecquerelMonitor.RoiWizard
+{
+    // Сборка результата в объекты BecqMoni. Файлы не пишутся: конфигурация уходит в
+    // ROIConfigManager, а записи набора — в NuclideDefinitionManager, то есть инструмент
+    // работает как часть приложения, а не как генератор XML на диск.
+    public class SetExporter
+    {
+        readonly ResolutionModel resolution;
+
+        public SetExporter(ResolutionModel resolution) : this(resolution, null)
+        {
+        }
+
+        public SetExporter(ResolutionModel resolution, ZoneCalculator zones)
+        {
+            this.resolution = resolution;
+            this.Zones = zones ?? new ZoneCalculator(resolution);
+        }
+
+        // границы зоны считает отдельный калькулятор — он же уходит в проверки
+        public ZoneCalculator Zones { get; private set; }
+
+
+        public ROIConfigData BuildRoiConfig(IEnumerable lines, string name,
+                                            Func colorOf)
+        {
+            ROIConfigData config = new ROIConfigData();
+            config.Guid = System.Guid.NewGuid().ToString();
+            config.Name = string.IsNullOrEmpty(name) ? "IAEA lines" : name;
+            config.LastUpdated = DateTime.Now;
+
+            List ordered = Selected(lines);
+            ordered.Sort(delegate(SpectralLine a, SpectralLine b)
+            {
+                int byLabel = string.CompareOrdinal(a.Label, b.Label);
+                return byLabel != 0 ? byLabel : a.Energy.CompareTo(b.Energy);
+            });
+
+            foreach (SpectralLine line in ordered)
+            {
+                double lower, upper;
+                this.Zones.LimitsFor(line, out lower, out upper);
+
+                ROIDefinitionData roi = new ROIDefinitionData();
+                roi.Name = line.Label;
+                roi.Enabled = true;
+                roi.PeakEnergy = line.Energy;
+                roi.LowerLimit = lower;
+                roi.UpperLimit = upper;
+                roi.Color = colorOf != null ? colorOf(line) : Color.Red;
+                // период полураспада в конфигурации ROI хранится в годах
+                roi.HalfLife = line.HalfLifeYears >= 1e9 ? 0 : line.HalfLifeYears;
+                roi.Intencity = line.Intensity;
+                config.ROIDefinitions.Add(roi);
+            }
+            return config;
+        }
+
+        // Возвращает записи набора и сам NuclideSet. Вызывающая сторона добавляет их в
+        // NuclideDefinitionManager — существующие нуклиды и наборы при этом не трогаются.
+        public NuclideSet BuildNuclideSet(IEnumerable lines, string setName,
+                                          Func colorOf,
+                                          SpectralLine anchorOverride,
+                                          out List definitions)
+        {
+            List anchors = null;
+            if (anchorOverride != null)
+            {
+                anchors = new List();
+                anchors.Add(anchorOverride);
+            }
+            return this.BuildNuclideSet(lines, setName, colorOf, anchors,
+                                        AnchorPicker.DefaultCount, out definitions);
+        }
+
+        // anchorOverride — якоря, выбранные руками (null = выбрать автоматически).
+        // anchorCount действует только при автовыборе: сколько линий пометить IsAnchor.
+        public NuclideSet BuildNuclideSet(IEnumerable lines, string setName,
+                                          Func colorOf,
+                                          IList anchorOverride, int anchorCount,
+                                          out List definitions)
+        {
+            List ordered = Selected(lines);
+            ordered.Sort(delegate(SpectralLine a, SpectralLine b) { return a.Energy.CompareTo(b.Energy); });
+
+            NuclideSet set = new NuclideSet();
+            set.Id = System.Guid.NewGuid();
+            set.Name = string.IsNullOrEmpty(setName) ? "IAEA set" : setName;
+            set.HideUnknownPeaks = false;
+
+            List anchors = anchorOverride != null && anchorOverride.Count > 0
+                ? new List(anchorOverride)
+                : AnchorPicker.PickMany(ordered, this.resolution, anchorCount);
+            definitions = new List();
+
+            foreach (SpectralLine line in ordered)
+            {
+                NuclideDefinition definition = new NuclideDefinition();
+                definition.Name = line.LibraryName;
+                definition.Energy = line.Energy;
+                // у нераспадных записей (ХРИ, вторичные) период полураспада не заполняется —
+                // конвенция файла-образца BecqMoni
+                definition.HalfLife = line.Type == LineType.Xrf || line.Type == LineType.Secondary
+                    ? 0
+                    : (line.HalfLifeYears >= 1e9 ? 0 : line.HalfLifeYears);
+                definition.NuclideColor = colorOf != null ? colorOf(line) : Color.Gray;
+                definition.Visible = true;
+                definition.Intencity = line.Intensity;
+                definition.Sets.Add(set.Id);
+                definition.IsAnchor = Contains(anchors, line);
+                definitions.Add(definition);
+            }
+            return set;
+        }
+
+        // сравнение по ссылке: линия из набора и линия из списка якорей — один объект
+        static bool Contains(List anchors, SpectralLine line)
+        {
+            foreach (SpectralLine anchor in anchors)
+            {
+                if (ReferenceEquals(anchor, line))
+                {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        static List Selected(IEnumerable lines)
+        {
+            List result = new List();
+            foreach (SpectralLine line in lines)
+            {
+                if (line.Selected)
+                {
+                    result.Add(line);
+                }
+            }
+            return result;
+        }
+    }
+
+}
diff --git a/BecquerelMonitor/RoiWizard/SpectralLine.cs b/BecquerelMonitor/RoiWizard/SpectralLine.cs
new file mode 100644
index 0000000..1458764
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/SpectralLine.cs
@@ -0,0 +1,225 @@
+using System;
+using System.Collections.Generic;
+
+namespace BecquerelMonitor.RoiWizard
+{
+    public enum LineType
+    {
+        // γ-линия распада
+        Gamma,
+        // рентген, сопровождающий распад (K/L-серия дочернего атома)
+        Xray,
+        // характеристический рентген материала защиты или детектора
+        Xrf,
+        // расчётный вторичный пик: обратное рассеяние, вылет, суммирование
+        Secondary
+    }
+
+    // Строка рабочего набора. Ключ нужен, чтобы выбор пользователя переживал
+    // пересборку набора (смена фильтров, равновесия ряда, слияния).
+    public class SpectralLine
+    {
+        public string Key { get; set; }
+
+        // Имя нуклида в каталоге либо «XRF Pb» для флуоресценции
+        public string Nuclide { get; set; }
+
+        // Подпись: «Ac-228 (Th-232)», «Tl-208 (Th-232) X KA1», «XRF Pb Ka1 74.97».
+        // Родитель в скобках — признак цепочки для BecqMoni, не украшение.
+        public string Label { get; set; }
+
+        // Подпись нуклида без суффикса линии — то, чем линия числится в наборе.
+        // В режиме «линии семейства» линия Ra-228 идёт под именем Th-232, и цепочку
+        // надо брать отсюда, а не из Nuclide: иначе набор рассыпется по членам ряда.
+        public string OwnerLabel { get; set; }
+
+        public double Energy { get; set; }
+
+        // Интенсивность, % (с учётом равновесия ряда, если оно включено)
+        public double Intensity { get; set; }
+
+        // Табличная интенсивность до пересчёта на распад родителя
+        public double RawIntensity { get; set; }
+
+        public LineType Type { get; set; }
+
+        public double HalfLifeYears { get; set; }
+
+        public string HalfLifeText { get; set; }
+
+        public bool Selected { get; set; }
+
+        // Линия — результат слияния группы; Interval хранит края группы
+        public bool Merged { get; set; }
+
+        public string Interval { get; set; }
+
+        public SpectralLine()
+        {
+            this.HalfLifeYears = 1e9;
+            this.Selected = true;
+        }
+
+        // Имя записи для библиотеки нуклидов.
+        // ChainOf (LibraryPeakFitter) читает текст в ПОСЛЕДНИХ скобках имени как имя
+        // родительской цепочки. У слитой линии подпись кончалась бы интервалом энергий —
+        // «Ac-228 (Th-232) (964.8–969.0)» — и цепочка читалась бы как «964.8–969.0»,
+        // то есть связка амплитуд по ряду не собралась бы. Поэтому интервал выносится
+        // из скобок, а скобки остаются родителю.
+        public string LibraryName
+        {
+            get
+            {
+                string name = this.Label;
+                if (this.Merged && !string.IsNullOrEmpty(this.Interval))
+                {
+                    string tail = " (" + this.Interval + ")";
+                    string baseName = name.EndsWith(tail, StringComparison.Ordinal)
+                        ? name.Substring(0, name.Length - tail.Length)
+                        : name;
+                    int close = baseName.LastIndexOf(')');
+                    int open = close > 0 ? baseName.LastIndexOf('(', close - 1) : -1;
+                    if (close == baseName.Length - 1 && open > 0)
+                    {
+                        string head = baseName.Substring(0, open).TrimEnd();
+                        string parent = baseName.Substring(open + 1, close - open - 1);
+                        name = head + " " + this.Interval + " (" + parent + ")";
+                    }
+                    else
+                    {
+                        name = baseName + " " + this.Interval;
+                    }
+                }
+                // ChainOf берёт цепочку из последних скобок, а без них — имя целиком.
+                // Поэтому «U-238 X L» становится собственной цепочкой и не связывается
+                // с «U-238»: линии с суффиксом у корня ряда и у одиночного нуклида
+                // выпадали из связки. Дописываем цепочку явно; у ХРИ материалов и
+                // вторичных маркеров связывать нечего, их не трогаем.
+                string owner = string.IsNullOrEmpty(this.OwnerLabel) ? this.Nuclide : this.OwnerLabel;
+                if (this.Type != LineType.Xrf && this.Type != LineType.Secondary &&
+                    !EndsWithBrackets(name) && !string.Equals(name, owner, StringComparison.Ordinal))
+                {
+                    name += " (" + owner + ")";
+                }
+                return name;
+            }
+        }
+
+        static bool EndsWithBrackets(string name)
+        {
+            if (string.IsNullOrEmpty(name) || name[name.Length - 1] != ')')
+            {
+                return false;
+            }
+            return name.LastIndexOf('(') >= 0;
+        }
+
+        public SpectralLine Clone()
+        {
+            return (SpectralLine)this.MemberwiseClone();
+        }
+    }
+
+    // Модель уширения пика для сцинтилляторов: FWHM(E) = R·√(662·E)/100.
+    // Для HPGe (R ~0.15 %) она занижает ширину на низких энергиях — там нужен
+    // закон √(a+bE), здесь не реализован.
+    public class ResolutionModel
+    {
+        readonly double resolutionAt662;
+
+        public ResolutionModel(double resolutionAt662Percent)
+        {
+            this.resolutionAt662 = resolutionAt662Percent > 0 ? resolutionAt662Percent : 7.5;
+        }
+
+        public double ResolutionAt662
+        {
+            get { return this.resolutionAt662; }
+        }
+
+        public double Fwhm(double energy)
+        {
+            if (energy <= 0)
+            {
+                return 0;
+            }
+            return this.resolutionAt662 / 100.0 * Math.Sqrt(662.0 * energy);
+        }
+    }
+
+    // Критерий, ближе которого линии считаются нераздельными.
+    // Числа взяты из BecquerelMonitor/LibraryPeakFitter.cs, не подобраны на глаз.
+    public enum MergeCriterion
+    {
+        // SparrowFwhm = 0.85 (δ = 2σ): физический предел разрешимости двух пиков.
+        // Ближе этого дублет виден как один пик — для ROI-маркеров сливаем.
+        Sparrow,
+        // ClaimToleranceFwhm = 0.25: для наборов, уходящих в библиотеку. Пары от 0.25
+        // до 0.85 FWHM разбирает библиотечный фит по якорной линии, более далёкие —
+        // деконволюция, поэтому сливать их нельзя: набор обедняется.
+        AnchoredSet,
+        // Прежнее поведение NuclideMaster: 2–3×FWHM, грубое окно идентификации.
+        Manual
+    }
+
+    public static class MergeCriterionInfo
+    {
+        public const double SparrowFwhm = 0.85;
+        public const double ClaimToleranceFwhm = 0.25;
+
+        public static double DefaultFactor(MergeCriterion criterion)
+        {
+            switch (criterion)
+            {
+                case MergeCriterion.Sparrow:
+                    return SparrowFwhm;
+                case MergeCriterion.AnchoredSet:
+                    return ClaimToleranceFwhm;
+                default:
+                    return 3.0;
+            }
+        }
+
+        // Для якорного режима осмысленный разброс — 0.2…0.3 (оценка разработчика
+        // BecqMoni; ClaimToleranceFwhm = 0.25 попадает в середину).
+        public static bool IsFactorSane(MergeCriterion criterion, double factor)
+        {
+            switch (criterion)
+            {
+                case MergeCriterion.Sparrow:
+                    return Math.Abs(factor - SparrowFwhm) < 1e-9;
+                case MergeCriterion.AnchoredSet:
+                    return factor >= 0.2 && factor <= 0.3;
+                default:
+                    return factor > 0;
+            }
+        }
+    }
+
+    // Вековое равновесие ряда: доля распадов ряда, проходящая через нуклид.
+    // Bi-212: β 64.06 % (→Po-212) / α 35.94 % (→Tl-208);
+    // Ac-227: β 98.62 % (→Th-227) / α 1.38 % (→Fr-223);
+    // Bi-211: α 99.724 % (→Tl-207) / β 0.276 % (→Po-211).
+    public static class EquilibriumFactors
+    {
+        static readonly Dictionary factors = new Dictionary(StringComparer.OrdinalIgnoreCase)
+        {
+            { "Tl-208", 0.3594 },
+            { "Po-212", 0.6406 },
+            { "Fr-223", 0.0138 },
+            { "Th-227", 0.9862 },
+            { "Tl-207", 0.99724 },
+            { "Po-211", 0.00276 }
+        };
+
+        // Без пересчёта I — «на 100 распадов самого излучающего нуклида», как в базе IAEA
+        // и в файлах Intensities BecqMoni: у Tl-208 линия 2614 кэВ имеет I = 99.75 %.
+        // В равновесном ряду Th-232 через Tl-208 идёт лишь 35.94 % распадов, поэтому
+        // на один распад Th-232 эта линия даёт 99.75 × 0.3594 ≈ 35.85 %.
+        public static double For(string nuclide)
+        {
+            double factor;
+            return factors.TryGetValue(nuclide ?? "", out factor) ? factor : 1.0;
+        }
+    }
+}
diff --git a/BecquerelMonitor/RoiWizard/WizardTheme.cs b/BecquerelMonitor/RoiWizard/WizardTheme.cs
new file mode 100644
index 0000000..a5635a2
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/WizardTheme.cs
@@ -0,0 +1,205 @@
+using System;
+using System.Drawing;
+using System.Runtime.InteropServices;
+using System.Windows.Forms;
+using XPTable.Models;
+
+namespace BecquerelMonitor.RoiWizard
+{
+    // Палитра веб-версии инструмента (styles/becqmoni.css), перенесённая в форму
+    // один в один. Веб-страница — эталон интерфейса: на ней обкатывались и раскладка,
+    // и цвета, поэтому окно в BecqMoni обязано выглядеть так же, а не «примерно так».
+    //
+    // Числа взяты из переменных темы, имена сохранены, чтобы правку в CSS было легко
+    // перенести сюда: --card, --panel, --head, --ink, --muted, --line, --grid,
+    // --accent, --accent-ink, --sel, --tabbg.
+    static class WizardTheme
+    {
+        public static readonly Color Card = Color.FromArgb(0xFF, 0xFF, 0xFF);        // --card
+        public static readonly Color Panel = Color.FromArgb(0xEC, 0xEF, 0xF3);       // --panel
+        public static readonly Color Head = Color.FromArgb(0x1F, 0x3A, 0x5F);        // --head
+        public static readonly Color Ink = Color.FromArgb(0x1A, 0x1A, 0x1A);         // --ink
+        public static readonly Color Muted = Color.FromArgb(0x5A, 0x66, 0x72);       // --muted
+        public static readonly Color Line = Color.FromArgb(0xAD, 0xAD, 0xAD);        // --line
+        public static readonly Color Grid = Color.FromArgb(0xEE, 0xF0, 0xF2);        // --grid
+        public static readonly Color Accent = Color.FromArgb(0x12, 0x50, 0x7A);      // --accent
+        public static readonly Color AccentInk = Color.FromArgb(0x1F, 0x3A, 0x5F);   // --accent-ink
+        public static readonly Color Selection = Color.FromArgb(0xCD, 0xE4, 0xF7);   // --sel
+        public static readonly Color TabBack = Color.FromArgb(0xE4, 0xE4, 0xE4);     // --tabbg
+        public static readonly Color Chip = Color.FromArgb(0xE5, 0xE5, 0xE5);       // --chip
+        public static readonly Color ChipLine = Color.FromArgb(0x7A, 0xA7, 0xCE);   // .chip.on border
+        public static readonly Color Xray = Color.FromArgb(0x8A, 0x3D, 0x72);       // X-линии в списке
+        public static readonly Color NoLines = Color.FromArgb(0x9A, 0x9A, 0x9A);    // .nuc.nolines
+        // --bar: rgba(20,72,116,.22) — микро-бар интенсивности полупрозрачный намеренно,
+        // иначе на выбранной строке сливался бы с --sel
+        public static readonly Color Bar = Color.FromArgb(56, 0x14, 0x48, 0x74);
+
+        // 12px/1.4 "Segoe UI" из темы — это 9 pt
+        public static Font BaseFont
+        {
+            get { return new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); }
+        }
+
+        public static Font LegendFont
+        {
+            get { return new Font("Segoe UI", 9F, FontStyle.Bold, GraphicsUnit.Point); }
+        }
+
+        // 11px мелкого текста списков (.nuc .hl) — 8.25 pt
+        public static Font HintFont
+        {
+            get { return new Font("Segoe UI", 8.25F, FontStyle.Regular, GraphicsUnit.Point); }
+        }
+
+        // 9.5px полужирного бейджа семейства (.fbadge) — 7.125 pt
+        public static Font BadgeFont
+        {
+            get { return new Font("Segoe UI", 7.125F, FontStyle.Bold, GraphicsUnit.Point); }
+        }
+
+        // Цвета бейджей типов линий — правила .b-g / .b-x / .b-xrf / .b-sec темы.
+        // Ключ — не подпись (она переводится), а код типа.
+        public static void LineTypeColors(string kind, out Color back, out Color fore)
+        {
+            switch (kind)
+            {
+                case "g":   back = Color.FromArgb(0xD3, 0xE0, 0xEE); fore = Color.FromArgb(0x12, 0x50, 0x7A); return;
+                case "x":   back = Color.FromArgb(0xEC, 0xD9, 0xE8); fore = Color.FromArgb(0x8A, 0x4A, 0x7A); return;
+                case "xrf": back = Color.FromArgb(0xF6, 0xE6, 0xC8); fore = Color.FromArgb(0x8A, 0x64, 0x20); return;
+                case "sec": back = Color.FromArgb(0xD9, 0xE8, 0xDC); fore = Color.FromArgb(0x2F, 0x6B, 0x42); return;
+                default:    back = Chip;                             fore = Ink;                             return;
+            }
+        }
+
+        // Цвета бейджей семейств — правила .f-popular … .f-waste темы, пара «фон/текст».
+        // Коды классификации: NORM, MED, IND, SNM по ANSI N42.34, остальные вне стандарта.
+        public static void FamilyColors(string code, out Color back, out Color fore)
+        {
+            switch ((code ?? "").ToLowerInvariant())
+            {
+                case "popular": back = Color.FromArgb(0xE2, 0xF0, 0xDC); fore = Color.FromArgb(0x2F, 0x6B, 0x3F); return;
+                case "norm":    back = Color.FromArgb(0xDC, 0xE9, 0xF5); fore = Color.FromArgb(0x28, 0x52, 0x7A); return;
+                case "med":     back = Color.FromArgb(0xF5, 0xE2, 0xEF); fore = Color.FromArgb(0x8A, 0x3D, 0x72); return;
+                case "ind":     back = Color.FromArgb(0xE6, 0xE2, 0xF5); fore = Color.FromArgb(0x4B, 0x3F, 0x8A); return;
+                case "snm":     back = Color.FromArgb(0xFD, 0xF0, 0xD0); fore = Color.FromArgb(0x8A, 0x6A, 0x1F); return;
+                case "fiss":    back = Color.FromArgb(0xFD, 0xE2, 0xDE); fore = Color.FromArgb(0x93, 0x37, 0x2C); return;
+                case "naa":     back = Color.FromArgb(0xDF, 0xF0, 0xF2); fore = Color.FromArgb(0x27, 0x6B, 0x73); return;
+                case "waste":   back = Color.FromArgb(0xEC, 0xE6, 0xDE); fore = Color.FromArgb(0x6B, 0x5A, 0x45); return;
+                default:        back = Chip;                             fore = Ink;                             return;
+            }
+        }
+
+        // Применяется после InitializeComponent: обходит дерево контролов и красит то,
+        // что в вебе окрашено темой. Системные цвета трогаются только там, где тема
+        // задаёт своё — фон окна (#f0f0f0) и так совпадает с системным.
+        public static void Apply(Control root)
+        {
+            root.Font = BaseFont;
+            Walk(root);
+        }
+
+        static void Walk(Control parent)
+        {
+            foreach (Control control in parent.Controls)
+            {
+                GroupBox box = control as GroupBox;
+                if (box != null)
+                {
+                    // легенда панели — акцентным цветом и полужирным, как .gbox > .lg
+                    box.ForeColor = AccentInk;
+                    box.Font = LegendFont;
+                    Walk(box);
+                    // содержимое панели остаётся обычным шрифтом
+                    foreach (Control child in box.Controls)
+                    {
+                        child.Font = BaseFont;
+                    }
+                    continue;
+                }
+
+                Table table = control as Table;
+                if (table != null)
+                {
+                    table.GridColor = Grid;
+                    table.GridLines = GridLines.Both;
+                    table.SelectionBackColor = Selection;
+                    table.SelectionForeColor = Ink;
+                    table.ForeColor = Ink;
+                    table.BackColor = Card;
+                    continue;
+                }
+
+                StatusStrip status = control as StatusStrip;
+                if (status != null)
+                {
+                    status.BackColor = Panel;
+                    status.ForeColor = AccentInk;
+                    continue;
+                }
+
+                ListBox list = control as ListBox;
+                if (list != null)
+                {
+                    list.ForeColor = Accent;      // чипы «Выбрано» — акцентным, как в вебе
+                    continue;
+                }
+
+                NumericUpDown numeric = control as NumericUpDown;
+                if (numeric != null)
+                {
+                    ApplyNumberPadding(numeric);
+                    continue;
+                }
+
+                Label label = control as Label;
+                if (label != null && label.Text.EndsWith(":", StringComparison.Ordinal))
+                {
+                    label.ForeColor = Muted;      // подписи-заголовки списков приглушены
+                    continue;
+                }
+
+                Walk(control);
+            }
+        }
+
+        // Отступ числа от правого края поля. В теме у input задан padding 1px 4px,
+        // а у поля ввода WinForms отступа нет ни свойством, ни стилем: внутренние
+        // поля текста задаёт окну сообщение EM_SETMARGINS. Число прижимается вправо
+        // (TextAlign задан в разметке), и без отступа оно упиралось бы в стрелки.
+        const int EM_SETMARGINS = 0x00D3;
+        const int EC_RIGHTMARGIN = 0x0002;
+        const int NumberPadding = 4;                 // px, как padding-right в теме
+
+        [DllImport("user32.dll", CharSet = CharSet.Auto)]
+        static extern IntPtr SendMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam);
+
+        static void ApplyNumberPadding(NumericUpDown numeric)
+        {
+            // поле ввода счётчика — его дочерний контрол; сообщение шлётся окну,
+            // поэтому отступ ставится и заново при каждом пересоздании дескриптора
+            foreach (Control child in numeric.Controls)
+            {
+                TextBoxBase edit = child as TextBoxBase;
+                if (edit == null)
+                {
+                    continue;
+                }
+                SetRightMargin(edit);
+                edit.HandleCreated += delegate(object sender, EventArgs e)
+                {
+                    SetRightMargin((Control)sender);
+                };
+            }
+        }
+
+        static void SetRightMargin(Control edit)
+        {
+            if (!edit.IsHandleCreated)
+            {
+                return;
+            }
+            SendMessage(edit.Handle, EM_SETMARGINS,
+                        (IntPtr)EC_RIGHTMARGIN, (IntPtr)(NumberPadding << 16));
+        }
+    }
+}
diff --git a/BecquerelMonitor/RoiWizard/ZoneCalculator.cs b/BecquerelMonitor/RoiWizard/ZoneCalculator.cs
new file mode 100644
index 0000000..eb8f017
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/ZoneCalculator.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace BecquerelMonitor.RoiWizard
+{
+    public enum RoiStyle
+    {
+        // маркеры: границы −10, высота задаётся Intencity
+        Markers,
+        // зоны вокруг пика
+        Zones,
+        ZonesWithMarkers
+    }
+
+    public enum ZoneWidthMode
+    {
+        // процент от энергии — как задаётся ширина ROI в самом BecqMoni
+        PercentOfEnergy,
+        // k × FWHM по модели разрешения
+        FwhmFactor
+    }
+
+    // Границы ROI-зоны. Вынесено из SetExporter, чтобы проверки и тесты не тянули
+    // за собой типы BecqMoni: расчёт границ — чистая арифметика над энергией.
+    public class ZoneCalculator
+    {
+        readonly ResolutionModel resolution;
+
+        public ZoneCalculator(ResolutionModel resolution)
+        {
+            this.resolution = resolution;
+            this.Style = RoiStyle.Markers;
+            this.WidthMode = ZoneWidthMode.PercentOfEnergy;
+            this.ZonePercent = 5.0;
+            this.ZoneFwhmFactor = 3.0;
+        }
+
+        public RoiStyle Style { get; set; }
+        public ZoneWidthMode WidthMode { get; set; }
+        public double ZonePercent { get; set; }
+        public double ZoneFwhmFactor { get; set; }
+
+        // Для режима маркеров BecqMoni ожидает -10: это признак того, что зоны нет,
+        // а запись рисуется штрихом высотой по Intencity.
+        public void LimitsFor(SpectralLine line, out double lower, out double upper)
+        {
+            if (this.Style == RoiStyle.Markers)
+            {
+                lower = -10;
+                upper = -10;
+                return;
+            }
+            double halfWidth = this.WidthMode == ZoneWidthMode.PercentOfEnergy
+                ? line.Energy * this.ZonePercent / 100.0 / 2.0
+                : this.ZoneFwhmFactor * this.resolution.Fwhm(line.Energy) / 2.0;
+            lower = Math.Floor(line.Energy - halfWidth);
+            upper = Math.Ceiling(line.Energy + halfWidth);
+        }
+    }
+}
diff --git a/BecquerelMonitor/RoiWizard/help.xml b/BecquerelMonitor/RoiWizard/help.xml
new file mode 100644
index 0000000..31fc6da
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/help.xml
@@ -0,0 +1,5 @@
+
+
+  <p><b>Зачем.</b> Инструмент собирает два файла для BecqMoni из снимка ядерных данных IAEA Live Chart (ENSDF): <b>ROI-конфигурацию</b> (маркеры и зоны на энергетической оси) и <b>набор нуклидов</b> — именованный <code>NuclideSet</code>, который дописывается в вашу библиотеку <code>NuclideDefinition.xml</code>. Страница автономна: данные лежат снимком в <code>data/nuclides.js</code>, обращений в сеть нет.</p><p><b>Шаг 1 · Изотопы.</b> Три способа набрать источники: поиск по имени (или по коду семейства), готовая группа — 8 семейств по ANSI N42.34 и 3 ряда распада, — и панель ХРИ (характеристический рентген материалов защиты и детектора). У поиска и у группы одинаковые три кнопки: <b>добавить</b> нуклид сам по себе, <b>+ линии семейства</b> (линии дочерних идут под именем родителя, одной записью) и <b>+ цепочка</b> (дочерние отдельными нуклидами, родитель в скобках имени). Скобки не косметика: по ним BecqMoni узнаёт цепочку и связывает амплитуды линий. Пять готовых наборов под полем поиска дают рабочий пример в один клик.</p><p><b>Шаг 2 · Линии.</b> <b>R</b> — паспортное разрешение детектора на 662 кэВ; из него считается FWHM(E) = R·√(662·E)/100 (модель для сцинтилляторов). <b>Объединить близкие</b> сливает линии, которые ваш детектор всё равно не разделит: порог <b>Sparrow 0,85·FWHM</b> — физический предел разрешимости двух пиков, для ROI-маркеров; <b>якорный набор 0,25·FWHM</b> — для выгрузки в библиотеку, потому что пары от 0,25 до 0,85 FWHM разбирает библиотечный фит, и сливать их — обеднять набор. Фильтры (интенсивность, энергия, период полураспада) управляют <b>выбором</b> линий, а галки типов — только видимостью. Ниже — расчёт вторичных пиков (обратное рассеяние, комптон-край, вылеты 511 и 1022, суммирование, наложение) и поиск близких линий по всей базе: «кто ещё светит рядом с этой энергией».</p><p><b>Шаг 3 · Оформление и экспорт.</b> Режим маркеров или зон, ширина зоны в процентах от энергии либо через k·FWHM, цвета по цепочке или по нуклиду. <b>Скачать ROI .xml</b> даёт файл для <code>%APPDATA%\BecqMoni\config\ROI\</code>. <b>Дописать в библиотеку</b> просит ваш <code>NuclideDefinition.xml</code> и возвращает его копию с добавленным набором — существующие нуклиды и наборы сохраняются, рядом скачивается бэкап оригинала.</p><p><b>Якорь.</b> Ровно одна линия набора помечается <code>IsAnchor=true</code>: найдя её в спектре, BecqMoni сажает остальные линии на табличные позиции и подгоняет их амплитуды. Без якоря библиотечный фит не запускается вовсе. Автовыбор берёт сильную и одинокую линию (для Th-232 это 2614,5 кэВ, для U-238 — 609,3), но её можно назначить вручную. Галка <b>полный набор</b> выгружает все линии выбранных нуклидов минуя фильтры: по прогонам разработчика BecqMoni выигрыш даёт именно полная цепочка, а набор из одних сильных линий фиту ничего не добавляет.</p><p><b>Перед сохранением</b> данные проверяются. Для ROI совпавшие энергии, нулевой выход и перекрытие зон — замечания: сохранить можно. Для набора первые два — ошибки: две линии на одной энергии вырождают подгонку амплитуд, а нулевая интенсивность выбрасывает линию из связки по цепочке.</p><p><b>Границы применимости.</b> База — снимок, а не живой запрос к IAEA (API не отдаёт CORS); обновляется скриптом <code>update_nuclides.py</code> или кнопкой в GitHub Actions. Модель ширины пика — для сцинтилляторов; для HPGe она занижает ширину на низких энергиях. Интенсивности ХРИ условные (Kα1 = 100) — это маркеры, не выходы. Вторичные пики расчётные. Коэффициенты Бк/cps не заполняются: их задают в BecqMoni по кривой эффективности. Инструмент неофициальный и частью BecqMoni не является.</p><p><b>Источники и стандарты.</b> В таблице — что именно взято и откуда.</p><table class="srctbl"><thead><tr><th>Что взято</th><th>Откуда</th></tr></thead><tbody><tr><td>Энергии и интенсивности γ-линий, периоды полураспада, ветвления распада — база <b>121 нуклида</b>, порог γ ≥ 0,05 %</td><td><a href="https://www-nds.iaea.org/relnsd/vcharthtml/VChartHTML.html" target="_blank" rel="noopener">IAEA Live Chart of Nuclides</a>, оценённые данные <a href="https://www.nndc.bnl.gov/ensdf/" target="_blank" rel="noopener">ENSDF</a> (NNDC, Брукхейвенская национальная лаборатория). Снимок лежит в <code>data/nuclides.js</code>, его дата — в подвале «об источниках данных»</td></tr><tr><td>Рентген, сопровождающий распад (K- и L-серии дочерних атомов), порог ≥ 0,5 %</td><td>тот же снимок IAEA/ENSDF, отдельная таблица линий</td></tr><tr><td>Энергии ХРИ <b>10 элементов</b> защиты и детектора (Fe, Cu, Cd, Sn, Te, I, Ba, La, W, Pb); интенсивности условные, Kα1 = 100</td><td><a href="https://physics.nist.gov/PhysRefData/XrayTrans/Html/search.html" target="_blank" rel="noopener">NIST X-ray Transition Energies Database</a>, свод Бердена</td></tr><tr><td>Коды семейств <b>NORM, MED, IND, SNM</b></td><td><a href="https://standards.ieee.org/standard/N42_34-2021.html" target="_blank" rel="noopener">IEEE/ANSI N42.34-2021</a> — критерии для носимых идентификаторов радионуклидов (RIID). Группы FISS, NAA, WASTE вне стандарта, заведены под задачи спектрометрии</td></tr><tr><td>Формулы вторичных пиков: BS = E/(1+2E/511), CE = E − BS, SE = E − 511, DE = E − 1022, вылет K-рентгена иода <b>−28 кэВ</b></td><td>G. F. Knoll, <a href="https://www.wiley.com/en-us/Radiation+Detection+and+Measurement,+4th+Edition-p-9780470131480" target="_blank" rel="noopener">Radiation Detection and Measurement</a> (Wiley), гл. 10: комптоновский край — разд. II.B; пик обратного рассеяния (0,2–0,25 МэВ), пики вылета и суммирование каскадов — разд. III; иодный вылет — рис. 10.10</td></tr><tr><td>Почему нужны поправки: край размыт разрешением, а пик ищется методом <b>второй производной</b>, поэтому найденный центроид не совпадает с обрывом</td><td>Knoll, гл. 10, разд. II.B (влияние энергии связи электронов); G. Gilmore, <a href="https://www.wiley.com/en-us/practical-gamma-ray-spectrometry-3rd-edition-p-9781119896081" target="_blank" rel="noopener">Practical Gamma-ray Spectrometry</a> (Wiley), разд. 9.2.3 — алгоритм Марискотти</td></tr><tr><td>Числа поправок: комптон-край <b>−0,8·FWHM</b>, обратное рассеяние <b>+10 кэВ</b>; доли от родительской линии BS 8 %, CE 6 %, SE 6 %</td><td>измерения комплекса Gamma-1C (NaI(Tl) 63×63, защита Pb 50 мм с вкладышем Cd/Cu): 9 нуклидов, 18 первичных линий, взята <b>31 запись из 41</b> — остальные отброшены как наложения. Разбор — <code>docs/REFERENCES-secondary-peaks.md</code></td></tr><tr><td>Коэффициенты равновесия ряда: Tl-208 <b>×0,3594</b>, Po-212 ×0,6406, Th-227 ×0,9862, Fr-223 ×0,0138, Tl-207 ×0,99724, Po-211 ×0,00276</td><td>ветвления на развилках рядов (Bi-212 β 64,06 % / α 35,94 %; Ac-227; Bi-211) по данным ENSDF</td></tr><tr><td>Пороги слияния <b>0,85·FWHM</b> (предел Sparrow) и <b>0,25·FWHM</b> (якорный набор), гейт значимости Fisher <b>z ≥ 4</b>, требование ровно одного <code>IsAnchor</code></td><td>исходники <a href="https://github.com/Am6er/BecqMoni" target="_blank" rel="noopener">Am6er/BecqMoni</a>, файл <code>BecquerelMonitor/LibraryPeakFitter.cs</code></td></tr><tr><td>Форматы файлов: ROI <code>FormatVersion 120920</code>, структура <code>NuclideDefinitionFile</code> (NuclideDefinitions + NuclideSets, привязка по guid)</td><td>те же исходники: <code>ROIConfigData.cs</code>, <code>ROIDefinitionData.cs</code>, <code>NuclideDefinition.cs</code>, <code>NuclideSet.cs</code></td></tr></tbody></table>
+  <p><b>What it is for.</b> The tool builds two files for BecqMoni out of a snapshot of IAEA Live Chart (ENSDF) nuclear data: an <b>ROI configuration</b> (markers and zones on the energy axis) and a <b>nuclide set</b> — a named <code>NuclideSet</code> appended to your <code>NuclideDefinition.xml</code> library. The page is self-contained: the data sit as a snapshot in <code>data/nuclides.js</code>, nothing is fetched over the network.</p><p><b>Step 1 · Nuclides.</b> Three ways to gather sources: search by name (or by family code), a ready-made group — 8 families per ANSI N42.34 and 3 decay series — and the XRF panel (characteristic X-rays of shielding and detector materials). Search and group share the same three buttons: <b>add</b> the nuclide on its own, <b>+ family lines</b> (daughter lines go under the parent’s name as one entry) and <b>+ chain</b> (daughters as separate nuclides, parent in brackets). The brackets are not cosmetic: BecqMoni reads the chain from them and couples the line amplitudes. The five presets under the search box give a working example in one click.</p><p><b>Step 2 · Lines.</b> <b>R</b> is the rated detector resolution at 662 keV; FWHM(E) = R·√(662·E)/100 follows from it (a scintillator model). <b>Merge close lines</b> merges what your detector cannot resolve anyway: the <b>Sparrow limit, 0.85·FWHM</b>, is the physical resolvability limit of two peaks and applies to ROI markers; the <b>anchored set, 0.25·FWHM</b>, is for library exports, because pairs between 0.25 and 0.85 FWHM are resolved by the library fit and merging them only impoverishes the set. Filters (intensity, energy, half-life) drive the <b>selection</b> of lines, while the type checkboxes only control visibility. Below are the secondary peaks (backscatter, Compton edge, 511 and 1022 escapes, summing, pile-up) and the whole-database nearby-line search: who else emits around this energy.</p><p><b>Step 3 · Styling and export.</b> Markers or zones, zone width as a percentage of energy or via k·FWHM, colours by chain or by nuclide. <b>Download ROI .xml</b> gives the file for <code>%APPDATA%\BecqMoni\config\ROI\</code>. <b>Append to the library</b> asks for your <code>NuclideDefinition.xml</code> and returns a copy with the set added — existing nuclides and sets are kept, and a backup of the original is downloaded alongside.</p><p><b>The anchor.</b> Exactly one line of the set is marked <code>IsAnchor=true</code>: once BecqMoni finds it in the spectrum, the remaining lines are placed at their tabulated positions and their amplitudes fitted. Without an anchor the library fit never starts. The automatic choice takes a strong and isolated line (2614.5 keV for Th-232, 609.3 for U-238), but you can set it by hand. The <b>full set</b> checkbox exports every line of the selected nuclides bypassing the filters: in the BecqMoni developer’s runs the gain comes precisely from the complete chain, while a set of strong lines alone adds nothing to the fit.</p><p><b>Before saving</b> the data are checked. For an ROI, coinciding energies, zero yield and overlapping zones are advisory — you can still save. For a set the first two are errors: two lines at one energy make the amplitude fit degenerate, and zero intensity drops a line out of the chain coupling.</p><p><b>Limits.</b> The database is a snapshot, not a live IAEA query (the API sends no CORS headers); it is refreshed by <code>update_nuclides.py</code> or by a GitHub Actions button. The peak-width model is for scintillators; for HPGe it underestimates the width at low energies. XRF intensities are nominal (Kα1 = 100) — markers, not yields. Secondary peaks are computed. Bq/cps coefficients are left empty: set them in BecqMoni from your efficiency curve. The tool is unofficial and not part of BecqMoni.</p><p><b>Sources and standards.</b> The table lists what exactly was taken and from where.</p><table class="srctbl"><thead><tr><th>What was taken</th><th>From where</th></tr></thead><tbody><tr><td>Energies and intensities of gamma lines, half-lives, decay branching — a base of <b>121 nuclides</b>, gamma threshold 0.05 %</td><td><a href="https://www-nds.iaea.org/relnsd/vcharthtml/VChartHTML.html" target="_blank" rel="noopener">IAEA Live Chart of Nuclides</a>, evaluated <a href="https://www.nndc.bnl.gov/ensdf/" target="_blank" rel="noopener">ENSDF</a> data (NNDC, Brookhaven National Laboratory). The snapshot lives in <code>data/nuclides.js</code>; its date is in the &laquo;about the data sources&raquo; footer</td></tr><tr><td>X-rays accompanying decay (K and L series of the daughter atoms), threshold 0.5 %</td><td>the same IAEA/ENSDF snapshot, separate line table</td></tr><tr><td>XRF energies for <b>10 elements</b> of shielding and detector (Fe, Cu, Cd, Sn, Te, I, Ba, La, W, Pb); intensities are nominal, K&alpha;1 = 100</td><td><a href="https://physics.nist.gov/PhysRefData/XrayTrans/Html/search.html" target="_blank" rel="noopener">NIST X-ray Transition Energies Database</a>, Bearden compilation</td></tr><tr><td>Family codes <b>NORM, MED, IND, SNM</b></td><td><a href="https://standards.ieee.org/standard/N42_34-2021.html" target="_blank" rel="noopener">IEEE/ANSI N42.34-2021</a> — performance criteria for handheld radionuclide identifiers (RIID). The FISS, NAA and WASTE groups sit outside the standard and were added for spectrometry work</td></tr><tr><td>Secondary-peak formulas: BS = E/(1+2E/511), CE = E − BS, SE = E − 511, DE = E − 1022, iodine K X-ray escape <b>−28 keV</b></td><td>G. F. Knoll, <a href="https://www.wiley.com/en-us/Radiation+Detection+and+Measurement,+4th+Edition-p-9780470131480" target="_blank" rel="noopener">Radiation Detection and Measurement</a> (Wiley), ch. 10: Compton edge in sec. II.B; backscatter peak (0.2–0.25 MeV), escape peaks and cascade summing in sec. III; iodine escape in fig. 10.10</td></tr><tr><td>Why the corrections are needed: the edge is smeared by the resolution while the peak is found by a <b>second-derivative</b> method, so the centroid cannot coincide with the drop</td><td>Knoll, ch. 10, sec. II.B (effect of electron binding); G. Gilmore, <a href="https://www.wiley.com/en-us/practical-gamma-ray-spectrometry-3rd-edition-p-9781119896081" target="_blank" rel="noopener">Practical Gamma-ray Spectrometry</a> (Wiley), sec. 9.2.3 — the Mariscotti algorithm</td></tr><tr><td>The correction figures: Compton edge <b>−0.8·FWHM</b>, backscatter <b>+10 keV</b>; fractions of the parent line BS 8 %, CE 6 %, SE 6 %</td><td>measurements on the Gamma-1C complex (NaI(Tl) 63×63, Pb 50 mm shield with a Cd/Cu liner): 9 nuclides, 18 primary lines, <b>31 records of 41</b> used — the rest dropped as overlaps. See <code>docs/REFERENCES-secondary-peaks.md</code></td></tr><tr><td>Series equilibrium factors: Tl-208 <b>×0.3594</b>, Po-212 ×0.6406, Th-227 ×0.9862, Fr-223 ×0.0138, Tl-207 ×0.99724, Po-211 ×0.00276</td><td>branching at the series forks (Bi-212 &beta; 64.06 % / &alpha; 35.94 %; Ac-227; Bi-211) from ENSDF data</td></tr><tr><td>Merge thresholds <b>0.85·FWHM</b> (Sparrow limit) and <b>0.25·FWHM</b> (anchored set), Fisher significance gate <b>z &ge; 4</b>, the single <code>IsAnchor</code> requirement</td><td>the <a href="https://github.com/Am6er/BecqMoni" target="_blank" rel="noopener">Am6er/BecqMoni</a> sources, <code>BecquerelMonitor/LibraryPeakFitter.cs</code></td></tr><tr><td>File formats: ROI <code>FormatVersion 120920</code>, the <code>NuclideDefinitionFile</code> structure (NuclideDefinitions + NuclideSets, guid binding)</td><td>the same sources: <code>ROIConfigData.cs</code>, <code>ROIDefinitionData.cs</code>, <code>NuclideDefinition.cs</code>, <code>NuclideSet.cs</code></td></tr></tbody></table>
+
diff --git a/BecquerelMonitor/RoiWizard/nuclides.xml b/BecquerelMonitor/RoiWizard/nuclides.xml
new file mode 100644
index 0000000..a54b202
--- /dev/null
+++ b/BecquerelMonitor/RoiWizard/nuclides.xml
@@ -0,0 +1,2429 @@
+
+
+  
+    
+    
+    
+    
+    
+    
+    
+    
+  
+  
+    
+      
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+      
+      
+      
+    
+    
+      
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+      
+      
+      
+    
+    
+      
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+      
+    
+    
+      
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+      
+    
+    
+      
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+      
+    
+    
+      
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+      
+      
+      
+    
+    
+      
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+      
+      
+      
+    
+    
+      
+      
+      
+      
+    
+    
+      
+      
+      
+      
+    
+    
+      
+      
+      
+      
+    
+    
+      
+      
+      
+      
+    
+    
+      
+      
+      
+      
+    
+    
+      
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+      
+    
+    
+      
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+      
+    
+    
+      
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+      
+    
+    
+      
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+      
+      
+        
+      
+    
+    
+      
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+      
+      
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+      
+      
+        
+        
+        
+        
+        
+      
+    
+    
+      
+      
+      
+      
+    
+    
+      
+        
+        
+      
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+      
+      
+      
+    
+  
+  
+    
+      
+        Th-232
+        Ra-228
+        Ac-228
+        Th-228
+        Ra-224
+        Rn-220
+        Po-216
+        Pb-212
+        Bi-212
+        Tl-208
+        Po-212
+      
+    
+    
+      
+        U-235
+        Th-231
+        Pa-231
+        Ac-227
+        Th-227
+        Fr-223
+        Ra-223
+        Rn-219
+        Po-215
+        Pb-211
+        Bi-211
+        Tl-207
+      
+    
+    
+      
+        U-238
+        Th-234
+        Pa-234m
+        U-234
+        Th-230
+        Ra-226
+        Rn-222
+        Po-218
+        Pb-214
+        Bi-214
+        Po-214
+        Pb-210
+        Bi-210
+        Po-210
+      
+    
+  
+  
+    
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+      
+    
+    
+      
+        
+        
+        
+        
+        
+        
+        
+        
+      
+    
+  
+

From 083be0b1b436607b9909e07b8c5747413f9982f2 Mon Sep 17 00:00:00 2001
From: Verter73 
Date: Sun, 26 Jul 2026 08:48:54 +0300
Subject: [PATCH 2/7] Help renderer: a method named Tag was hiding Control.Tag
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The markup walker called its handler Tag, which shadows the Tag property every
Control carries; the compiler warned (CS0108) and a reader would have to stop and
work out which of the two a call meant. Renamed to ApplyTag — nothing else changes.

With this the module compiles warning-free; what remains for the solution is the
project's own MSB3327 about a missing ClickOnce signing certificate.

Co-Authored-By: Claude Opus 5 
---
 BecquerelMonitor/RoiWizard/HelpForm.cs | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/BecquerelMonitor/RoiWizard/HelpForm.cs b/BecquerelMonitor/RoiWizard/HelpForm.cs
index e6ac34d..7bff0ff 100644
--- a/BecquerelMonitor/RoiWizard/HelpForm.cs
+++ b/BecquerelMonitor/RoiWizard/HelpForm.cs
@@ -143,7 +143,7 @@ void Render(string html)
                 string piece = token.Value;
                 if (piece[0] == '<')
                 {
-                    this.Tag(piece);
+                    this.ApplyTag(piece);
                 }
                 else
                 {
@@ -152,7 +152,9 @@ void Render(string html)
             }
         }
 
-        void Tag(string tag)
+        // Не «Tag»: у Control уже есть свойство с таким именем, и метод его скрывал —
+        // компилятор справедливо предупреждал (CS0108).
+        void ApplyTag(string tag)
         {
             bool closing = tag.StartsWith("
Date: Sun, 26 Jul 2026 14:07:20 +0300
Subject: [PATCH 3/7] Wizard window is a dock panel; checked-row highlight;
 presets reflow

RoiWizardForm inherits DockContent, so the window is a native BecqMoni
panel: the caption strip and buttons come from VS2015BlueTheme, it docks,
groups into tabs and auto-hides like the peak detection panel. Non-modal,
single instance, HideOnClose keeps the selection across reopenings; the
menu handler shows it via Show(dockPanel1, bounds).

Checked rows in the lines table get the web tint (#CDE4F7); the step-1
presets block measures its wrapped height so the last row is not clipped
in narrow columns. The help window (outside the dock system) gets a
panel-style caption drawn from the same theme palette; the app icon is
removed from both captions.

Core tests 100/100; clean Release build against master d1eab74.

Co-Authored-By: Claude Fable 5 
---
 BecquerelMonitor/MainForm.cs                  |  23 +-
 BecquerelMonitor/RoiWizard/HelpForm.cs        |   2 +
 .../RoiWizard/RoiWizardForm.Designer.cs       |   2 +
 BecquerelMonitor/RoiWizard/RoiWizardForm.cs   |  25 +-
 .../RoiWizard/RoiWizardStrings.Designer.cs    |   5 +
 .../RoiWizard/RoiWizardStrings.resx           |   3 +
 .../RoiWizard/RoiWizardStrings.ru.resx        |   3 +
 BecquerelMonitor/RoiWizard/WizardTheme.cs     | 320 ++++++++++++++++++
 8 files changed, 377 insertions(+), 6 deletions(-)

diff --git a/BecquerelMonitor/MainForm.cs b/BecquerelMonitor/MainForm.cs
index ae132a0..b5aae61 100644
--- a/BecquerelMonitor/MainForm.cs
+++ b/BecquerelMonitor/MainForm.cs
@@ -1269,14 +1269,27 @@ void RoiWizardToolStripMenuItem_Click(object sender, EventArgs e)
             this.ShowRoiWizardForm();
         }
 
-        // Окно мастера. Разрешение детектора передаётся делегатом: форма спросит его,
-        // когда пользователь нажмёт «из спектра», и оставит введённое руками значение,
-        // если взять неоткуда (0).
+        // Окно мастера — плавающая док-панель на общем dockPanel1: её можно пристыковать,
+        // сгруппировать с другими панелями и убрать в автоскрытие булавкой, как любую
+        // панель приложения. Экземпляр один: закрытие прячет панель (HideOnClose),
+        // повторный вызов из меню возвращает её со всеми настройками.
+        RoiWizard.RoiWizardForm roiWizardForm;
+
         public void ShowRoiWizardForm()
         {
-            using (RoiWizard.RoiWizardForm form = new RoiWizard.RoiWizardForm(this.RoiWizardResolution))
+            if (this.roiWizardForm == null || this.roiWizardForm.IsDisposed)
+            {
+                this.roiWizardForm = new RoiWizard.RoiWizardForm(this.RoiWizardResolution);
+                System.Drawing.Rectangle bounds = new System.Drawing.Rectangle(
+                    this.Location.X + Math.Max(0, (this.Width - 1200) / 2),
+                    this.Location.Y + Math.Max(0, (this.Height - 700) / 2),
+                    1200, 700);
+                this.roiWizardForm.Show(this.dockPanel1, bounds);
+            }
+            else
             {
-                form.ShowDialog(this);
+                this.roiWizardForm.Show(this.dockPanel1);
+                this.roiWizardForm.Activate();
             }
         }
 
diff --git a/BecquerelMonitor/RoiWizard/HelpForm.cs b/BecquerelMonitor/RoiWizard/HelpForm.cs
index 7bff0ff..17fd3d0 100644
--- a/BecquerelMonitor/RoiWizard/HelpForm.cs
+++ b/BecquerelMonitor/RoiWizard/HelpForm.cs
@@ -42,6 +42,8 @@ public HelpForm()
             this.MinimizeBox = false;
             this.MaximizeBox = true;
             this.ShowInTaskbar = false;
+            this.ShowIcon = false;
+            WizardTheme.ApplyCaption(this);        // синий заголовок, как у окон BecqMoni
 
             this.view.Dock = DockStyle.Fill;
             this.view.ReadOnly = true;
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs b/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs
index 3bd594a..1a2240e 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs
+++ b/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs
@@ -974,11 +974,13 @@ void InitializeComponent()
             this.statusStrip.Items.AddRange(new System.Windows.Forms.ToolStripItem[] {
                 this.statusLabel, this.buttonHelp, this.buttonStepPrev, this.buttonStepNext });
 
+
             // ─── форма ─────────────────────────────────────────────────────
             this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
             this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
             this.ClientSize = new System.Drawing.Size(1180, 620);
             this.MinimumSize = new System.Drawing.Size(1000, 500);
+            this.ShowIcon = false;
             this.Controls.Add(this.tabs);
             this.Controls.Add(this.statusStrip);
             this.Name = "RoiWizardForm";
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardForm.cs b/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
index 96243ba..5219a06 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
+++ b/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
@@ -6,6 +6,7 @@
 using System.Text;
 using System.Threading;
 using System.Windows.Forms;
+using WeifenLuo.WinFormsUI.Docking;
 using System.Xml.Serialization;
 using XPTable.Events;
 using XPTable.Models;
@@ -15,7 +16,11 @@ namespace BecquerelMonitor.RoiWizard
     // Окно конструктора: три шага повторяют веб-версию инструмента, но результат
     // никуда не выгружается файлом — ROI-конфигурация уходит в ROIConfigManager,
     // а набор нуклидов в NuclideDefinitionManager.
-    public partial class RoiWizardForm : Form
+    // Наследование от DockContent делает окно родной док-панелью BecqMoni:
+    // полоска заголовка, булавка автоскрытия, группировка и прилипание рисуются
+    // и работают той же темой VS2015BlueTheme, что у «Обнаружения пиков» и
+    // остальных панелей — имитировать ничего не нужно.
+    public partial class RoiWizardForm : DockContent
     {
         readonly NuclideCatalog catalog;
         readonly SourceSelection selection = new SourceSelection();
@@ -45,6 +50,9 @@ public RoiWizardForm(Func resolutionProvider)
             this.InitializeComponent();
             // цвета и шрифт — из темы веб-версии, чтобы окно выглядело так же
             WizardTheme.Apply(this);
+            // закрытие панели прячет её, а не разрушает: повторное открытие из меню
+            // возвращает выбранные источники и настройки нетронутыми
+            this.HideOnClose = true;
             this.resolutionProvider = resolutionProvider;
 
             this.catalog = NuclideCatalog.GetInstance();
@@ -77,6 +85,7 @@ ResolutionModel Resolution
             get { return new ResolutionModel((double)this.numResolution.Value); }
         }
 
+
         // ─── наполнение ─────────────────────────────────────────────────────
 
         void FillCombos()
@@ -438,6 +447,17 @@ void LayoutSources()
             this.groupGroup.SetBounds(Pad + column + Gap, Top, middle, boxHeight);
             this.groupXrf.SetBounds(Pad + column + Gap + middle + Gap, Top, column, boxHeight);
 
+            // блок пресетов переносится по ширине колонки, и число строк меняется:
+            // высота блока пересчитывается под фактический перенос, иначе нижняя
+            // строка срезается краем панели; таблица каталога отдаёт ей место
+            int presetWidth = column - 12;
+            int presetHeight = this.panelPresets.GetPreferredSize(
+                new Size(presetWidth, 0)).Height;
+            int presetTop = this.groupSearch.ClientSize.Height - 8 - presetHeight;
+            this.panelPresets.SetBounds(6, presetTop, presetWidth, presetHeight);
+            this.labelSearchHint.Top = presetTop - this.labelSearchHint.Height - 2;
+            this.tableCatalog.Height = this.labelSearchHint.Top - 4 - this.tableCatalog.Top;
+
             // ряды кнопок делят ширину своей панели: жёсткие ширины не влезают,
             // когда колонка уже суммы масштабированных кнопок (.line — flex-строка)
             LayoutButtonRow(this.groupSearch, new Control[] {
@@ -708,6 +728,7 @@ void OnLineCheckChanged(object sender, XPTable.Events.CellCheckBoxEventArgs e)
             if (line != null)
             {
                 line.Selected = row.Cells[0].Checked;
+                row.BackColor = line.Selected ? WizardTheme.Selection : WizardTheme.Card;
                 this.UpdateStatus();
             }
         }
@@ -1104,6 +1125,8 @@ void RefreshLines()
                 kind.Tag = TypeKind(line.Type);    // цвет бейджа — по коду, не по подписи
                 row.Cells.Add(kind);
                 row.Tag = line;
+                // отмеченная строка тонируется, как в вебе: tr.selrow{background:var(--sel)}
+                row.BackColor = line.Selected ? WizardTheme.Selection : WizardTheme.Card;
                 this.tableModelLines.Rows.Add(row);
             }
             this.tableLines.ResumeLayout();
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs b/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs
index 406863b..f0e02f8 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs
+++ b/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs
@@ -92,6 +92,11 @@ public static string stepExport
             get { return Get("stepExport", "Styling and export"); }
         }
 
+        public static string pinTip
+        {
+            get { return Get("pinTip", "Keep on top of other windows"); }
+        }
+
         public static string groupSearch_Text
         {
             get { return Get("groupSearch_Text", "Nuclide search"); }
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx b/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx
index 2b71453..0bec4af 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx
+++ b/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx
@@ -91,6 +91,9 @@
   
     Styling and export
   
+  
+    Keep on top of other windows
+  
   
     Nuclide search
   
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx b/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx
index 6f91fc1..aa16187 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx
+++ b/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx
@@ -91,6 +91,9 @@
   
     Оформление и экспорт
   
+  
+    Поверх остальных окон
+  
   
     Поиск изотопа
   
diff --git a/BecquerelMonitor/RoiWizard/WizardTheme.cs b/BecquerelMonitor/RoiWizard/WizardTheme.cs
index a5635a2..cb61cb0 100644
--- a/BecquerelMonitor/RoiWizard/WizardTheme.cs
+++ b/BecquerelMonitor/RoiWizard/WizardTheme.cs
@@ -96,8 +96,68 @@ public static void Apply(Control root)
         {
             root.Font = BaseFont;
             Walk(root);
+            // рамку окна Apply не трогает: окно мастера — DockContent, его полоску
+            // рисует тема DockPanelSuite самого приложения; самодельная полоска
+            // (ApplyCaption) нужна только окнам вне док-системы — справке
         }
 
+        // Полоска заголовка — как у док-панелей самого BecqMoni («Обнаружение пиков»,
+        // «Управление измерением»): цвет ToolWindowCaptionInactive.Background из
+        // VS2015BlueTheme, которую MainForm ставит в InitializeDockPanelTheme, та же
+        // высота, булавка и кнопки прямо в полоске, углы без скругления. Системный
+        // заголовок Windows и толще, и не пускает в себя булавку, поэтому он убирается
+        // совсем (WM_NCCALCSIZE), а полоска рисуется своя. Стили окна при этом
+        // сохраняются — перетаскивание, ресайз за края и снап работают как обычно.
+        // Все значения — из ColorPalette той же темы (снятые рефлексией):
+        // ToolWindowCaptionInactive.*, ToolWindowCaptionButton*, ToolWindowBorder.
+        internal static readonly Color CaptionBack = Color.FromArgb(0x4D, 0x60, 0x82);
+        internal static readonly Color CaptionGlyph = Color.FromArgb(0xCE, 0xD4, 0xDD);
+        internal static readonly Color CaptionHover = Color.FromArgb(0xFF, 0xFC, 0xF4);
+        internal static readonly Color CaptionHoverEdge = Color.FromArgb(0xE5, 0xC3, 0x65);
+        internal static readonly Color CaptionDown = Color.FromArgb(0xFF, 0xE8, 0xA6);
+        internal static readonly Color WindowEdge = Color.FromArgb(0x8E, 0x9B, 0xBC);
+
+        const int DWMWA_WINDOW_CORNER_PREFERENCE = 33;
+        const int DWMWA_BORDER_COLOR = 34;
+        const int DWMWA_CAPTION_COLOR = 35;
+        const int DWMWA_TEXT_COLOR = 36;
+        const int DWMWCP_DONOTROUND = 1;      // у полосок панелей скругления нет
+
+        [DllImport("dwmapi.dll")]
+        internal static extern int DwmSetWindowAttribute(IntPtr window, int attribute,
+                                                         ref int value, int size);
+
+        public static void ApplyCaption(Form form)
+        {
+            new PanelChrome(form);
+        }
+
+        internal static int ColorRef(Color color)
+        {
+            // DWM ждёт 0x00BBGGRR (COLORREF), а Color.ToArgb — 0xAARRGGBB
+            return color.R | (color.G << 8) | (color.B << 16);
+        }
+
+        internal static void SetDwm(Form form)
+        {
+            IntPtr handle = form.Handle;
+            int caption = ColorRef(CaptionBack);    // видно только в миниатюрах Alt-Tab
+            int text = ColorRef(Card);
+            int border = ColorRef(CaptionBack);
+            int corner = DWMWCP_DONOTROUND;
+            try
+            {
+                DwmSetWindowAttribute(handle, DWMWA_CAPTION_COLOR, ref caption, sizeof(int));
+                DwmSetWindowAttribute(handle, DWMWA_TEXT_COLOR, ref text, sizeof(int));
+                DwmSetWindowAttribute(handle, DWMWA_BORDER_COLOR, ref border, sizeof(int));
+                DwmSetWindowAttribute(handle, DWMWA_WINDOW_CORNER_PREFERENCE,
+                                      ref corner, sizeof(int));
+            }
+            catch (DllNotFoundException) { }        // dwmapi.dll есть везде, где есть Aero
+            catch (EntryPointNotFoundException) { } // на всякий случай
+        }
+
+
         static void Walk(Control parent)
         {
             foreach (Control control in parent.Controls)
@@ -202,4 +262,264 @@ static void SetRightMargin(Control edit)
                         (IntPtr)EC_RIGHTMARGIN, (IntPtr)(NumberPadding << 16));
         }
     }
+
+    // Полоска заголовка в стиле док-панелей BecqMoni плюс подкласс окна, который
+    // прячет системный caption. NativeWindow используется, чтобы не заставлять формы
+    // наследоваться от специального базового класса.
+    sealed class PanelChrome : NativeWindow
+    {
+        // Метрики VS2012DockPaneCaption (их переиспользует тема VS2015): высота =
+        // кнопка 18px + зазоры 3 сверху и снизу (текстовая ветка формулы даёт меньше
+        // и не побеждает); кнопки — квадраты с зазором 1 между собой и 4 от правого
+        // края. Высота 24px сверена пиксельно с живой панелью «Обнаружение пиков».
+        const int StripHeight = 24;               // высота полоски док-панели, лог. px
+        const int ButtonSize = 18;
+        const int ButtonGapTop = 3;
+        const int ButtonGapBetween = 1;
+        const int ButtonGapRight = 4;
+        const int Grip = 6;                       // зона ресайза по краям, лог. px
+
+        const int WM_NCCALCSIZE = 0x0083;
+        const int WM_NCHITTEST = 0x0084;
+        const int WM_NCLBUTTONDOWN = 0x00A1;
+        const int HTCLIENT = 1, HTCAPTION = 2;
+        const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14;
+        const int HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17;
+        const int SM_CXSIZEFRAME = 32, SM_CXPADDEDBORDER = 92;
+
+        [DllImport("user32.dll")]
+        static extern bool ReleaseCapture();
+        [DllImport("user32.dll")]
+        static extern IntPtr SendMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam);
+        [DllImport("user32.dll")]
+        static extern int GetSystemMetrics(int index);
+
+        [StructLayout(LayoutKind.Sequential)]
+        struct RECT { public int Left, Top, Right, Bottom; }
+
+        readonly Form form;
+        readonly Panel strip;
+        readonly CheckBox pin;
+        readonly Button buttonMax;
+        readonly ToolTip tips = new ToolTip();
+
+        public PanelChrome(Form owner)
+        {
+            this.form = owner;
+            this.strip = new Panel();
+            this.strip.Dock = DockStyle.Top;
+            this.strip.Height = this.Scaled(StripHeight);
+            this.strip.BackColor = WizardTheme.CaptionBack;
+            this.strip.Paint += this.OnStripPaint;
+            this.strip.MouseDown += this.OnStripMouseDown;
+            this.strip.MouseDoubleClick += this.OnStripDoubleClick;
+
+            Button close = this.MakeButton("");             // ChromeClose
+            close.Click += delegate { this.form.Close(); };
+            this.buttonMax = this.MakeButton("");           // ChromeMaximize
+            this.buttonMax.Visible = this.form.MaximizeBox;
+            this.buttonMax.Click += delegate { this.ToggleMaximize(); };
+            Button minimize = this.MakeButton("");          // ChromeMinimize
+            minimize.Visible = this.form.MinimizeBox;
+            minimize.Click += delegate { this.form.WindowState = FormWindowState.Minimized; };
+
+            this.pin = new CheckBox();
+            this.pin.Appearance = Appearance.Button;
+            this.StyleFlat(this.pin);
+            this.pin.Text = "";                             // Pin
+            this.pin.FlatAppearance.CheckedBackColor = WizardTheme.CaptionDown;
+            this.pin.CheckedChanged += delegate
+            {
+                this.form.TopMost = this.pin.Checked;
+                this.pin.Text = this.pin.Checked ? "" : "";
+            };
+            this.tips.SetToolTip(this.pin, RoiWizardStrings.pinTip);
+
+            // порядок добавления — это порядок укладки от правого края
+            this.strip.Controls.Add(close);
+            this.strip.Controls.Add(this.buttonMax);
+            this.strip.Controls.Add(minimize);
+            this.strip.Controls.Add(this.pin);
+            this.strip.Resize += delegate { this.LayoutButtons(); };
+            this.LayoutButtons();
+
+            // окантовка окна — ToolWindowBorder темы: кольцо в 1px из фона формы
+            // плюс кромка DWM того же цвета
+            this.form.BackColor = WizardTheme.WindowEdge;
+            this.form.Padding = new Padding(1);
+
+            // полоска добавляется последней: док-раскладка обходит контролы с конца,
+            // поэтому она займёт верх раньше, чем Fill заберёт остаток
+            this.form.Controls.Add(this.strip);
+            this.form.TextChanged += delegate { this.strip.Invalidate(); };
+            this.form.Resize += delegate { this.SyncMaxGlyph(); };
+
+            if (this.form.IsHandleCreated)
+            {
+                this.Attach();
+            }
+            this.form.HandleCreated += delegate { this.Attach(); };
+            this.form.HandleDestroyed += delegate { this.ReleaseHandle(); };
+        }
+
+        void Attach()
+        {
+            this.AssignHandle(this.form.Handle);
+            WizardTheme.SetDwm(this.form);
+            // рамки больше нет — окно чуть ужимается, чтобы содержимое осталось
+            // тех же пропорций, что и с системным заголовком
+            this.form.PerformLayout();
+        }
+
+        int Scaled(int logical)
+        {
+            return (int)Math.Round(logical * this.form.DeviceDpi / 96.0);
+        }
+
+        Button MakeButton(string glyph)
+        {
+            Button button = new Button();
+            this.StyleFlat(button);
+            button.Text = glyph;
+            return button;
+        }
+
+        void StyleFlat(ButtonBase button)
+        {
+            button.FlatStyle = FlatStyle.Flat;
+            button.FlatAppearance.BorderSize = 0;
+            button.FlatAppearance.BorderColor = WizardTheme.CaptionHoverEdge;
+            button.FlatAppearance.MouseOverBackColor = WizardTheme.CaptionHover;
+            button.FlatAppearance.MouseDownBackColor = WizardTheme.CaptionDown;
+            button.BackColor = WizardTheme.CaptionBack;
+            button.ForeColor = WizardTheme.CaptionGlyph;
+            button.Font = new Font("Segoe MDL2 Assets", 7F);
+            button.Size = new Size(this.Scaled(ButtonSize), this.Scaled(ButtonSize));
+            button.TabStop = false;
+            button.TextAlign = ContentAlignment.MiddleCenter;
+            // кремовая заливка с золотой рамкой и чёрным глифом — только под курсором,
+            // как у кнопок панелей темы
+            button.MouseEnter += delegate(object sender, EventArgs e)
+            {
+                ButtonBase self = (ButtonBase)sender;
+                self.FlatAppearance.BorderSize = 1;
+                self.ForeColor = WizardTheme.Ink;
+            };
+            button.MouseLeave += delegate(object sender, EventArgs e)
+            {
+                ButtonBase self = (ButtonBase)sender;
+                self.FlatAppearance.BorderSize = 0;
+                self.ForeColor = WizardTheme.CaptionGlyph;
+            };
+        }
+
+        void LayoutButtons()
+        {
+            int x = this.strip.Width - this.Scaled(ButtonGapRight);
+            int y = this.Scaled(ButtonGapTop);
+            foreach (Control button in this.strip.Controls)
+            {
+                if (!(button is ButtonBase))
+                {
+                    continue;
+                }
+                x -= button.Width;
+                button.Location = new Point(x, y);
+                x -= this.Scaled(ButtonGapBetween);
+            }
+        }
+
+        void OnStripPaint(object sender, PaintEventArgs e)
+        {
+            TextRenderer.DrawText(e.Graphics, this.form.Text, WizardTheme.BaseFont,
+                new Rectangle(this.Scaled(6), 0,
+                              this.strip.Width - this.Scaled(6), this.strip.Height),
+                WizardTheme.Card,
+                TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis |
+                TextFormatFlags.NoPrefix);
+        }
+
+        void OnStripMouseDown(object sender, MouseEventArgs e)
+        {
+            if (e.Button != MouseButtons.Left)
+            {
+                return;
+            }
+            int code = HTCAPTION;
+            if (this.form.WindowState == FormWindowState.Normal && e.Y <= this.Scaled(Grip))
+            {
+                int grip = this.Scaled(Grip);
+                code = e.X < grip ? HTTOPLEFT
+                     : e.X > this.strip.Width - grip ? HTTOPRIGHT : HTTOP;
+            }
+            ReleaseCapture();
+            SendMessage(this.form.Handle, WM_NCLBUTTONDOWN, (IntPtr)code, IntPtr.Zero);
+        }
+
+        void OnStripDoubleClick(object sender, MouseEventArgs e)
+        {
+            if (this.form.MaximizeBox)
+            {
+                this.ToggleMaximize();
+            }
+        }
+
+        void ToggleMaximize()
+        {
+            this.form.WindowState = this.form.WindowState == FormWindowState.Maximized
+                ? FormWindowState.Normal : FormWindowState.Maximized;
+        }
+
+        void SyncMaxGlyph()
+        {
+            this.buttonMax.Text = this.form.WindowState == FormWindowState.Maximized
+                ? "" : "";            // ChromeRestore : ChromeMaximize
+        }
+
+        protected override void WndProc(ref Message m)
+        {
+            switch (m.Msg)
+            {
+                case WM_NCCALCSIZE:
+                    if (m.WParam != IntPtr.Zero)
+                    {
+                        if (this.form.WindowState == FormWindowState.Maximized)
+                        {
+                            // развёрнутое окно без этой поправки вылезает рамкой
+                            // за края монитора
+                            RECT rect = (RECT)Marshal.PtrToStructure(m.LParam, typeof(RECT));
+                            int pad = GetSystemMetrics(SM_CXSIZEFRAME)
+                                    + GetSystemMetrics(SM_CXPADDEDBORDER);
+                            rect.Left += pad; rect.Top += pad;
+                            rect.Right -= pad; rect.Bottom -= pad;
+                            Marshal.StructureToPtr(rect, m.LParam, false);
+                        }
+                        m.Result = IntPtr.Zero;   // клиентская область = всё окно
+                        return;
+                    }
+                    break;
+
+                case WM_NCHITTEST:
+                    base.WndProc(ref m);
+                    if ((int)m.Result == HTCLIENT
+                        && this.form.WindowState == FormWindowState.Normal)
+                    {
+                        Point at = this.form.PointToClient(new Point(m.LParam.ToInt32()));
+                        int grip = this.Scaled(Grip);
+                        bool left = at.X < grip;
+                        bool right = at.X > this.form.ClientSize.Width - grip;
+                        bool top = at.Y < grip;
+                        bool bottom = at.Y > this.form.ClientSize.Height - grip;
+                        int hit = HTCLIENT;
+                        if (top) { hit = left ? HTTOPLEFT : right ? HTTOPRIGHT : HTTOP; }
+                        else if (bottom) { hit = left ? HTBOTTOMLEFT : right ? HTBOTTOMRIGHT : HTBOTTOM; }
+                        else if (left) { hit = HTLEFT; }
+                        else if (right) { hit = HTRIGHT; }
+                        m.Result = (IntPtr)hit;
+                    }
+                    return;
+            }
+            base.WndProc(ref m);
+        }
+    }
 }

From 77ad17ee59481cd6bcfddf0c0e8bb149c92bbb74 Mon Sep 17 00:00:00 2001
From: Verter73 
Date: Sun, 26 Jul 2026 15:03:08 +0300
Subject: [PATCH 4/7] Help window is a dock panel too; float size taken from
 the form

The help window had its own hand-drawn caption which did not match the strip
the theme paints for the wizard's float window. It is now a DockContent shown
as a floating panel, so the theme draws both windows and they agree by
construction; it is also no longer modal. The custom chrome is retired
entirely (PanelChrome, ApplyCaption, DWM calls, the WM_NCCALCSIZE hook) along
with the now-unused pinTip string.

ShowRoiWizardForm sized the float window with hard numbers while the form needs
1591x1001 - AutoScaleMode.Font grows the layout to the theme font, so the panel
opened clipped. The size now comes from the form and is clamped to the working
area of the screen it opens on.

Release build clean; core tests 100/100.

Co-Authored-By: Claude Fable 5 
---
 BecquerelMonitor/MainForm.cs                  |  14 +-
 BecquerelMonitor/RoiWizard/HelpForm.cs        |   3 +-
 BecquerelMonitor/RoiWizard/RoiWizardForm.cs   |  27 +-
 .../RoiWizard/RoiWizardStrings.Designer.cs    |   5 -
 .../RoiWizard/RoiWizardStrings.resx           |   3 -
 .../RoiWizard/RoiWizardStrings.ru.resx        |   3 -
 BecquerelMonitor/RoiWizard/WizardTheme.cs     | 324 +-----------------
 7 files changed, 41 insertions(+), 338 deletions(-)

diff --git a/BecquerelMonitor/MainForm.cs b/BecquerelMonitor/MainForm.cs
index b5aae61..4e3348a 100644
--- a/BecquerelMonitor/MainForm.cs
+++ b/BecquerelMonitor/MainForm.cs
@@ -1280,10 +1280,18 @@ public void ShowRoiWizardForm()
             if (this.roiWizardForm == null || this.roiWizardForm.IsDisposed)
             {
                 this.roiWizardForm = new RoiWizard.RoiWizardForm(this.RoiWizardResolution);
+                // Размер плавающего окна берётся у самой формы: её Size уже подогнан
+                // шрифтом темы (AutoScaleMode.Font укрупняет разметку), и плавающее
+                // окно отдаёт содержимому ровно столько же, сколько форма имеет в
+                // клиентской области. Жёсткие числа здесь обрезали бы содержимое.
+                System.Drawing.Size want = this.roiWizardForm.Size;
+                System.Drawing.Rectangle work = Screen.FromControl(this).WorkingArea;
+                want = new System.Drawing.Size(Math.Min(want.Width, work.Width),
+                                               Math.Min(want.Height, work.Height));
                 System.Drawing.Rectangle bounds = new System.Drawing.Rectangle(
-                    this.Location.X + Math.Max(0, (this.Width - 1200) / 2),
-                    this.Location.Y + Math.Max(0, (this.Height - 700) / 2),
-                    1200, 700);
+                    work.X + Math.Max(0, (work.Width - want.Width) / 2),
+                    work.Y + Math.Max(0, (work.Height - want.Height) / 2),
+                    want.Width, want.Height);
                 this.roiWizardForm.Show(this.dockPanel1, bounds);
             }
             else
diff --git a/BecquerelMonitor/RoiWizard/HelpForm.cs b/BecquerelMonitor/RoiWizard/HelpForm.cs
index 17fd3d0..8a8fc3b 100644
--- a/BecquerelMonitor/RoiWizard/HelpForm.cs
+++ b/BecquerelMonitor/RoiWizard/HelpForm.cs
@@ -16,7 +16,7 @@ namespace BecquerelMonitor.RoiWizard
     // а вынут из index.html скриптом tools/export_help.py в ресурс help.xml: два
     // текста иначе разъедутся после первой же правки страницы. Здесь разбирается
     // то же подмножество разметки, что использует страница: p, b, code, a, таблица.
-    public class HelpForm : Form
+    public class HelpForm : WeifenLuo.WinFormsUI.Docking.DockContent
     {
         const string ResourceName = "BecquerelMonitor.RoiWizard.help.xml";
 
@@ -43,7 +43,6 @@ public HelpForm()
             this.MaximizeBox = true;
             this.ShowInTaskbar = false;
             this.ShowIcon = false;
-            WizardTheme.ApplyCaption(this);        // синий заголовок, как у окон BecqMoni
 
             this.view.Dock = DockStyle.Fill;
             this.view.ReadOnly = true;
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardForm.cs b/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
index 5219a06..438c5e9 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
+++ b/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
@@ -2050,14 +2050,37 @@ static string SafeFileName(string name)
 
         // Справка — тот же текст, что в модальном окне страницы; он лежит ресурсом,
         // выгруженным из index.html, поэтому расходиться версиям негде.
+        //
+        // Окно показывается такой же плавающей панелью, как и сам мастер: тогда его
+        // заголовок рисует та же тема хоста, и два окна модуля выглядят одинаково.
+        // Подбирать цвета руками для второго окна бессмысленно — они разъедутся при
+        // любой смене темы. Вне док-системы (тесты, автономный прогон формы) остаётся
+        // обычный диалог.
         void ShowHelp()
         {
-            using (HelpForm help = new HelpForm())
+            if (this.helpForm != null && !this.helpForm.IsDisposed)
             {
-                help.ShowDialog(this);
+                this.helpForm.Activate();
+                return;
+            }
+            this.helpForm = new HelpForm();
+            if (this.DockPanel != null)
+            {
+                Size want = this.helpForm.Size;
+                Rectangle bounds = new Rectangle(
+                    this.Left + Math.Max(0, (this.Width - want.Width) / 2),
+                    this.Top + Math.Max(0, (this.Height - want.Height) / 2),
+                    want.Width, want.Height);
+                this.helpForm.Show(this.DockPanel, bounds);
+            }
+            else
+            {
+                this.helpForm.Show(this);
             }
         }
 
+        HelpForm helpForm;
+
         // Кнопки внизу подписываются именами соседних шагов, а на краях — обобщённо
         // и выключены. То же поведение, что у пары кнопок в строке состояния страницы.
         void GoStep(int delta)
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs b/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs
index f0e02f8..406863b 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs
+++ b/BecquerelMonitor/RoiWizard/RoiWizardStrings.Designer.cs
@@ -92,11 +92,6 @@ public static string stepExport
             get { return Get("stepExport", "Styling and export"); }
         }
 
-        public static string pinTip
-        {
-            get { return Get("pinTip", "Keep on top of other windows"); }
-        }
-
         public static string groupSearch_Text
         {
             get { return Get("groupSearch_Text", "Nuclide search"); }
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx b/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx
index 0bec4af..2b71453 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx
+++ b/BecquerelMonitor/RoiWizard/RoiWizardStrings.resx
@@ -91,9 +91,6 @@
   
     Styling and export
   
-  
-    Keep on top of other windows
-  
   
     Nuclide search
   
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx b/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx
index aa16187..6f91fc1 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx
+++ b/BecquerelMonitor/RoiWizard/RoiWizardStrings.ru.resx
@@ -91,9 +91,6 @@
   
     Оформление и экспорт
   
-  
-    Поверх остальных окон
-  
   
     Поиск изотопа
   
diff --git a/BecquerelMonitor/RoiWizard/WizardTheme.cs b/BecquerelMonitor/RoiWizard/WizardTheme.cs
index cb61cb0..79cda0d 100644
--- a/BecquerelMonitor/RoiWizard/WizardTheme.cs
+++ b/BecquerelMonitor/RoiWizard/WizardTheme.cs
@@ -96,68 +96,12 @@ public static void Apply(Control root)
         {
             root.Font = BaseFont;
             Walk(root);
-            // рамку окна Apply не трогает: окно мастера — DockContent, его полоску
-            // рисует тема DockPanelSuite самого приложения; самодельная полоска
-            // (ApplyCaption) нужна только окнам вне док-системы — справке
+            // Рамку окна Apply не трогает намеренно. Оба окна модуля — DockContent,
+            // и полоску заголовка с кнопками им рисует тема DockPanelSuite самого
+            // приложения. Своя отрисовка заголовка здесь была и удалена: подобранные
+            // руками цвета совпадали с одной темой и расходились с любой другой.
         }
 
-        // Полоска заголовка — как у док-панелей самого BecqMoni («Обнаружение пиков»,
-        // «Управление измерением»): цвет ToolWindowCaptionInactive.Background из
-        // VS2015BlueTheme, которую MainForm ставит в InitializeDockPanelTheme, та же
-        // высота, булавка и кнопки прямо в полоске, углы без скругления. Системный
-        // заголовок Windows и толще, и не пускает в себя булавку, поэтому он убирается
-        // совсем (WM_NCCALCSIZE), а полоска рисуется своя. Стили окна при этом
-        // сохраняются — перетаскивание, ресайз за края и снап работают как обычно.
-        // Все значения — из ColorPalette той же темы (снятые рефлексией):
-        // ToolWindowCaptionInactive.*, ToolWindowCaptionButton*, ToolWindowBorder.
-        internal static readonly Color CaptionBack = Color.FromArgb(0x4D, 0x60, 0x82);
-        internal static readonly Color CaptionGlyph = Color.FromArgb(0xCE, 0xD4, 0xDD);
-        internal static readonly Color CaptionHover = Color.FromArgb(0xFF, 0xFC, 0xF4);
-        internal static readonly Color CaptionHoverEdge = Color.FromArgb(0xE5, 0xC3, 0x65);
-        internal static readonly Color CaptionDown = Color.FromArgb(0xFF, 0xE8, 0xA6);
-        internal static readonly Color WindowEdge = Color.FromArgb(0x8E, 0x9B, 0xBC);
-
-        const int DWMWA_WINDOW_CORNER_PREFERENCE = 33;
-        const int DWMWA_BORDER_COLOR = 34;
-        const int DWMWA_CAPTION_COLOR = 35;
-        const int DWMWA_TEXT_COLOR = 36;
-        const int DWMWCP_DONOTROUND = 1;      // у полосок панелей скругления нет
-
-        [DllImport("dwmapi.dll")]
-        internal static extern int DwmSetWindowAttribute(IntPtr window, int attribute,
-                                                         ref int value, int size);
-
-        public static void ApplyCaption(Form form)
-        {
-            new PanelChrome(form);
-        }
-
-        internal static int ColorRef(Color color)
-        {
-            // DWM ждёт 0x00BBGGRR (COLORREF), а Color.ToArgb — 0xAARRGGBB
-            return color.R | (color.G << 8) | (color.B << 16);
-        }
-
-        internal static void SetDwm(Form form)
-        {
-            IntPtr handle = form.Handle;
-            int caption = ColorRef(CaptionBack);    // видно только в миниатюрах Alt-Tab
-            int text = ColorRef(Card);
-            int border = ColorRef(CaptionBack);
-            int corner = DWMWCP_DONOTROUND;
-            try
-            {
-                DwmSetWindowAttribute(handle, DWMWA_CAPTION_COLOR, ref caption, sizeof(int));
-                DwmSetWindowAttribute(handle, DWMWA_TEXT_COLOR, ref text, sizeof(int));
-                DwmSetWindowAttribute(handle, DWMWA_BORDER_COLOR, ref border, sizeof(int));
-                DwmSetWindowAttribute(handle, DWMWA_WINDOW_CORNER_PREFERENCE,
-                                      ref corner, sizeof(int));
-            }
-            catch (DllNotFoundException) { }        // dwmapi.dll есть везде, где есть Aero
-            catch (EntryPointNotFoundException) { } // на всякий случай
-        }
-
-
         static void Walk(Control parent)
         {
             foreach (Control control in parent.Controls)
@@ -262,264 +206,4 @@ static void SetRightMargin(Control edit)
                         (IntPtr)EC_RIGHTMARGIN, (IntPtr)(NumberPadding << 16));
         }
     }
-
-    // Полоска заголовка в стиле док-панелей BecqMoni плюс подкласс окна, который
-    // прячет системный caption. NativeWindow используется, чтобы не заставлять формы
-    // наследоваться от специального базового класса.
-    sealed class PanelChrome : NativeWindow
-    {
-        // Метрики VS2012DockPaneCaption (их переиспользует тема VS2015): высота =
-        // кнопка 18px + зазоры 3 сверху и снизу (текстовая ветка формулы даёт меньше
-        // и не побеждает); кнопки — квадраты с зазором 1 между собой и 4 от правого
-        // края. Высота 24px сверена пиксельно с живой панелью «Обнаружение пиков».
-        const int StripHeight = 24;               // высота полоски док-панели, лог. px
-        const int ButtonSize = 18;
-        const int ButtonGapTop = 3;
-        const int ButtonGapBetween = 1;
-        const int ButtonGapRight = 4;
-        const int Grip = 6;                       // зона ресайза по краям, лог. px
-
-        const int WM_NCCALCSIZE = 0x0083;
-        const int WM_NCHITTEST = 0x0084;
-        const int WM_NCLBUTTONDOWN = 0x00A1;
-        const int HTCLIENT = 1, HTCAPTION = 2;
-        const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14;
-        const int HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17;
-        const int SM_CXSIZEFRAME = 32, SM_CXPADDEDBORDER = 92;
-
-        [DllImport("user32.dll")]
-        static extern bool ReleaseCapture();
-        [DllImport("user32.dll")]
-        static extern IntPtr SendMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam);
-        [DllImport("user32.dll")]
-        static extern int GetSystemMetrics(int index);
-
-        [StructLayout(LayoutKind.Sequential)]
-        struct RECT { public int Left, Top, Right, Bottom; }
-
-        readonly Form form;
-        readonly Panel strip;
-        readonly CheckBox pin;
-        readonly Button buttonMax;
-        readonly ToolTip tips = new ToolTip();
-
-        public PanelChrome(Form owner)
-        {
-            this.form = owner;
-            this.strip = new Panel();
-            this.strip.Dock = DockStyle.Top;
-            this.strip.Height = this.Scaled(StripHeight);
-            this.strip.BackColor = WizardTheme.CaptionBack;
-            this.strip.Paint += this.OnStripPaint;
-            this.strip.MouseDown += this.OnStripMouseDown;
-            this.strip.MouseDoubleClick += this.OnStripDoubleClick;
-
-            Button close = this.MakeButton("");             // ChromeClose
-            close.Click += delegate { this.form.Close(); };
-            this.buttonMax = this.MakeButton("");           // ChromeMaximize
-            this.buttonMax.Visible = this.form.MaximizeBox;
-            this.buttonMax.Click += delegate { this.ToggleMaximize(); };
-            Button minimize = this.MakeButton("");          // ChromeMinimize
-            minimize.Visible = this.form.MinimizeBox;
-            minimize.Click += delegate { this.form.WindowState = FormWindowState.Minimized; };
-
-            this.pin = new CheckBox();
-            this.pin.Appearance = Appearance.Button;
-            this.StyleFlat(this.pin);
-            this.pin.Text = "";                             // Pin
-            this.pin.FlatAppearance.CheckedBackColor = WizardTheme.CaptionDown;
-            this.pin.CheckedChanged += delegate
-            {
-                this.form.TopMost = this.pin.Checked;
-                this.pin.Text = this.pin.Checked ? "" : "";
-            };
-            this.tips.SetToolTip(this.pin, RoiWizardStrings.pinTip);
-
-            // порядок добавления — это порядок укладки от правого края
-            this.strip.Controls.Add(close);
-            this.strip.Controls.Add(this.buttonMax);
-            this.strip.Controls.Add(minimize);
-            this.strip.Controls.Add(this.pin);
-            this.strip.Resize += delegate { this.LayoutButtons(); };
-            this.LayoutButtons();
-
-            // окантовка окна — ToolWindowBorder темы: кольцо в 1px из фона формы
-            // плюс кромка DWM того же цвета
-            this.form.BackColor = WizardTheme.WindowEdge;
-            this.form.Padding = new Padding(1);
-
-            // полоска добавляется последней: док-раскладка обходит контролы с конца,
-            // поэтому она займёт верх раньше, чем Fill заберёт остаток
-            this.form.Controls.Add(this.strip);
-            this.form.TextChanged += delegate { this.strip.Invalidate(); };
-            this.form.Resize += delegate { this.SyncMaxGlyph(); };
-
-            if (this.form.IsHandleCreated)
-            {
-                this.Attach();
-            }
-            this.form.HandleCreated += delegate { this.Attach(); };
-            this.form.HandleDestroyed += delegate { this.ReleaseHandle(); };
-        }
-
-        void Attach()
-        {
-            this.AssignHandle(this.form.Handle);
-            WizardTheme.SetDwm(this.form);
-            // рамки больше нет — окно чуть ужимается, чтобы содержимое осталось
-            // тех же пропорций, что и с системным заголовком
-            this.form.PerformLayout();
-        }
-
-        int Scaled(int logical)
-        {
-            return (int)Math.Round(logical * this.form.DeviceDpi / 96.0);
-        }
-
-        Button MakeButton(string glyph)
-        {
-            Button button = new Button();
-            this.StyleFlat(button);
-            button.Text = glyph;
-            return button;
-        }
-
-        void StyleFlat(ButtonBase button)
-        {
-            button.FlatStyle = FlatStyle.Flat;
-            button.FlatAppearance.BorderSize = 0;
-            button.FlatAppearance.BorderColor = WizardTheme.CaptionHoverEdge;
-            button.FlatAppearance.MouseOverBackColor = WizardTheme.CaptionHover;
-            button.FlatAppearance.MouseDownBackColor = WizardTheme.CaptionDown;
-            button.BackColor = WizardTheme.CaptionBack;
-            button.ForeColor = WizardTheme.CaptionGlyph;
-            button.Font = new Font("Segoe MDL2 Assets", 7F);
-            button.Size = new Size(this.Scaled(ButtonSize), this.Scaled(ButtonSize));
-            button.TabStop = false;
-            button.TextAlign = ContentAlignment.MiddleCenter;
-            // кремовая заливка с золотой рамкой и чёрным глифом — только под курсором,
-            // как у кнопок панелей темы
-            button.MouseEnter += delegate(object sender, EventArgs e)
-            {
-                ButtonBase self = (ButtonBase)sender;
-                self.FlatAppearance.BorderSize = 1;
-                self.ForeColor = WizardTheme.Ink;
-            };
-            button.MouseLeave += delegate(object sender, EventArgs e)
-            {
-                ButtonBase self = (ButtonBase)sender;
-                self.FlatAppearance.BorderSize = 0;
-                self.ForeColor = WizardTheme.CaptionGlyph;
-            };
-        }
-
-        void LayoutButtons()
-        {
-            int x = this.strip.Width - this.Scaled(ButtonGapRight);
-            int y = this.Scaled(ButtonGapTop);
-            foreach (Control button in this.strip.Controls)
-            {
-                if (!(button is ButtonBase))
-                {
-                    continue;
-                }
-                x -= button.Width;
-                button.Location = new Point(x, y);
-                x -= this.Scaled(ButtonGapBetween);
-            }
-        }
-
-        void OnStripPaint(object sender, PaintEventArgs e)
-        {
-            TextRenderer.DrawText(e.Graphics, this.form.Text, WizardTheme.BaseFont,
-                new Rectangle(this.Scaled(6), 0,
-                              this.strip.Width - this.Scaled(6), this.strip.Height),
-                WizardTheme.Card,
-                TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis |
-                TextFormatFlags.NoPrefix);
-        }
-
-        void OnStripMouseDown(object sender, MouseEventArgs e)
-        {
-            if (e.Button != MouseButtons.Left)
-            {
-                return;
-            }
-            int code = HTCAPTION;
-            if (this.form.WindowState == FormWindowState.Normal && e.Y <= this.Scaled(Grip))
-            {
-                int grip = this.Scaled(Grip);
-                code = e.X < grip ? HTTOPLEFT
-                     : e.X > this.strip.Width - grip ? HTTOPRIGHT : HTTOP;
-            }
-            ReleaseCapture();
-            SendMessage(this.form.Handle, WM_NCLBUTTONDOWN, (IntPtr)code, IntPtr.Zero);
-        }
-
-        void OnStripDoubleClick(object sender, MouseEventArgs e)
-        {
-            if (this.form.MaximizeBox)
-            {
-                this.ToggleMaximize();
-            }
-        }
-
-        void ToggleMaximize()
-        {
-            this.form.WindowState = this.form.WindowState == FormWindowState.Maximized
-                ? FormWindowState.Normal : FormWindowState.Maximized;
-        }
-
-        void SyncMaxGlyph()
-        {
-            this.buttonMax.Text = this.form.WindowState == FormWindowState.Maximized
-                ? "" : "";            // ChromeRestore : ChromeMaximize
-        }
-
-        protected override void WndProc(ref Message m)
-        {
-            switch (m.Msg)
-            {
-                case WM_NCCALCSIZE:
-                    if (m.WParam != IntPtr.Zero)
-                    {
-                        if (this.form.WindowState == FormWindowState.Maximized)
-                        {
-                            // развёрнутое окно без этой поправки вылезает рамкой
-                            // за края монитора
-                            RECT rect = (RECT)Marshal.PtrToStructure(m.LParam, typeof(RECT));
-                            int pad = GetSystemMetrics(SM_CXSIZEFRAME)
-                                    + GetSystemMetrics(SM_CXPADDEDBORDER);
-                            rect.Left += pad; rect.Top += pad;
-                            rect.Right -= pad; rect.Bottom -= pad;
-                            Marshal.StructureToPtr(rect, m.LParam, false);
-                        }
-                        m.Result = IntPtr.Zero;   // клиентская область = всё окно
-                        return;
-                    }
-                    break;
-
-                case WM_NCHITTEST:
-                    base.WndProc(ref m);
-                    if ((int)m.Result == HTCLIENT
-                        && this.form.WindowState == FormWindowState.Normal)
-                    {
-                        Point at = this.form.PointToClient(new Point(m.LParam.ToInt32()));
-                        int grip = this.Scaled(Grip);
-                        bool left = at.X < grip;
-                        bool right = at.X > this.form.ClientSize.Width - grip;
-                        bool top = at.Y < grip;
-                        bool bottom = at.Y > this.form.ClientSize.Height - grip;
-                        int hit = HTCLIENT;
-                        if (top) { hit = left ? HTTOPLEFT : right ? HTTOPRIGHT : HTTOP; }
-                        else if (bottom) { hit = left ? HTBOTTOMLEFT : right ? HTBOTTOMRIGHT : HTBOTTOM; }
-                        else if (left) { hit = HTLEFT; }
-                        else if (right) { hit = HTRIGHT; }
-                        m.Result = (IntPtr)hit;
-                    }
-                    return;
-            }
-            base.WndProc(ref m);
-        }
-    }
 }

From b271383e7cf06bcf64795f1c38a0854c70772a52 Mon Sep 17 00:00:00 2001
From: Verter73 
Date: Sun, 26 Jul 2026 16:46:18 +0300
Subject: [PATCH 5/7] New nuclide set shows up without restarting the
 application

After writing a set, the "Nuclide set" combo of the peak detection window kept
its old contents until BecqMoni was restarted.

ROI configs have a notification - ROIConfigManager raises ROIConfigListChanged
and the combos refresh themselves. Nuclide sets have no event at all, and
DCPeakDetectionView.RefreshNuclideSets() is only called from the constructor,
so no code path could refresh the list after a write. This affected
NuclideSetForm's own saves too, not just the wizard.

NuclideDefinitionManager now raises NuclideDefinitionListChanged at the end of
a successful SaveDefinitionFile, and DCPeakDetectionView subscribes to it. The
current selection survives - RefreshNuclideSets restores it via
IndexOf(selectedNuclideSet). Additions only.

Verified by reading both combo boxes with CB_GETLBTEXT before and after an
export: the set list gains "IAEA set" immediately, the ROI list "IAEA lines".

Co-Authored-By: Claude Fable 5 
---
 BecquerelMonitor/DCPeakDetectionView.cs      | 6 ++++++
 BecquerelMonitor/NuclideDefinitionManager.cs | 8 ++++++++
 2 files changed, 14 insertions(+)

diff --git a/BecquerelMonitor/DCPeakDetectionView.cs b/BecquerelMonitor/DCPeakDetectionView.cs
index 5e4cd7f..fa00d21 100644
--- a/BecquerelMonitor/DCPeakDetectionView.cs
+++ b/BecquerelMonitor/DCPeakDetectionView.cs
@@ -20,6 +20,12 @@ public DCPeakDetectionView(MainForm mainForm)
 
             this.RefreshNuclideSets();
             this.UpdateDeconvolutionInfoButtonState();
+            // список наборов пересобирается при каждой записи файла определений,
+            // иначе новый набор виден только после перезапуска
+            this.nuclideManager.NuclideDefinitionListChanged += delegate(object sender, EventArgs e)
+            {
+                this.RefreshNuclideSets();
+            };
         }
 
         // Token: 0x0600043E RID: 1086 RVA: 0x0001423C File Offset: 0x0001243C
diff --git a/BecquerelMonitor/NuclideDefinitionManager.cs b/BecquerelMonitor/NuclideDefinitionManager.cs
index 5ccd92f..e7c8863 100644
--- a/BecquerelMonitor/NuclideDefinitionManager.cs
+++ b/BecquerelMonitor/NuclideDefinitionManager.cs
@@ -53,6 +53,10 @@ public List NuclideSets
         }
 
         // Token: 0x06000932 RID: 2354 RVA: 0x00035750 File Offset: 0x00033950
+        // Поднимается после успешной записи файла определений: списки наборов
+        // в интерфейсе иначе обновляются только при следующем запуске.
+        public event EventHandler NuclideDefinitionListChanged;
+
         public static NuclideDefinitionManager GetInstance()
         {
             if (!NuclideDefinitionManager.instance.LoadDefinitionFile())
@@ -115,6 +119,10 @@ public bool SaveDefinitionFile()
                 MessageBox.Show(Resources.ERRSavingNuclideDefinitionFile, Resources.ErrorDialogTitle, MessageBoxButtons.OK, MessageBoxIcon.Hand);
                 return false;
             }
+            if (this.NuclideDefinitionListChanged != null)
+            {
+                this.NuclideDefinitionListChanged(this, new EventArgs());
+            }
             return true;
         }
 

From 102e77844300e5e5d9f5a50c18c90c6beda8efe3 Mon Sep 17 00:00:00 2001
From: Verter73 
Date: Sun, 26 Jul 2026 17:16:08 +0300
Subject: [PATCH 6/7] Sorting: numeric columns compare numbers, sorted column
 keeps the row tint

Two defects found while testing the table.

Numeric columns sorted as text: in "E, keV" 39.86 sat between 340.96 and 409.46.
The columns are TextColumn, whose default comparer reads Cell.Text. The numbers
were in Cell.Data all along, which is what NumberComparer reads - ten numeric
columns now declare it. Nearby hits also gained a numeric half-life; that cell
used to carry only the caption.

Clicking a header wiped the row highlight in that column: XPTable fills the
sorted column with Table.SortedColumnBackColor (WhiteSmoke by default) on top of
the row background. The module's tables now set it to Color.Transparent - the
fill is only drawn when A != 0, and the sort direction is still shown by the
header arrow.

Verified in the live application; core tests 100/100.

Co-Authored-By: Claude Fable 5 
---
 .../RoiWizard/RoiWizardForm.Designer.cs           | 10 ++++++++++
 BecquerelMonitor/RoiWizard/RoiWizardForm.cs       | 15 ++++++++++-----
 BecquerelMonitor/RoiWizard/WizardTheme.cs         |  7 +++++++
 3 files changed, 27 insertions(+), 5 deletions(-)

diff --git a/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs b/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs
index 1a2240e..bc3046b 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs
+++ b/BecquerelMonitor/RoiWizard/RoiWizardForm.Designer.cs
@@ -34,7 +34,9 @@ void InitializeComponent()
             this.columnCatalogName = new XPTable.Models.TextColumn();
             this.columnCatalogFamilies = new XPTable.Models.TextColumn();
             this.columnCatalogHalfLife = new XPTable.Models.TextColumn();
+            this.columnCatalogHalfLife.Comparer = typeof(XPTable.Sorting.NumberComparer);
             this.columnCatalogLines = new XPTable.Models.TextColumn();
+            this.columnCatalogLines.Comparer = typeof(XPTable.Sorting.NumberComparer);
             this.tableModelCatalog = new XPTable.Models.TableModel();
 
             this.groupGroup = new System.Windows.Forms.GroupBox();
@@ -116,11 +118,15 @@ void InitializeComponent()
             this.tableNear = new XPTable.Models.Table();
             this.columnModelNear = new XPTable.Models.ColumnModel();
             this.columnNearDelta = new XPTable.Models.TextColumn();
+            this.columnNearDelta.Comparer = typeof(XPTable.Sorting.NumberComparer);
             this.columnNearName = new XPTable.Models.TextColumn();
             this.columnNearEnergy = new XPTable.Models.TextColumn();
+            this.columnNearEnergy.Comparer = typeof(XPTable.Sorting.NumberComparer);
             this.columnNearIntensity = new XPTable.Models.TextColumn();
+            this.columnNearIntensity.Comparer = typeof(XPTable.Sorting.NumberComparer);
             this.columnNearType = new XPTable.Models.TextColumn();
             this.columnNearHalfLife = new XPTable.Models.TextColumn();
+            this.columnNearHalfLife.Comparer = typeof(XPTable.Sorting.NumberComparer);
             this.columnNearAdd = new XPTable.Models.ButtonColumn();
             this.columnNearFill = new XPTable.Models.TextColumn();
             this.tableModelNear = new XPTable.Models.TableModel();
@@ -135,9 +141,13 @@ void InitializeComponent()
             this.columnLineSelected = new XPTable.Models.CheckBoxColumn();
             this.columnLineName = new XPTable.Models.TextColumn();
             this.columnLineEnergy = new XPTable.Models.TextColumn();
+            this.columnLineEnergy.Comparer = typeof(XPTable.Sorting.NumberComparer);
             this.columnLineIntensity = new XPTable.Models.TextColumn();
+            this.columnLineIntensity.Comparer = typeof(XPTable.Sorting.NumberComparer);
             this.columnLineRelative = new XPTable.Models.TextColumn();
+            this.columnLineRelative.Comparer = typeof(XPTable.Sorting.NumberComparer);
             this.columnLineHalfLife = new XPTable.Models.TextColumn();
+            this.columnLineHalfLife.Comparer = typeof(XPTable.Sorting.NumberComparer);
             this.columnLineType = new XPTable.Models.TextColumn();
             this.tableModelLines = new XPTable.Models.TableModel();
 
diff --git a/BecquerelMonitor/RoiWizard/RoiWizardForm.cs b/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
index 438c5e9..81f02bd 100644
--- a/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
+++ b/BecquerelMonitor/RoiWizard/RoiWizardForm.cs
@@ -1287,7 +1287,8 @@ void SearchNearby()
                     if (Math.Abs(gamma.Energy - energy) <= window && gamma.Intensity >= minIntensity)
                     {
                         this.nearHits.Add(new NearHit(nuclide.Name, gamma.Energy, gamma.Intensity,
-                                                      "γ", "g", nuclide.HalfLifeText, null));
+                                                      "γ", "g", nuclide.HalfLifeText,
+                                                      nuclide.HalfLifeYears, null));
                     }
                 }
                 foreach (CatalogXrayLine xray in nuclide.Xray)
@@ -1296,7 +1297,8 @@ void SearchNearby()
                     {
                         this.nearHits.Add(new NearHit(nuclide.Name, xray.Energy, xray.Intensity,
                                                       "X " + xray.Shell, "x",
-                                                      nuclide.HalfLifeText, null));
+                                                      nuclide.HalfLifeText,
+                                                      nuclide.HalfLifeYears, null));
                     }
                 }
             }
@@ -1310,7 +1312,7 @@ void SearchNearby()
                             RoiWizardStrings.lineTypeXrf + " " + element.Symbol,
                             line.Energy, line.Intensity,
                             RoiWizardStrings.lineTypeXrf + " " + line.Label, "xrf",
-                            "—", element.Symbol));
+                            "—", 0.0, element.Symbol));
                     }
                 }
             }
@@ -1342,7 +1344,7 @@ void SearchNearby()
                 Cell kind = new Cell(hit.TypeName);
                 kind.Tag = hit.TypeKind;           // цвет бейджа — по коду, не по подписи
                 row.Cells.Add(kind);
-                row.Cells.Add(new Cell(HalfLifeLabel(hit.HalfLife)));
+                row.Cells.Add(new Cell(HalfLifeLabel(hit.HalfLife), hit.HalfLifeYears));
                 // тег ячейки — признак «есть что нажимать»: у добавленного нуклида
                 // кнопки нет, как и на странице, вместо неё подпись «в наборе»
                 Cell action = new Cell(added ? RoiWizardStrings.nearAdded
@@ -1405,11 +1407,14 @@ sealed class NearHit
             public readonly string TypeName;     // подпись бейджа: она переводится
             public readonly string TypeKind;     // код типа: по нему берётся цвет
             public readonly string HalfLife;
+            public readonly double HalfLifeYears;   // ключ сортировки столбца T½
             public readonly string XrfSymbol;
 
             public NearHit(string nuclide, double energy, double intensity,
-                           string typeName, string typeKind, string halfLife, string xrfSymbol)
+                           string typeName, string typeKind, string halfLife,
+                           double halfLifeYears, string xrfSymbol)
             {
+                this.HalfLifeYears = halfLifeYears;
                 this.Nuclide = nuclide;
                 this.Energy = energy;
                 this.Intensity = intensity;
diff --git a/BecquerelMonitor/RoiWizard/WizardTheme.cs b/BecquerelMonitor/RoiWizard/WizardTheme.cs
index 79cda0d..d777f12 100644
--- a/BecquerelMonitor/RoiWizard/WizardTheme.cs
+++ b/BecquerelMonitor/RoiWizard/WizardTheme.cs
@@ -130,6 +130,13 @@ static void Walk(Control parent)
                     table.SelectionForeColor = Ink;
                     table.ForeColor = Ink;
                     table.BackColor = Card;
+                    // Отсортированный столбец XPTable по умолчанию заливает своим
+                    // цветом (WhiteSmoke) поверх фона строки, и после щелчка по
+                    // заголовку отмеченные строки теряли подсветку именно в этом
+                    // столбце. Прозрачный цвет отключает заливку (рендерер рисует
+                    // её только при A != 0); направление сортировки и так показано
+                    // стрелкой в заголовке — как на странице-эталоне.
+                    table.SortedColumnBackColor = Color.Transparent;
                     continue;
                 }
 

From 4347e2ffc441c5836597b5420a53b81ebeff0e41 Mon Sep 17 00:00:00 2001
From: Verter73 
Date: Sun, 26 Jul 2026 17:33:07 +0300
Subject: [PATCH 7/7] Revert "New nuclide set shows up without restarting the
 application"

This reverts commit b271383e7cf06bcf64795f1c38a0854c70772a52.
---
 BecquerelMonitor/DCPeakDetectionView.cs      | 6 ------
 BecquerelMonitor/NuclideDefinitionManager.cs | 8 --------
 2 files changed, 14 deletions(-)

diff --git a/BecquerelMonitor/DCPeakDetectionView.cs b/BecquerelMonitor/DCPeakDetectionView.cs
index fa00d21..5e4cd7f 100644
--- a/BecquerelMonitor/DCPeakDetectionView.cs
+++ b/BecquerelMonitor/DCPeakDetectionView.cs
@@ -20,12 +20,6 @@ public DCPeakDetectionView(MainForm mainForm)
 
             this.RefreshNuclideSets();
             this.UpdateDeconvolutionInfoButtonState();
-            // список наборов пересобирается при каждой записи файла определений,
-            // иначе новый набор виден только после перезапуска
-            this.nuclideManager.NuclideDefinitionListChanged += delegate(object sender, EventArgs e)
-            {
-                this.RefreshNuclideSets();
-            };
         }
 
         // Token: 0x0600043E RID: 1086 RVA: 0x0001423C File Offset: 0x0001243C
diff --git a/BecquerelMonitor/NuclideDefinitionManager.cs b/BecquerelMonitor/NuclideDefinitionManager.cs
index e7c8863..5ccd92f 100644
--- a/BecquerelMonitor/NuclideDefinitionManager.cs
+++ b/BecquerelMonitor/NuclideDefinitionManager.cs
@@ -53,10 +53,6 @@ public List NuclideSets
         }
 
         // Token: 0x06000932 RID: 2354 RVA: 0x00035750 File Offset: 0x00033950
-        // Поднимается после успешной записи файла определений: списки наборов
-        // в интерфейсе иначе обновляются только при следующем запуске.
-        public event EventHandler NuclideDefinitionListChanged;
-
         public static NuclideDefinitionManager GetInstance()
         {
             if (!NuclideDefinitionManager.instance.LoadDefinitionFile())
@@ -119,10 +115,6 @@ public bool SaveDefinitionFile()
                 MessageBox.Show(Resources.ERRSavingNuclideDefinitionFile, Resources.ErrorDialogTitle, MessageBoxButtons.OK, MessageBoxIcon.Hand);
                 return false;
             }
-            if (this.NuclideDefinitionListChanged != null)
-            {
-                this.NuclideDefinitionListChanged(this, new EventArgs());
-            }
             return true;
         }