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


<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<title>Звёздный глобус Ø1000 мм — 24 лепестка — J2000</title>

<style>
    body {
        margin: 0;
        padding: 20px;
        font-family: Arial, sans-serif;
        background: #eeeeee;
        color: #111;
    }

    .panel {
        max-width: 1000px;
        margin: 0 auto 20px;
        padding: 20px;
        background: white;
        border-radius: 8px;
        box-sizing: border-box;
    }

    h1, h2 {
        margin-top: 0;
    }

    button {
        padding: 12px 18px;
        margin: 5px;
        border: 1px solid #333;
        background: white;
        border-radius: 5px;
        cursor: pointer;
        font-size: 15px;
    }

    button:hover {
        background: #eeeeee;
    }

    button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
    }

    #status {
        margin-top: 15px;
        padding: 12px;
        background: #f5f5f5;
        font-family: monospace;
        white-space: pre-line;
        border-radius: 4px;
    }

    #preview {
        overflow: auto;
        text-align: center;
    }

    #preview svg {
        width: 100%;
        max-width: 500px;
        height: auto;
        background: white;
        border: 1px solid #aaa;
    }

    .small {
        font-size: 13px;
        color: #555;
        line-height: 1.5;
    }

    .stats {
        margin-top: 15px;
        font-size: 14px;
        color: #333;
    }
</style>
</head>

<body>

<div class="panel">

    <h1>Звёздный глобус Ø1000 мм</h1>

    <p>
        <b>24 лепестка</b> ·
        <b>J2000</b> ·
        звёзды до <b>5.0m</b>
    </p>

    <p class="small">
        Чёрные звёзды на белом фоне.
        Каждый лепесток охватывает 15° прямого восхождения.
        SVG имеют физические размеры в миллиметрах.
        При печати необходимо использовать масштаб 100%.
    </p>

    <button id="downloadAllButton" onclick="downloadAll()" disabled>
        Скачать 24 SVG
    </button>

    <button id="downloadZipButton" onclick="downloadZIP()" disabled>
        Скачать ZIP
    </button>

    <div id="status">
        Загрузка каталога...
    </div>

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

</div>


<div class="panel">

    <h2>Предпросмотр лепестка №1</h2>

    <div id="preview"></div>

</div>


<script>

/* =========================================================
   ОСНОВНЫЕ ПАРАМЕТРЫ
   ========================================================= */

const DIAMETER_MM = 1000;

const RADIUS_MM =
    DIAMETER_MM / 2;

const NUMBER_OF_GORES = 24;

const GORE_DEGREES =
    360 / NUMBER_OF_GORES;

const MAX_MAGNITUDE = 5.0;

const TAB_MM = 8;

const MARGIN_MM = 12;


/*
 * Yale Bright Star Catalog.
 *
 * J2000 координаты.
 */

const CATALOG_URL =
    "https://brettonw.github.io/YaleBrightStarCatalog/bsc5.json";


let stars = [];

let catalogLoaded = false;


/* =========================================================
   ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
   ========================================================= */

function degToRad(deg) {

    return deg * Math.PI / 180;

}


function radToDeg(rad) {

    return rad * 180 / Math.PI;

}


function sleep(ms) {

    return new Promise(resolve => {

        setTimeout(resolve, ms);

    });

}


function fmt(n) {

    return Number(
        Number(n).toFixed(3)
    );

}


function setStatus(text) {

    const element =
        document.getElementById("status");

    element.textContent = text;

}


function normalizeRA(ra) {

    let value =
        Number(ra) % 360;

    if (value < 0) {

        value += 360;

    }

    return value;

}


/*
 * Угол в диапазоне [-180, +180).
 */

function signedAngle(deg) {

    let x =
        ((deg + 180) % 360 + 360) % 360 - 180;

    return x;

}


/* =========================================================
   ПАРСИНГ RA
   ========================================================= */

/*
 * Поддерживаются варианты:
 *
 * 12.345
 * "12h 34m 56.7s"
 * "12:34:56.7"
 * "12 34 56.7"
 */

