Загрузка данных


<!doctype html>

<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Звёздный глобус — 24 лепестка / Ø1000 мм</title><style>
:root {
    color-scheme: dark;
    --bg:#111318;
    --panel:#1b1e26;
    --line:#343946;
    --text:#eef1f7;
    --muted:#9da5b5;
    --accent:#62a0ff;
}

* {
    box-sizing:border-box;
}

body {
    margin:0;
    background:var(--bg);
    color:var(--text);
    font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
}

header {
    padding:18px 24px;
    border-bottom:1px solid var(--line);
}

h1 {
    margin:0 0 5px;
    font-size:21px;
}

header p {
    margin:0;
    color:var(--muted);
    font-size:13px;
}

main {
    display:grid;
    grid-template-columns:360px 1fr;
    min-height:calc(100vh - 76px);
}

aside {
    padding:18px;
    background:var(--panel);
    border-right:1px solid var(--line);
    overflow:auto;
}

section.preview {
    padding:20px;
    overflow:auto;
}

.box {
    border:1px solid var(--line);
    border-radius:10px;
    padding:14px;
    margin-bottom:14px;
}

label {
    display:block;
    font-size:13px;
    margin-bottom:8px;
    color:var(--muted);
}

input[type=file] {
    width:100%;
}

input[type=number] {
    width:100%;
    padding:8px;
    border:1px solid var(--line);
    background:#101218;
    color:white;
    border-radius:6px;
}

button {
    width:100%;
    padding:9px 10px;
    margin-top:7px;
    border:1px solid #465064;
    border-radius:7px;
    background:#252b37;
    color:white;
    cursor:pointer;
}

button:hover {
    background:#303949;
}

button.primary {
    background:#2468d8;
    border-color:#3c83f0;
}

button:disabled {
    opacity:.45;
    cursor:not-allowed;
}

.stats {
    font-size:13px;
    line-height:1.55;
}

.stats b {
    color:white;
}

#svgHost {
    display:flex;
    justify-content:center;
    align-items:flex-start;
    min-height:500px;
}

#svgHost svg {
    width:min(850px,100%);
    height:auto;
    background:white;
    box-shadow:0 4px 25px #0008;
}

.downloads {
    display:grid;
    grid-template-columns:repeat(4,1fr);
    gap:6px;
    margin-top:15px;
}

.downloads button {
    margin:0;
    font-size:12px;
}

.notice {
    color:var(--muted);
    font-size:12px;
    line-height:1.5;
}

.ok {
    color:#67dc93;
}

.warn {
    color:#ffca62;
}

.error {
    color:#ff7272;
}

@media(max-width:850px) {
    main {
        grid-template-columns:1fr;
    }

    aside {
        border-right:0;
        border-bottom:1px solid var(--line);
    }

    .downloads {
        grid-template-columns:repeat(3,1fr);
    }
}
</style></head><body><header>
    <h1>Звёздный бумажный глобус Ø1000 мм — 24 лепестка</h1>
    <p>
        HYG 4.1 · J2000.0 · звёзды ярче 5-й величины ·
        SVG 1:1 · названия звёзд · зеркальное отражение
    </p>
</header><main><aside><div class="box">

    <label>Файл HYG 4.1 CSV</label>

    <input
        id="csvFile"
        type="file"
        accept=".csv,text/csv"
    >

    <button
        id="generate"
        class="primary"
        disabled
    >
        Построить глобус
    </button>

</div>

<div class="box">

    <label>Диаметр глобуса, мм</label>

    <input
        id="diameter"
        type="number"
        value="1000"
        min="100"
        step="1"
    >

    <label style="margin-top:12px">
        Порог звёздной величины
    </label>

    <input
        id="magLimit"
        type="number"
        value="5"
        step="0.1"
    >

    <label style="margin-top:12px">
        Размер самой яркой звезды, мм
    </label>

    <input
        id="maxStar"
        type="number"
        value="3.2"
        min="0.2"
        step="0.1"
    >

    <label style="margin-top:12px">
        Минимальный размер звезды, мм
    </label>

    <input
        id="minStar"
        type="number"
        value="0.55"
        min="0.1"
        step="0.05"
    >

    <label style="margin-top:12px">
        Размер шрифта названий, мм
    </label>

    <input
        id="labelSize"
        type="number"
        value="1.8"
        min="0.5"
        step="0.1"
    >

    <label style="margin-top:12px">
        Показывать названия звёзд
    </label>

    <input
        id="showLabels"
        type="checkbox"
        checked
    >

