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


<!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(" / ");
}


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

/*
 * Размер звёзд уменьшен в 3 раза
 * относительно исходной версии.
 *
 * Примерные радиусы:
 *
 * 0m → 1.2 мм
 * 1m → 1.0 мм
 * 2m → 0.83 мм
 * 3m → 0.67 мм
 * 4m → 0.50 мм
 * 5m → 0.33 мм
 *
 * Соответственно диаметры:
 *
 * 0m → 2.4 мм
 * 1m → 2.0 мм
 * 2m → 1.66 мм
 * 3m → 1.34 мм
 * 4m → 1.0 мм
 * 5m → 0.66 мм
 */

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;


    /*
     * Уменьшение в 3 раза.
     */

    const r =
        originalRadius / 3;


    return Math.max(
        0.3,
        Math.min(
            1.2,
            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>