function parseRA(value) {

    if (
        value === undefined ||
        value === null
    ) {

        return null;

    }


    if (typeof value === "number") {

        if (!Number.isFinite(value)) {

            return null;

        }


        /*
         * Готовое поле RA из bsc5.json
         * обычно представлено как строка.
         *
         * Числовое значение трактуем
         * как часы.
         */

        if (value >= 0 && value <= 24) {

            return value * 15;

        }

        return normalizeRA(value);

    }


    const text =
        String(value)
            .trim()
            .replace(/h/gi, " ")
            .replace(/m/gi, " ")
            .replace(/s/gi, " ")
            .replace(/:/g, " ")
            .replace(/,/g, ".")
            .replace(/\s+/g, " ");


    const parts =
        text.split(" ")
            .filter(Boolean);


    if (!parts.length) {

        return null;

    }


    const h =
        Number(parts[0]);


    const m =
        parts.length > 1
            ? Number(parts[1])
            : 0;


    const s =
        parts.length > 2
            ? Number(parts[2])
            : 0;


    if (
        !Number.isFinite(h) ||
        !Number.isFinite(m) ||
        !Number.isFinite(s)
    ) {

        return null;

    }


    /*
     * Если строка похожа на десятичные градусы
     * отдельно это не определяем:
     * для RA каталога используется часы.
     */

    return normalizeRA(

        15 * (
            h +
            m / 60 +
            s / 3600
        )

    );

}


/* =========================================================
   ПАРСИНГ DECLINATION
   ========================================================= */

/*
 * Поддерживаются:
 *
 * +45 12 33
 * -45 12 33
 * +45° 12′ 33″
 * +45:12:33
 * числовое значение градусов
 */

function parseDec(value) {

    if (
        value === undefined ||
        value === null
    ) {

        return null;

    }


    if (typeof value === "number") {

        if (!Number.isFinite(value)) {

            return null;

        }


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

            return null;

        }

        return value;

    }


    const text =
        String(value)
            .trim()
            .replace(/°/g, " ")
            .replace(/′/g, " ")
            .replace(/″/g, " ")
            .replace(/:/g, " ")
            .replace(/,/g, ".")
            .replace(/\s+/g, " ");


    if (!text) {

        return null;

    }


    const negative =
        text.startsWith("-") ||
        text.startsWith("−");


    const cleaned =
        text.replace(
            /^[+\-−]/,
            ""
        );


    const parts =
        cleaned.split(" ")
            .filter(Boolean);


    const d =
        Number(parts[0]);


    const m =
        parts.length > 1
            ? Number(parts[1])
            : 0;


    const s =
        parts.length > 2
            ? Number(parts[2])
            : 0;


    if (
        !Number.isFinite(d) ||
        !Number.isFinite(m) ||
        !Number.isFinite(s)
    ) {

        return null;

    }


    let dec =
        Math.abs(d) +
        Math.abs(m) / 60 +
        Math.abs(s) / 3600;


    if (negative) {

        dec = -dec;

    }


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

        return null;

    }


    return dec;

}


/* =========================================================
   ПОЛУЧЕНИЕ RA ИЗ ЗАПИСИ
   ========================================================= */

function getRA(record) {

    /*
     * Самый надёжный вариант для bsc5.json:
     * готовое поле RA.
     */

    if (
        record.RA !== undefined &&
        record.RA !== null
    ) {

        const ra =
            parseRA(record.RA);

        if (ra !== null) {

            return ra;

        }

    }


    /*
     * Запасной вариант:
     * исходные поля BSC.
     */

    const h =
        Number(record.RAh);

    const m =
        Number(record.RAm);

    const s =
        Number(record.RAs);


    if (
        Number.isFinite(h) &&
        Number.isFinite(m) &&
        Number.isFinite(s)
    ) {

        return normalizeRA(

            15 * (
                h +
                m / 60 +
                s / 3600
            )

        );

    }


    return null;

}


/* =========================================================
   ПОЛУЧЕНИЕ DECLINATION ИЗ ЗАПИСИ
   ========================================================= */

function getDec(record) {

    /*
     * Готовое поле Dec.
     */

    if (
        record.Dec !== undefined &&
        record.Dec !== null
    ) {

        const dec =
            parseDec(record.Dec);

        if (dec !== null) {

            return dec;

        }

    }


    /*
     * Исходные поля BSC.
     *
     * В Yale BSC знак находится
     * в поле DE-.
     */

    let degrees =
        Number(record.DEd);

    let minutes =
        Number(record.DEm);

    let seconds =
        Number(record.DEs);


    if (
        !Number.isFinite(degrees) ||
        !Number.isFinite(minutes) ||
        !Number.isFinite(seconds)
    ) {

        return null;

    }


    let dec =
        Math.abs(degrees) +
        Math.abs(minutes) / 60 +
        Math.abs(seconds) / 3600;


    /*
     * ВАЖНО:
     * правильное поле BSC — DE-
     */

    const sign =
        record["DE-"];


    if (
        sign !== undefined &&
        sign !== null
    ) {

        const value =
            String(sign)
                .trim()
                .toUpperCase();


        if (
            value === "-" ||
            value === "−" ||
            value === "S"
        ) {

            dec = -dec;

        }

    }

    else if (degrees < 0) {

        dec = -dec;

    }


    return dec;

}