</div>

<div class="box stats" id="stats">
    CSV ещё не загружен.
</div>

<div class="box">

    <button id="downloadCurrent" disabled>
        Скачать текущий лепесток SVG
    </button>

    <div
        class="notice"
        style="margin-top:10px"
    >
        SVG имеет физический размер в миллиметрах.
        При печати необходимо выбрать
        «Actual size / 100%», а не «Fit to page».
    </div>

</div>

<div class="box">

    <div class="notice">

        <b>Геометрия:</b><br>

        24 лепестка × 15° по прямому восхождению.<br>

        Центральный меридиан каждого лепестка используется
        только как математическая ось проекции и
        <b>не рисуется в SVG</b>.

        <br>

        Границы искривлены согласно cos(δ).

        <br><br>

        <b>Зеркальное отражение:</b><br>

        Все звёзды в каждом лепестке отражаются
        относительно центрального меридиана.
        То есть локальная координата X каждой звезды
        меняется на противоположную:
        <code>x → -x</code>.

        <br><br>

        <b>Координаты HYG:</b><br>

        RA — часы, Dec — градусы, эпоха/equinox J2000.0.

        <br><br>

        <b>Названия:</b><br>

        Используется колонка <code>proper</code>.
        Если её нет, используется колонка <code>name</code>.

    </div>

</div>

</aside><section class="preview"><div id="svgHost">

    <div class="notice">
        После загрузки CSV здесь появится первый лепесток.
    </div>

</div>

<div
    class="downloads"
    id="downloads"
></div>

</section></main><script>
"use strict";


/* ============================================================
   ЗВЁЗДНЫЙ ГЛОБУС

   24 лепестка.

   Центральный меридиан НЕ РИСУЕТСЯ.

   Геометрическая модель:

       R = D / 2

       y = -R * latitude

       x = R * deltaLongitude * cos(latitude)

   24 лепестка:

       360 / 24 = 15 градусов

   Полоса каждого лепестка:

       -7.5° <= deltaLongitude < +7.5°

   ВАЖНО:

       Перед выводом каждая звезда зеркально отражается
       относительно центрального меридиана своего лепестка.

       Исходная локальная координата:
           x

       После отражения:
           xMirror = -x

   Таким образом:

       центральный меридиан → ось X = 0
       левая половина       → правая
       правая половина      → левая

   Дополнительно:

       • названия звёзд из HYG proper/name
       • мелкий SVG-текст рядом со звездой
       • названия не рисуются, если поле пустое
   ============================================================ */


const N_GORES = 24;

const FULL_DEG = 360;

const GORE_DEG =
    FULL_DEG / N_GORES;

const HALF_GORE_DEG =
    GORE_DEG / 2;


let gores = [];

let currentGore = 0;

let sourceFileName = "";


/* ------------------------------------------------------------
   Утилиты
   ------------------------------------------------------------ */