/* =========================================================
   ЗАГРУЗКА КАТАЛОГА
   ========================================================= */

async function loadCatalog() {

    if (catalogLoaded) {

        return true;

    }


    setStatus(
        "Загрузка Yale Bright Star Catalog..."
    );


    try {

        const response =
            await fetch(
                CATALOG_URL,
                {
                    cache: "no-cache"
                }
            );


        if (!response.ok) {

            throw new Error(
                "HTTP " +
                response.status +
                " " +
                response.statusText
            );

        }


        const data =
            await response.json();


        if (!Array.isArray(data)) {

            throw new Error(
                "Каталог имеет неожиданный формат: " +
                "ожидался JSON-массив."
            );

        }


        const result = [];


        for (const record of data) {

            /*
             * Vmag
             */

            const mag =
                Number(record.Vmag);


            if (!Number.isFinite(mag)) {

                continue;

            }


            if (
                mag > MAX_MAGNITUDE
            ) {

                continue;

            }


            /*
             * RA
             */

            const ra =
                getRA(record);


            if (ra === null) {

                continue;

            }


            /*
             * Dec
             */

            const dec =
                getDec(record);


            if (dec === null) {

                continue;

            }


            /*
             * Защита от некорректных данных.
             */

            if (
                ra < 0 ||
                ra >= 360 ||
                dec < -90 ||
                dec > 90
            ) {

                continue;

            }


            result.push({

                ra: ra,

                dec: dec,

                mag: mag,

                hr: record.HR ?? null,

                name: record.Common ?? null

            });

        }


        stars = result;


        if (!stars.length) {

            throw new Error(
                "После обработки каталога не осталось звёзд."
            );

        }


        catalogLoaded = true;


        setStatus(

            "Каталог загружен.\n" +

            "Всего записей в JSON: " +

            data.length +

            "\n" +

            "Звёзд с V ≤ " +

            MAX_MAGNITUDE.toFixed(1) +

            "m: " +

            stars.length

        );


        updateStats();

        enableButtons();

        renderPreview();


        return true;

    }

    catch (error) {

        console.error(
            "Ошибка загрузки каталога:",
            error
        );


        setStatus(

            "ОШИБКА ЗАГРУЗКИ КАТАЛОГА\n\n" +

            error.message +

            "\n\n" +

            "Если HTML открыт напрямую через file://,\n" +

            "запустите его через локальный HTTP-сервер."

        );


        return false;

    }

}


/* =========================================================
   СТАТИСТИКА
   ========================================================= */

function updateStats() {

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


    const counts = [];


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

        counts.push(
            starsForGore(i).length
        );

    }


    element.textContent =
        "Распределение звёзд по лепесткам: " +
        counts.join(" / ");

}


/* =========================================================
   РАЗМЕР ЗВЕЗДЫ
   ========================================================= */

/*
 * Размер звёзд уменьшен В 2 РАЗА
 * относительно предыдущей версии.
 *
 * Примерные радиусы:
 *
 * 0m → 0.60 мм
 * 1m → 0.50 мм
 * 2m → 0.415 мм
 * 3m → 0.335 мм
 * 4m → 0.25 мм
 * 5m → 0.165 мм
 *
 * Диаметры:
 *
 * 0m → 1.20 мм
 * 1m → 1.00 мм
 * 2m → 0.83 мм
 * 3m → 0.67 мм
 * 4m → 0.50 мм
 * 5m → 0.33 мм
 *
 * Минимальный радиус оставлен 0.15 мм,
 * чтобы самые слабые звёзды не исчезали
 * полностью из SVG.
 */

function starRadius(mag) {

    const brightness =
        Math.max(
            0,
            Math.min(
                5,
                5 - mag
            )
        );


    const originalRadius =
        1.0 +
        Math.pow(
            brightness / 5,
            0.65
        ) * 2.6;


    /*
     * Сначала предыдущий масштаб:
     * originalRadius / 3
     *
     * Теперь дополнительно
     * уменьшаем размер в 2 раза.
     */

    const r =
        originalRadius / 6;


    return Math.max(
        0.15,
        Math.min(
            0.6,
            r
        )
    );

}


/* =========================================================
   ЦЕНТР ЛЕПЕСТКА
   ========================================================= */

function goreCenterRA(index) {

    return (
        index *
        GORE_DEGREES
    );

}


/* =========================================================
   ЗВЁЗДЫ ЛЕПЕСТКА
   ========================================================= */

function starsForGore(index) {

    const center =
        goreCenterRA(index);


    const half =
        GORE_DEGREES / 2;


    const EPS =
        1e-8;


    return stars.filter(star => {

        const delta =
            signedAngle(
                star.ra - center
            );


        /*
         * Полуоткрытый интервал:
         *
         * [-7.5°, +7.5°)
         *
         * Благодаря этому звезда,
         * лежащая точно на разрезе,
         * попадает только в один лепесток.
         */

        return (
            delta >= -half - EPS &&
            delta < half - EPS
        );

    });

}


/* =========================================================
   ПРОЕКЦИЯ
   ========================================================= */

/*
 * Практическая sinusoidal-gore развёртка.
 *
 * y = R * latitude
 *
 * x = R * cos(latitude) * sin(longitude)
 *
 * Здесь R — радиус готового глобуса.
 */

function projectPoint(
    deltaRA,
    dec
) {

    const lambda =
        degToRad(deltaRA);


    const phi =
        degToRad(dec);


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


    const y =
        RADIUS_MM *
        phi;


    return {

        x: x,

        y: y

    };

}


/* =========================================================
   ГРАНИЦА ЛЕПЕСТКА
   ========================================================= */

function goreBoundary() {

    const half =
        degToRad(
            GORE_DEGREES / 2
        );


    const points = [];


    /*
     * Левая сторона:
     * север → юг.
     */

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

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


        const x =
            RADIUS_MM *
            Math.cos(phi) *
            Math.sin(-half);


        const y =
            RADIUS_MM *
            phi;


        points.push({

            x: x,

            y: y

        });

    }


    /*
     * Правая сторона:
     * юг → север.
     */

    for (
        let i = 360;
        i >= 0;
        i--
    ) {

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


        const x =
            RADIUS_MM *
            Math.cos(phi) *
            Math.sin(half);


        const y =
            RADIUS_MM *
            phi;


        points.push({

            x: x,

            y: y

        });

    }


    return points;

}


/* =========================================================
   PATH
   ========================================================= */

function pointsToPath(points) {

    if (!points.length) {

        return "";

    }


    let d =
        "M " +
        fmt(points[0].x) +
        " " +
        fmt(points[0].y);


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

        d +=
            " L " +
            fmt(points[i].x) +
            " " +
            fmt(points[i].y);

    }


    d += " Z";


    return d;

}


/* =========================================================
   КЛАПАНЫ
   ========================================================= */

/*
 * Создаёт внешний пунктирный контур клапана
 * вдоль одной стороны лепестка.
 *
 * Клапаны не доходят до полюсов,
 * чтобы не образовывать большие
 * перекрытия в полюсах.
 */

function createTabPoints(side) {

    const half =
        degToRad(
            GORE_DEGREES / 2
        );


    const points = [];


    const poleCut =
        18;


    const start =
        poleCut;


    const end =
        180 - poleCut;


    for (
        let i = start;
        i <= end;
        i += 2
    ) {

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


        const boundaryX =
            RADIUS_MM *
            Math.cos(phi) *
            Math.sin(
                side === "left"
                    ? -half
                    : half
            );


        const boundaryY =
            RADIUS_MM *
            phi;


        const direction =
            side === "left"
                ? -1
                : 1;


        points.push({

            x:
                boundaryX +
                direction * TAB_MM,

            y:
                boundaryY

        });

    }


    return points;

}


/* =========================================================
   СКАЧИВАНИЕ ТЕКСТА
   ========================================================= */

function downloadText(
    text,
    filename
) {

    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
    );

}


/* =========================================================
   SVG ОДНОГО ЛЕПЕСТКА
   ========================================================= */