function esc(s) {

    return String(s ?? "")
        .replace(/&/g,"&amp;")
        .replace(/</g,"&lt;")
        .replace(/>/g,"&gt;")
        .replace(/"/g,"&quot;")
        .replace(/'/g,"&apos;");
}


function num(v) {

    if (
        v === null ||
        v === undefined
    ) {
        return NaN;
    }

    const s =
        String(v)
            .trim()
            .replace(",", ".");

    if (!s) {
        return NaN;
    }

    return Number(s);
}


function degToRad(x) {

    return x * Math.PI / 180;
}


function radToDeg(x) {

    return x * 180 / Math.PI;
}


/*
    Приведение долготы к [-180, +180).
*/

function wrap180(deg) {

    return (
        ((deg + 180) % 360 + 360) % 360
        - 180
    );
}


/*
    RA из HYG:

        0...24 часов

    преобразуется в:

        0...360 градусов
*/

function raHoursToDeg(raHours) {

    return raHours * 15;
}


/*
    Определяем индекс лепестка.

    Центральные оси:

        0°, 15°, 30° ...

    Границы:

        ±7.5°
*/

function goreIndex(raDeg) {

    raDeg =
        ((raDeg % 360) + 360) % 360;

    let i =
        Math.floor(
            (raDeg + HALF_GORE_DEG)
            / GORE_DEG
        );

    i =
        ((i % N_GORES) + N_GORES)
        % N_GORES;

    return i;
}


/*
    Центральная математическая ось лепестка.

    Используется только для расчёта координат.
*/

function goreCenterLon(index) {

    return index * GORE_DEG;
}


/*
    Координаты звезды на плоскости лепестка.
*/

function projectStar(
    raDeg,
    decDeg,
    goreIndexValue,
    R
) {

    const center =
        goreCenterLon(goreIndexValue);

    let delta =
        wrap180(
            raDeg - center
        );

    const lambda =
        degToRad(delta);

    const phi =
        degToRad(decDeg);

    const x =
        R *
        lambda *
        Math.cos(phi);

    const y =
        -R * phi;

    return {
        x,
        y
    };
}


/* ------------------------------------------------------------
   CSV parser
   ------------------------------------------------------------ */

function parseCSV(text) {

    const rows = [];

    let row = [];

    let cell = "";

    let quoted = false;


    for (
        let i = 0;
        i < text.length;
        i++
    ) {

        const c =
            text[i];


        if (quoted) {

            if (c === '"') {

                if (
                    text[i + 1] === '"'
                ) {

                    cell += '"';

                    i++;

                } else {

                    quoted = false;
                }

            } else {

                cell += c;
            }

        } else {

            if (c === '"') {

                quoted = true;

            } else if (c === ',') {

                row.push(cell);

                cell = "";

            } else if (c === '\n') {

                row.push(cell);

                rows.push(row);

                row = [];

                cell = "";

            } else if (c === '\r') {

                /* CRLF обработается через LF. */

            } else {

                cell += c;
            }
        }
    }


    if (
        cell.length ||
        row.length
    ) {

        row.push(cell);

        rows.push(row);
    }


    return rows;
}


function normaliseHeader(s) {

    return String(s ?? "")
        .trim()
        .replace(/^\uFEFF/, "")
        .toLowerCase();
}


function findColumn(
    headers,
    candidates
) {

    for (
        const candidate
        of candidates
    ) {

        const i =
            headers.indexOf(candidate);

        if (i >= 0) {

            return i;
        }
    }

    return -1;
}


/*
    HYG v4.1:

        ra
        dec
        mag
        proper
        name

    Для названия используется:

        proper

    Если proper отсутствует или пустое:

        name
*/

function csvToStars(text) {

    const rows =
        parseCSV(text);


    if (!rows.length) {

        throw new Error(
            "CSV-файл пуст."
        );
    }


    const headers =
        rows[0]
            .map(normaliseHeader);


    const raCol =
        findColumn(
            headers,
            [
                "ra",
                "right ascension",
                "rightascension"
            ]
        );


    const decCol =
        findColumn(
            headers,
            [
                "dec",
                "declination"
            ]
        );


    const magCol =
        findColumn(
            headers,
            [
                "mag",
                "magnitude",
                "vmag"
            ]
        );


    const properCol =
        findColumn(
            headers,
            [
                "proper",
                "proper name",
                "propername"
            ]
        );


    const nameCol =
        findColumn(
            headers,
            [
                "name",
                "star name",
                "starname"
            ]
        );


    if (
        raCol < 0 ||
        decCol < 0 ||
        magCol < 0
    ) {

        throw new Error(
            "Не найдены необходимые колонки ra, dec, mag. " +
            "Это не похоже на HYG CSV."
        );
    }


    const stars = [];


    for (
        let r = 1;
        r < rows.length;
        r++
    ) {

        const row =
            rows[r];


        if (!row.length) {
            continue;
        }


        const ra =
            num(row[raCol]);


        const dec =
            num(row[decCol]);


        const mag =
            num(row[magCol]);


        if (
            !Number.isFinite(ra) ||
            !Number.isFinite(dec) ||
            !Number.isFinite(mag)
        ) {

            continue;
        }


        if (
            ra < 0 ||
            ra >= 24
        ) {

            continue;
        }


        if (
            dec < -90 ||
            dec > 90
        ) {

            continue;
        }


        let starName = "";


        if (
            properCol >= 0
        ) {

            starName =
                String(
                    row[properCol] ?? ""
                ).trim();
        }


        /*
            Если собственного названия нет,
            используем name.
        */

        if (
            !starName &&
            nameCol >= 0
        ) {

            starName =
                String(
                    row[nameCol] ?? ""
                ).trim();
        }


        stars.push({

            raHours: ra,

            raDeg:
                raHoursToDeg(ra),

            dec,

            mag,

            name: starName
        });
    }


    return stars;
}


/* ------------------------------------------------------------
   Размер звезды
   ------------------------------------------------------------ */

function starDiameter(
    mag,
    minD,
    maxD
) {

    /*
        Сохраняем исходную шкалу,
        рассчитанную на диапазон примерно
        от 0 до +5.
    */

    const t =
        Math.max(
            0,
            Math.min(
                1,
                mag / 5
            )
        );


    const d =
        maxD -
        (maxD - minD) *
        Math.pow(
            t,
            0.65
        );


    return Math.max(
        minD,
        Math.min(
            maxD,
            d
        )
    );
}


/* ------------------------------------------------------------
   SVG геометрия
   ------------------------------------------------------------ */

function makeGoreSVG(
    index,
    stars,
    options
) {

    const D =
        options.diameter;


    const R =
        D / 2;


    /*
        Максимальная ширина на экваторе:

            2 R * π/24
    */

    const halfWidthEquator =
        R *
        degToRad(
            HALF_GORE_DEG
        );


    const width =
        2 *
        halfWidthEquator;


    const height =
        Math.PI * R;


    /*
        Координатная система SVG:

            x = 0 ... width
            y = 0 ... height
    */

    const cx =
        width / 2;


    const cy =
        height / 2;


    /*
        Кривые границы лепестка.
    */

    const pointsLeft = [];

    const pointsRight = [];


    const samples = 180;


    for (
        let i = 0;
        i <= samples;
        i++
    ) {

        const phi =
            -Math.PI / 2 +
            Math.PI *
            i /
            samples;


        const y =
            cy -
            R * phi;


        const x =
            R *
            degToRad(
                HALF_GORE_DEG
            ) *
            Math.cos(phi);


        pointsLeft.push([
            cx - x,
            y
        ]);


        pointsRight.push([
            cx + x,
            y
        ]);
    }


    /*
        Единый непрерывный внешний контур.

        Центрального меридиана нет.
    */

    const firstLeft =
        pointsLeft[0];


    let boundary =
        "M " +
        firstLeft[0].toFixed(4) +
        " " +
        firstLeft[1].toFixed(4);


    /*
        Левая граница.
    */

    for (
        let i = 1;
        i < pointsLeft.length;
        i++
    ) {

        const p =
            pointsLeft[i];


        boundary +=
            " L " +
            p[0].toFixed(4) +
            " " +
            p[1].toFixed(4);
    }


    /*
        Правая граница.

        Идём сверху вниз.
    */

    for (
        let i = pointsRight.length - 1;
        i >= 0;
        i--
    ) {

        const p =
            pointsRight[i];


        boundary +=
            " L " +
            p[0].toFixed(4) +
            " " +
            p[1].toFixed(4);
    }


    boundary += " Z";


    /*
        SVG.

        1 SVG unit = 1 mm.
    */

    let svg = "";


    svg +=
        `<?xml version="1.0" encoding="UTF-8"?>\n`;


    svg +=
        `<svg xmlns="http://www.w3.org/2000/svg" ` +
        `width="${width.toFixed(4)}mm" ` +
        `height="${height.toFixed(4)}mm" ` +
        `viewBox="0 0 ${width.toFixed(4)} ${height.toFixed(4)}">\n`;


    svg +=
        `<title>Star globe gore ${index + 1}/24 — mirrored</title>\n`;


    /*
        Белая бумага.
    */

    svg +=
        `<path d="${boundary}" ` +
        `fill="white" stroke="none"/>\n`;


    /*
        Контур резки.
    */

    svg +=
        `<path d="${boundary}" ` +
        `fill="none" ` +
        `stroke="#000" ` +
        `stroke-width="0.15"/>\n`;


    /*
        Звёзды и их названия.

        ВАЖНО:

        projectStar() сначала вычисляет
        обычную локальную координату:

            p.x

        Затем выполняется зеркальное отражение
        относительно центрального меридиана:

            mirroredX = -p.x

        Поскольку центральный меридиан
        соответствует x = 0, это точное
        геометрическое отражение.
    */

    for (
        const star
        of stars
    ) {

        const p =
            projectStar(
                star.raDeg,
                star.dec,
                index,
                R
            );


        /*
            ЗЕРКАЛЬНОЕ ОТРАЖЕНИЕ

            Было:

                p.x

            Стало:

                -p.x
        */

        const mirroredX =
            -p.x;


        const x =
            cx + mirroredX;


        const y =
            cy + p.y;


        const d =
            starDiameter(
                star.mag,
                options.minStar,
                options.maxStar
            );


        const r =
            d / 2;


        /*
            Звезда.
        */

        svg +=
            `<circle ` +
            `cx="${x.toFixed(4)}" ` +
            `cy="${y.toFixed(4)}" ` +
            `r="${r.toFixed(4)}" ` +
            `fill="black">`;


        svg +=
            `<title>` +
            `RA ${star.raHours.toFixed(6)}h, ` +
            `Dec ${star.dec.toFixed(6)}°, ` +
            `mag ${star.mag.toFixed(3)}` +
            (
                star.name
                    ? `, ${esc(star.name)}`
                    : ""
            ) +
            `</title>`;


        svg +=
            `</circle>\n`;


        /*
            Название звезды.

            После зеркального отражения
            подпись также должна находиться
            около зеркального положения звезды.

            Используем тот же локальный offset
            вправо относительно положения звезды.
        */

        if (
            options.showLabels &&
            star.name
        ) {

            /*
                Положение подписи зеркально следует
                за звездой.

                Отступ сохраняется относительно
                нового положения.
            */

            const labelX =
                x + r + 0.8;


            const labelY =
                y - 0.8;


            svg +=
                `<text ` +
                `x="${labelX.toFixed(4)}" ` +
                `y="${labelY.toFixed(4)}" ` +
                `font-family="Arial, Helvetica, sans-serif" ` +
                `font-size="${options.labelSize.toFixed(4)}mm" ` +
                `font-weight="normal" ` +
                `fill="black" ` +
                `text-anchor="start" ` +
                `dominant-baseline="auto">` +
                `${esc(star.name)}` +
                `</text>\n`;
        }
    }


    svg +=
        `</svg>`;


    return svg;
}


/* ------------------------------------------------------------
   Проверка геометрии
   ------------------------------------------------------------ */

function validateStars(stars) {

    let bad = 0;

    let boundary = 0;

    let assigned = 0;


    const seen =
        new Map();


    for (
        const s
        of stars
    ) {

        const g =
            goreIndex(
                s.raDeg
            );


        if (
            g < 0 ||
            g >= N_GORES
        ) {

            bad++;

            continue;
        }


        assigned++;


        /*
            Проверяем обратное преобразование.
        */

        const center =
            goreCenterLon(g);


        const p =
            projectStar(
                s.raDeg,
                s.dec,
                g,
                1
            );


        const phi =
            -p.y;


        const cosPhi =
            Math.cos(phi);


        let recoveredDelta;


        if (
            Math.abs(cosPhi) < 1e-12
        ) {

            recoveredDelta = 0;

        } else {

            recoveredDelta =
                radToDeg(
                    p.x / cosPhi
                );
        }


        const recoveredRA =
            (
                (
                    center +
                    recoveredDelta
                ) %
                360 +
                360
            ) % 360;


        const errLon =
            Math.abs(
                wrap180(
                    recoveredRA -
                    s.raDeg
                )
            );


        const errDec =
            Math.abs(
                radToDeg(-p.y) -
                s.dec
            );


        if (
            errLon > 1e-9 ||
            errDec > 1e-9
        ) {

            bad++;
        }


        /*
            Считаем звёзды у границ.
        */

        const delta =
            Math.abs(
                wrap180(
                    s.raDeg -
                    center
                )
            );


        if (
            Math.abs(
                delta -
                HALF_GORE_DEG
            ) < 1e-8
        ) {

            boundary++;
        }


        /*
            Каждая запись должна назначаться
            ровно одному лепестку.
        */

        const key =
            `${s.raHours}|${s.dec}|${s.mag}`;


        if (!seen.has(key)) {

            seen.set(
                key,
                []
            );
        }


        seen
            .get(key)
            .push(g);
    }


    let duplicates = 0;


    for (
        const [, arr]
        of seen
    ) {

        if (
            arr.length !== 1
        ) {

            duplicates++;
        }
    }


    return {

        bad,

        boundary,

        assigned,

        duplicates
    };
}


/* ------------------------------------------------------------
   Построение
   ------------------------------------------------------------ */

function buildGores(
    stars,
    options
) {

    const selected =
        stars.filter(
            s =>
                s.mag <
                options.magLimit
        );


    const result =
        Array.from(
            {
                length:
                    N_GORES
            },
            () => []
        );


    for (
        const star
        of selected
    ) {

        const g =
            goreIndex(
                star.raDeg
            );


        result[g].push(
            star
        );
    }


    return result;
}


/* ------------------------------------------------------------
   Отображение
   ------------------------------------------------------------ */

function showGore(index) {

    if (!gores.length) {

        return;
    }


    currentGore =
        index;


    const options =
        getOptions();


    const svgText =
        makeGoreSVG(
            index,
            gores[index],
            options
        );


    const host =
        document.getElementById(
            "svgHost"
        );


    host.innerHTML =
        svgText;


    document
        .getElementById(
            "downloadCurrent"
        )
        .disabled = false;


    document
        .querySelectorAll(
            ".downloads button"
        )
        .forEach(
            (button, i) => {

                button.classList.toggle(
                    "primary",
                    i === index
                );
            }
        );
}


/* ------------------------------------------------------------
   Параметры
   ------------------------------------------------------------ */

function getOptions() {

    return {

        diameter:
            num(
                document
                    .getElementById(
                        "diameter"
                    )
                    .value
            ),


        magLimit:
            num(
                document
                    .getElementById(
                        "magLimit"
                    )
                    .value
            ),


        maxStar:
            num(
                document
                    .getElementById(
                        "maxStar"
                    )
                    .value
            ),


        minStar:
            num(
                document
                    .getElementById(
                        "minStar"
                    )
                    .value
            ),


        labelSize:
            num(
                document
                    .getElementById(
                        "labelSize"
                    )
                    .value
            ),


        showLabels:
            document
                .getElementById(
                    "showLabels"
                )
                .checked
    };
}


/* ------------------------------------------------------------
   Download
   ------------------------------------------------------------ */

function downloadText(
    filename,
    text
) {

    const blob =
        new Blob(
            [text],
            {
                type:
                    "image/svg+xml;charset=utf-8"
            }
        );


    const url =
        URL.createObjectURL(
            blob
        );


    const a =
        document.createElement(
            "a"
        );


    a.href =
        url;


    a.download =
        filename;


    document
        .body
        .appendChild(a);


    a.click();

    a.remove();


    setTimeout(
        () =>
            URL.revokeObjectURL(
                url
            ),
        1000
    );
}


function downloadGore(index) {

    const options =
        getOptions();


    const svg =
        makeGoreSVG(
            index,
            gores[index],
            options
        );


    const name =
        `globe_gore_` +
        `${String(index + 1).padStart(2,"0")}` +
        `_of_24_mirrored.svg`;


    downloadText(
        name,
        svg
    );
}


/* ------------------------------------------------------------
   Интерфейс: загрузка CSV
   ------------------------------------------------------------ */

document
    .getElementById(
        "csvFile"
    )
    .addEventListener(
        "change",
        async function() {

            const file =
                this.files[0];


            if (!file) {

                return;
            }


            sourceFileName =
                file.name;


            const stats =
                document.getElementById(
                    "stats"
                );


            try {

                stats.innerHTML =
                    "Читаю CSV…";


                const text =
                    await file.text();


                const stars =
                    csvToStars(
                        text
                    );


                window.__allStars =
                    stars;


                const namedCount =
                    stars.filter(
                        s => s.name
                    ).length;


                stats.innerHTML =
                    `<span class="ok">CSV прочитан.</span><br>` +

                    `Записей со корректными координатами: ` +

                    `<b>${stars.length.toLocaleString("ru-RU")}</b><br>` +

                    `Звёзд с названиями: ` +

                    `<b>${namedCount.toLocaleString("ru-RU")}</b><br><br>` +

                    `Файл: <b>${esc(file.name)}</b>`;


                document
                    .getElementById(
                        "generate"
                    )
                    .disabled = false;


            } catch (err) {

                stats.innerHTML =
                    `<span class="error">Ошибка:</span><br>` +
                    `${esc(err.message)}`;


                document
                    .getElementById(
                        "generate"
                    )
                    .disabled = true;
            }
        }
    );


/* ------------------------------------------------------------
   Построение
   ------------------------------------------------------------ */

document
    .getElementById(
        "generate"
    )
    .addEventListener(
        "click",
        function() {

            const stats =
                document.getElementById(
                    "stats"
                );


            try {

                const options =
                    getOptions();


                if (
                    !Number.isFinite(
                        options.diameter
                    ) ||
                    options.diameter <= 0
                ) {

                    throw new Error(
                        "Диаметр должен быть положительным."
                    );
                }


                if (
                    !Number.isFinite(
                        options.magLimit
                    )
                ) {

                    throw new Error(
                        "Неверный предел звёздной величины."
                    );
                }


                if (
                    !Number.isFinite(
                        options.maxStar
                    ) ||
                    options.maxStar <= 0
                ) {

                    throw new Error(
                        "Размер самой яркой звезды должен быть положительным."
                    );
                }


                if (
                    !Number.isFinite(
                        options.minStar
                    ) ||
                    options.minStar <= 0
                ) {

                    throw new Error(
                        "Минимальный размер звезды должен быть положительным."
                    );
                }


                if (
                    !Number.isFinite(
                        options.labelSize
                    ) ||
                    options.labelSize <= 0
                ) {

                    throw new Error(
                        "Размер шрифта должен быть положительным."
                    );
                }


                const all =
                    window.__allStars ||
                    [];


                const selected =
                    all.filter(
                        s =>
                            s.mag <
                            options.magLimit
                    );


                gores =
                    buildGores(
                        all,
                        options
                    );


                const validation =
                    validateStars(
                        selected
                    );


                /*
                    Считаем звёзды с названиями.
                */

                const namedSelected =
                    selected.filter(
                        s => s.name
                    ).length;


                /*
                    Статистика по лепесткам.
                */

                let distribution =
                    "";


                for (
                    let i = 0;
                    i < N_GORES;
                    i++
                ) {

                    distribution +=
                        `${i + 1}: ` +
                        `${gores[i].length.toLocaleString("ru-RU")} ` +
                        `звёзд<br>`;
                }


                stats.innerHTML =
                    `<span class="ok">Готово. Звёзды зеркально отражены.</span><br>` +

                    `Всего в HYG: ` +
                    `<b>${all.length.toLocaleString("ru-RU")}</b><br>` +

                    `Ярче ${options.magLimit}: ` +
                    `<b>${selected.length.toLocaleString("ru-RU")}</b><br>` +

                    `Из них с названиями: ` +
                    `<b>${namedSelected.toLocaleString("ru-RU")}</b><br>` +

                    `Распределено: ` +
                    `<b>${validation.assigned.toLocaleString("ru-RU")}</b><br>` +

                    `Ошибок обратной проверки: ` +
                    `<b class="${validation.bad ? "error" : "ok"}">` +
                    `${validation.bad}</b><br>` +

                    `Граничных случаев: ` +
                    `<b>${validation.boundary}</b><br>` +

                    `Дубликатов назначения: ` +
                    `<b class="${validation.duplicates ? "warn" : "ok"}">` +
                    `${validation.duplicates}</b><hr>` +

                    `<b>Звёзды по лепесткам:</b><br>` +

                    distribution;


                /*
                    Кнопки 1...24.
                */

                const downloads =
                    document.getElementById(
                        "downloads"
                    );


                downloads.innerHTML =
                    "";


                for (
                    let i = 0;
                    i < N_GORES;
                    i++
                ) {

                    const b =
                        document.createElement(
                            "button"
                        );


                    b.textContent =
                        `Лепесток ${i + 1}`;


                    b.title =
                        `Показать/скачать лепесток ${i + 1}`;


                    b.addEventListener(
                        "click",
                        () => {

                            showGore(i);

                        }
                    );


                    downloads.appendChild(
                        b
                    );
                }


                /*
                    Первый лепесток.
                */

                showGore(0);


            } catch (err) {

                stats.innerHTML =
                    `<span class="error">Ошибка построения:</span><br>` +
                    `${esc(err.message)}`;
            }
        }
    );


/* ------------------------------------------------------------
   Скачать текущий лепесток
   ------------------------------------------------------------ */

document
    .getElementById(
        "downloadCurrent"
    )
    .addEventListener(
        "click",
        function() {

            if (!gores.length) {

                return;
            }


            downloadGore(
                currentGore
            );
        }
    );

</script></body>
</html>