function createGoreSVG(index) {

    const starsHere =
        starsForGore(index);


    const halfWidth =
        RADIUS_MM *
        Math.sin(
            degToRad(
                GORE_DEGREES / 2
            )
        );


    const goreWidth =
        halfWidth * 2;


    const totalWidth =
        goreWidth +
        TAB_MM * 2 +
        MARGIN_MM * 2;


    const totalHeight =
        Math.PI *
        RADIUS_MM +
        MARGIN_MM * 2;


    const viewMinX =
        -totalWidth / 2;


    const viewMinY =
        -totalHeight / 2;


    const boundary =
        goreBoundary();


    const boundaryPath =
        pointsToPath(boundary);


    let svg = "";


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

<svg xmlns="http://www.w3.org/2000/svg"
     version="1.1"
     width="${fmt(totalWidth)}mm"
     height="${fmt(totalHeight)}mm"
     viewBox="${fmt(viewMinX)} ${fmt(viewMinY)} ${fmt(totalWidth)} ${fmt(totalHeight)}">

<rect
    x="${fmt(viewMinX)}"
    y="${fmt(viewMinY)}"
    width="${fmt(totalWidth)}"
    height="${fmt(totalHeight)}"
    fill="white"/>

<g>
`;


    /*
     * Основная поверхность.
     */

    svg += `<path
    d="${boundaryPath}"
    fill="white"
    stroke="black"
    stroke-width="0.35"
    stroke-linejoin="round"/>`;


    /*
     * Звёзды.
     */

    for (
        const star of starsHere
    ) {

        const delta =
            signedAngle(
                star.ra -
                goreCenterRA(index)
            );


        const point =
            projectPoint(
                delta,
                star.dec
            );


        const r =
            starRadius(
                star.mag
            );


        /*
         * Не рисуем звезду,
         * если из-за округления
         * она оказалась вне поверхности.
         */

        if (
            Math.abs(point.x) >
            halfWidth + 0.001
        ) {

            continue;

        }


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

    }


    /*
     * Центральный меридиан УДАЛЁН.
     *
     * Пунктирная линия экватора
     * остаётся ниже.
     */


    /*
     * Экватор.
     */

    svg += `

<line
    x1="${fmt(-halfWidth)}"
    y1="0"
    x2="${fmt(halfWidth)}"
    y2="0"
    stroke="black"
    stroke-width="0.15"
    stroke-dasharray="2,2"/>
`;


    /*
     * Полюса.
     */

    const poleY =
        Math.PI *
        RADIUS_MM /
        2;


    svg += `

<circle
    cx="0"
    cy="${fmt(-poleY)}"
    r="1.5"
    fill="none"
    stroke="black"
    stroke-width="0.3"/>

<circle
    cx="0"
    cy="${fmt(poleY)}"
    r="1.5"
    fill="none"
    stroke="black"
    stroke-width="0.3"/>
`;


    /*
     * Клапаны.
     */

    const leftTab =
        createTabPoints("left");


    const rightTab =
        createTabPoints("right");


    function tabPath(points) {

        if (!points.length) {

            return "";

        }


        let d =
            "M " +
            fmt(points[0].x) +
            " " +
            fmt(points[0].y);


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

            d +=
                " L " +
                fmt(points[i].x) +
                " " +
                fmt(points[i].y);

        }


        return d;

    }


    svg += `

<path
    d="${tabPath(leftTab)}"
    fill="none"
    stroke="black"
    stroke-width="0.25"
    stroke-dasharray="3,2"/>

<path
    d="${tabPath(rightTab)}"
    fill="none"
    stroke="black"
    stroke-width="0.25"
    stroke-dasharray="3,2"/>
`;


    /*
     * Номер лепестка.
     */

    svg += `

<text
    x="0"
    y="${fmt(-poleY - 5)}"
    text-anchor="middle"
    font-family="Arial, sans-serif"
    font-size="5"
    fill="black">

    GORE ${index + 1}

</text>
`;


    /*
     * RA диапазон.
     */

    const raStart =
        normalizeRA(
            goreCenterRA(index) -
            GORE_DEGREES / 2
        );


    const raEnd =
        normalizeRA(
            goreCenterRA(index) +
            GORE_DEGREES / 2
        );


    svg += `

<text
    x="0"
    y="${fmt(poleY + 7)}"
    text-anchor="middle"
    font-family="Arial, sans-serif"
    font-size="3.5"
    fill="black">

    RA ${formatRA(raStart)} — ${formatRA(raEnd)}

</text>
`;


    /*
     * Техническая информация.
     */

    svg += `

<text
    x="${fmt(viewMinX + 3)}"
    y="${fmt(viewMinY + 5)}"
    font-family="Arial, sans-serif"
    font-size="2.5"
    fill="black">

    Ø1000 mm · J2000 · V≤${MAX_MAGNITUDE.toFixed(1)} · ${starsHere.length} stars

</text>
`;


    svg += `

</g>

</svg>`;


    return svg;

}


/* =========================================================
   FORMAT RA
   ========================================================= */

function formatRA(degrees) {

    const hours =
        normalizeRA(degrees) / 15;


    return (
        hours.toFixed(2) +
        "h"
    );

}


/* =========================================================
   PREVIEW
   ========================================================= */

function renderPreview() {

    if (!stars.length) {

        return;

    }


    const svg =
        createGoreSVG(0);


    const container =
        document.getElementById(
            "preview"
        );


    container.innerHTML =
        svg;

}


/* =========================================================
   АКТИВАЦИЯ КНОПОК
   ========================================================= */

function enableButtons() {

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


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

}


/* =========================================================
   СКАЧАТЬ 24 SVG
   ========================================================= */

async function downloadAll() {

    if (!catalogLoaded) {

        const success =
            await loadCatalog();


        if (!success) {

            return;

        }

    }


    setStatus(
        "Создание 24 SVG-файлов..."
    );


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

        const svg =
            createGoreSVG(i);


        const filename =
            "star_globe_" +
            String(i + 1).padStart(2, "0") +
            "_of_24.svg";


        downloadText(
            svg,
            filename
        );


        setStatus(

            "Создание SVG...\n" +

            "Лепесток " +

            (i + 1) +

            " из " +

            NUMBER_OF_GORES

        );


        /*
         * Небольшая задержка,
         * чтобы браузер успевал
         * обработать загрузки.
         */

        await sleep(250);

    }


    setStatus(

        "ГОТОВО\n\n" +

        "Создано: 24 SVG\n" +

        "Диаметр: 1000 мм\n" +

        "Лепестков: 24\n" +

        "Угол лепестка: 15°\n" +

        "Система координат: J2000\n" +

        "Предел звёзд: V ≤ " +

        MAX_MAGNITUDE.toFixed(1) +

        "m"

    );

}


/* =========================================================
   ЗАГРУЗКА JSZIP
   ========================================================= */

function loadScript(src) {

    return new Promise(
        (resolve, reject) => {

            /*
             * Если JSZip уже существует.
             */

            if (
                typeof JSZip !==
                "undefined"
            ) {

                resolve();

                return;

            }


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


            script.src = src;

            script.async = true;


            script.onload = () => {

                resolve();

            };


            script.onerror = () => {

                reject(
                    new Error(
                        "Не удалось загрузить JSZip."
                    )
                );

            };


            document.head.appendChild(
                script
            );

        }
    );

}


/* =========================================================
   ZIP
   ========================================================= */

async function downloadZIP() {

    if (!catalogLoaded) {

        const success =
            await loadCatalog();


        if (!success) {

            return;

        }

    }


    setStatus(
        "Загрузка библиотеки ZIP..."
    );


    try {

        await loadScript(
            "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"
        );


        setStatus(
            "Создание ZIP-файла..."
        );


        const zip =
            new JSZip();


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

            const svg =
                createGoreSVG(i);


            const filename =
                "star_globe_" +
                String(i + 1).padStart(2, "0") +
                "_of_24.svg";


            zip.file(
                filename,
                svg
            );


            setStatus(

                "Создание ZIP...\n" +

                "Лепесток " +

                (i + 1) +

                " из " +

                NUMBER_OF_GORES

            );

        }


        const blob =
            await zip.generateAsync({

                type: "blob",

                compression: "DEFLATE",

                compressionOptions: {

                    level: 6

                }

            });


        const url =
            URL.createObjectURL(blob);


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


        a.href = url;


        a.download =
            "star_globe_1000mm_J2000_24_gores.zip";


        document.body.appendChild(a);


        a.click();


        a.remove();


        setTimeout(
            () => {

                URL.revokeObjectURL(url);

            },
            2000
        );


        setStatus(

            "ZIP-ФАЙЛ ГОТОВ\n\n" +

            "Файл: " +

            "star_globe_1000mm_J2000_24_gores.zip\n" +

            "Лепестков: 24\n" +

            "Диаметр: 1000 мм\n" +

            "J2000\n" +

            "V ≤ " +

            MAX_MAGNITUDE.toFixed(1) +

            "m"

        );

    }

    catch (error) {

        console.error(error);


        setStatus(

            "ОШИБКА СОЗДАНИЯ ZIP\n\n" +

            error.message

        );

    }

}


/* =========================================================
   ЗАПУСК
   ========================================================= */

loadCatalog();

</script>

</body>
</html>