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


<!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: 1100px;
        margin: 0 auto 20px;
        padding: 20px;
        background: white;
        border-radius: 8px;
        box-sizing: border-box;
    }

    h1, h2, h3 {
        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;
        line-height: 1.7;
    }

    .ok {
        color: #176b2c;
    }

    .warning {
        color: #a15c00;
    }

    .error {
        color: #a00000;
    }

    .formula {
        background: #f5f5f5;
        padding: 12px;
        border-radius: 5px;
        font-family: monospace;
        overflow-x: auto;
    }
</style>
</head>

<body>

<div class="panel">

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

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

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

    <div class="formula">
        x = R · cos(Dec) · sin(RA − RA_center)<br>
        y = −R · Dec_rad<br>
        R = 500 мм
    </div>

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

    <p class="small">
        Preview показывает звёзды и границу лепестка.
        Красная точка — звезда, находящаяся непосредственно
        около границы RA лепестка.
    </p>

    <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 HALF_GORE_DEGREES = GORE_DEGREES / 2;

const MAX_MAGNITUDE = 5.0;


/*
 * Техническое поле вокруг геометрии.
 */
const MARGIN_MM = 12;


/*
 * Обводка лепестка.
 */
const GORE_STROKE = "#999999";
const GORE_STROKE_WIDTH = 0.2;


/*
 * Линии контроля.
 */
const SHOW_EQUATOR = false;
const SHOW_POLES = false;
const SHOW_GRID = false;


/*
 * Пограничная зона.

 * Если звезда находится ближе этого угла
 * к границе лепестка, она считается boundary-near.
 */
const BOUNDARY_TOLERANCE_DEG = 0.01;


/*
 * Максимальная допустимая ошибка
 * обратного преобразования.
 *
 * Для J2000 координат это только
 * математический контроль нашего
 * преобразования.
 */
const ROUNDTRIP_TOLERANCE_DEG = 1e-9;


/*
 * Каталог Yale Bright Star Catalog.
 */
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) {
    document.getElementById("status").textContent = text;
}


function normalizeRA(ra) {

    let value = Number(ra) % 360;

    if (value < 0) {
        value += 360;
    }

    /*
     * Не допускаем 360.
     */
    if (value >= 360) {
        value -= 360;
    }

    return value;
}


/*
 * Возвращает угол [-180, +180).
 */
function signedAngle(deg) {

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


/* =========================================================
   PARSE RA
   ========================================================= */

function parseRA(value) {

    if (
        value === undefined ||
        value === null
    ) {
        return null;
    }


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

        if (!Number.isFinite(value)) {
            return null;
        }

        /*
         * В BSC числовое RA обычно часы.
         *
         * Если 0...24 — считаем часами.
         */
        if (
            value >= 0 &&
            value <= 24
        ) {
            return normalizeRA(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, " ");


    if (!text) {
        return null;
    }


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


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


    if (
        h < 0 ||
        h > 24 ||
        m < 0 ||
        m >= 60 ||
        s < 0 ||
        s >= 60
    ) {
        return null;
    }


    return normalizeRA(
        15 * (
            h +
            m / 60 +
            s / 3600
        )
    );
}


/* =========================================================
   PARSE DEC
   ========================================================= */

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 raw =
        String(value)
            .trim()
            .replace(/−/g, "-")
            .replace(/°/g, " ")
            .replace(/′/g, " ")
            .replace(/″/g, " ")
            .replace(/:/g, " ")
            .replace(/,/g, ".")
            .replace(/\s+/g, " ");


    if (!raw) {
        return null;
    }


    const negative =
        raw.startsWith("-");


    const cleaned =
        raw.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;
    }


    if (
        d < 0 ||
        d > 90 ||
        m < 0 ||
        m >= 60 ||
        s < 0 ||
        s >= 60
    ) {
        return null;
    }


    let dec =
        d +
        m / 60 +
        s / 3600;


    if (negative) {
        dec = -dec;
    }


    if (
        dec < -90 ||
        dec > 90
    ) {
        return null;
    }


    return dec;
}


/* =========================================================
   GET RA
   ========================================================= */

function getRA(record) {

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

        const ra =
            parseRA(record.RA);

        if (ra !== null) {
            return ra;
        }
    }


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


/* =========================================================
   GET DEC
   ========================================================= */

function getDec(record) {

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

        const dec =
            parseDec(record.Dec);

        if (dec !== null) {
            return dec;
        }
    }


    const degrees =
        Number(record.DEd);

    const minutes =
        Number(record.DEm);

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


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


/* =========================================================
   LOAD CATALOG
   ========================================================= */

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

            const mag =
                Number(record.Vmag);


            if (!Number.isFinite(mag)) {
                continue;
            }


            if (
                mag > MAX_MAGNITUDE
            ) {
                continue;
            }


            const ra =
                getRA(record);


            if (ra === null) {
                continue;
            }


            const dec =
                getDec(record);


            if (dec === null) {
                continue;
            }


            result.push({

                ra: normalizeRA(ra),

                dec: dec,

                mag: mag,

                hr:
                    record.HR ??
                    null,

                name:
                    record.Common ??
                    null
            });
        }


        if (!result.length) {

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


        stars = result;

        catalogLoaded = true;


        setStatus(
            "Каталог загружен.\n" +
            "Записей в JSON: " +
            data.length +
            "\n" +
            "Звёзд V ≤ " +
            MAX_MAGNITUDE.toFixed(1) +
            "m: " +
            stars.length
        );


        runFullGeometryCheck();

        updateStats();
        enableButtons();
        renderPreview();


        return true;

    }
    catch (error) {

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


        setStatus(
            "ОШИБКА ЗАГРУЗКИ КАТАЛОГА\n\n" +
            error.message +
            "\n\n" +
            "Если HTML открыт напрямую через file://,\n" +
            "запустите его через локальный HTTP-сервер."
        );


        return false;
    }
}


/* =========================================================
   GORE CENTER
   ========================================================= */

function goreCenterRA(index) {

    return normalizeRA(
        index * GORE_DEGREES
    );
}


/* =========================================================
   GORE INDEX
   ========================================================= */

function goreIndexForRA(ra) {

    const normalized =
        normalizeRA(ra);


    /*
     * Сдвигаем на половину лепестка,
     * чтобы диапазоны стали:
     *
     * 0: 352.5...7.5
     * 1: 7.5...22.5
     * ...
     */

    const shifted =
        normalizeRA(
            normalized +
            HALF_GORE_DEGREES
        );


    let index =
        Math.floor(
            shifted /
            GORE_DEGREES
        );


    if (
        index < 0 ||
        index >= NUMBER_OF_GORES
    ) {
        index = 0;
    }


    return index;
}


/* =========================================================
   STAR → GORE
   ========================================================= */

function starsForGore(index) {

    return stars.filter(
        star =>
            goreIndexForRA(star.ra) === index
    );
}


/* =========================================================
   DISTANCE TO GORE BORDER
   ========================================================= */

function distanceToGoreBorder(
    ra,
    index
) {

    const center =
        goreCenterRA(index);


    const delta =
        Math.abs(
            signedAngle(
                ra - center
            )
        );


    return HALF_GORE_DEGREES - delta;
}


/* =========================================================
   FORWARD PROJECTION
   ========================================================= */

/*
 * Sinusoidal projection.
 *
 * lambda = RA offset from gore center.
 * phi    = declination.
 *
 * x = R cos(phi) sin(lambda)
 * y = -R phi
 *
 * SVG Y is inverted intentionally:
 * north = negative Y.
 */

function projectPoint(
    deltaRA,
    dec
) {

    const lambda =
        degToRad(deltaRA);

    const phi =
        degToRad(dec);


    return {

        x:
            RADIUS_MM *
            Math.cos(phi) *
            Math.sin(lambda),

        y:
            -RADIUS_MM *
            phi
    };
}


/* =========================================================
   INVERSE PROJECTION
   ========================================================= */

function inverseProjectPoint(
    x,
    y,
    centerRA
) {

    /*
     * phi = -y / R
     */

    const phi =
        -y /
        RADIUS_MM;


    /*
     * lambda:
     *
     * x = R cos(phi) sin(lambda)
     *
     * sin(lambda) =
     * x / (R cos(phi))
     */

    const cosPhi =
        Math.cos(phi);


    let ratio =
        x /
        (
            RADIUS_MM *
            cosPhi
        );


    /*
     * Защита от микроскопического
     * floating point выхода за [-1,1].
     */

    ratio =
        Math.max(
            -1,
            Math.min(
                1,
                ratio
            )
        );


    const lambda =
        Math.asin(ratio);


    const deltaRA =
        radToDeg(lambda);


    const dec =
        radToDeg(phi);


    const ra =
        normalizeRA(
            centerRA +
            deltaRA
        );


    return {
        ra: ra,
        dec: dec,
        deltaRA: deltaRA
    };
}


/* =========================================================
   ROUND TRIP CHECK
   ========================================================= */

function checkRoundTrip(
    star,
    index
) {

    const center =
        goreCenterRA(index);


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


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


    const inverse =
        inverseProjectPoint(
            p.x,
            p.y,
            center
        );


    const raError =
        Math.abs(
            signedAngle(
                inverse.ra -
                star.ra
            )
        );


    const decError =
        Math.abs(
            inverse.dec -
            star.dec
        );


    return {

        raError,
        decError,

        ok:
            raError <=
                ROUNDTRIP_TOLERANCE_DEG &&
            decError <=
                ROUNDTRIP_TOLERANCE_DEG
    };
}


/* =========================================================
   GEOMETRY CHECK
   ========================================================= */

function runFullGeometryCheck() {

    let failed = 0;

    let boundaryStars = 0;

    let maxRAError = 0;

    let maxDecError = 0;

    let maxXError = 0;

    let maxYError = 0;


    for (
        const star of stars
    ) {

        const index =
            goreIndexForRA(star.ra);


        const center =
            goreCenterRA(index);


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


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


        /*
         * Обратная проекция.
         */

        const inverse =
            inverseProjectPoint(
                p.x,
                p.y,
                center
            );


        /*
         * Ошибки.
         */

        const raError =
            Math.abs(
                signedAngle(
                    inverse.ra -
                    star.ra
                )
            );


        const decError =
            Math.abs(
                inverse.dec -
                star.dec
            );


        maxRAError =
            Math.max(
                maxRAError,
                raError
            );


        maxDecError =
            Math.max(
                maxDecError,
                decError
            );


        /*
         * Повторная прямая проекция.
         */

        const p2 =
            projectPoint(
                inverse.deltaRA,
                inverse.dec
            );


        maxXError =
            Math.max(
                maxXError,
                Math.abs(
                    p2.x -
                    p.x
                )
            );


        maxYError =
            Math.max(
                maxYError,
                Math.abs(
                    p2.y -
                    p.y
                )
            );


        /*
         * Контроль границы.
         */

        const distance =
            distanceToGoreBorder(
                star.ra,
                index
            );


        if (
            distance <=
            BOUNDARY_TOLERANCE_DEG
        ) {
            boundaryStars++;
        }


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

        if (
            Math.abs(delta) >
            HALF_GORE_DEGREES +
            1e-10
        ) {
            failed++;
        }


        /*
         * Проверка round-trip.
         */

        if (
            raError >
                ROUNDTRIP_TOLERANCE_DEG ||
            decError >
                ROUNDTRIP_TOLERANCE_DEG
        ) {
            failed++;
        }
    }


    const resultClass =
        failed === 0
            ? "ok"
            : "error";


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


    stats.innerHTML =
        `<span class="${resultClass}">
        Геометрия:
        ${failed === 0 ? "OK" : "ОШИБКА"}
        </span><br>
        Звёзд: ${stars.length}<br>
        Максимальная ошибка RA round-trip:
        ${maxRAError.toExponential(3)}°<br>
        Максимальная ошибка Dec round-trip:
        ${maxDecError.toExponential(3)}°<br>
        Максимальная ошибка X:
        ${maxXError.toExponential(3)} мм<br>
        Максимальная ошибка Y:
        ${maxYError.toExponential(3)} мм<br>
        Звёзд ближе ${BOUNDARY_TOLERANCE_DEG}°
        к границе:
        <span class="${boundaryStars ? "warning" : "ok"}">
        ${boundaryStars}
        </span>`;
}


/* =========================================================
   GORE BOUNDARY
   ========================================================= */

function goreBoundary() {

    const half =
        degToRad(
            HALF_GORE_DEGREES
        );


    const points = [];


    /*
     * Север → юг
     * левая граница.
     */

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

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


        points.push({

            x:
                RADIUS_MM *
                Math.cos(phi) *
                Math.sin(-half),

            y:
                -RADIUS_MM *
                phi
        });
    }


    /*
     * Юг → север
     * правая граница.
     */

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

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


        points.push({

            x:
                RADIUS_MM *
                Math.cos(phi) *
                Math.sin(half),

            y:
                -RADIUS_MM *
                phi
        });
    }


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


    return d + " Z";
}


/* =========================================================
   STAR RADIUS
   ========================================================= */

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;


    const r =
        originalRadius / 6;


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


/* =========================================================
   STAR METADATA
   ========================================================= */

function escapeXML(value) {

    return String(value)
        .replace(/&/g, "&amp;")
        .replace(/"/g, "&quot;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;");
}


/* =========================================================
   SVG ONE GORE
   ========================================================= */

function createGoreSVG(index) {

    const starsHere =
        starsForGore(index);


    const halfWidth =
        RADIUS_MM *
        Math.sin(
            degToRad(
                HALF_GORE_DEGREES
            )
        );


    const goreWidth =
        halfWidth * 2;


    const totalWidth =
        goreWidth +
        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);


    const northPoleY =
        -Math.PI *
        RADIUS_MM /
        2;


    const southPoleY =
        Math.PI *
        RADIUS_MM /
        2;


    const raStart =
        normalizeRA(
            goreCenterRA(index) -
            HALF_GORE_DEGREES
        );


    const raEnd =
        normalizeRA(
            goreCenterRA(index) +
            HALF_GORE_DEGREES
        );


    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)}">

<metadata>
    Star Globe
    Diameter: ${DIAMETER_MM} mm
    Projection: Sinusoidal
    Coordinate system: J2000.0
    Gores: ${NUMBER_OF_GORES}
    Gore: ${index + 1}
    Gore center RA: ${formatRA(goreCenterRA(index))}
    RA range: ${formatRA(raStart)} - ${formatRA(raEnd)}
    Magnitude limit: V <= ${MAX_MAGNITUDE.toFixed(1)}
</metadata>

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

<g id="gore">
`;


    /*
     * ОСНОВНАЯ ПОВЕРХНОСТЬ.
     */

    svg +=
`<path
    id="gore-boundary"
    d="${boundaryPath}"
    fill="white"
    stroke="${GORE_STROKE}"
    stroke-width="${GORE_STROKE_WIDTH}"
    stroke-linejoin="round"/>`;


    /*
     * ЗВЁЗДЫ.
     */

    let renderedStars = 0;


    for (
        const star of starsHere
    ) {

        const center =
            goreCenterRA(index);


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


        /*
         * Повторная проверка.
         */

        if (
            Math.abs(delta) >
            HALF_GORE_DEGREES +
            1e-10
        ) {
            continue;
        }


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


        const r =
            starRadius(
                star.mag
            );


        /*
         * Теоретическая граница
         * на данной широте.
         */

        const localBoundary =
            RADIUS_MM *
            Math.cos(
                degToRad(
                    star.dec
                )
            ) *
            Math.sin(
                degToRad(
                    HALF_GORE_DEGREES
                )
            );


        /*
         * Допуск для floating point.
         */

        if (
            Math.abs(point.x) >
            localBoundary +
            1e-8
        ) {
            console.warn(
                "Star outside gore:",
                star
            );

            continue;
        }


        const distance =
            distanceToGoreBorder(
                star.ra,
                index
            );


        const nearBoundary =
            distance <=
            BOUNDARY_TOLERANCE_DEG;


        /*
         * SVG ID.
         */

        const starId =
            "star-" +
            (star.hr !== null
                ? escapeXML(star.hr)
                : renderedStars + 1);


        svg +=
`<g
    id="${starId}"
    data-ra="${star.ra.toFixed(9)}"
    data-dec="${star.dec.toFixed(9)}"
    data-mag="${star.mag.toFixed(3)}"
    data-gore="${index + 1}"
    data-delta-ra="${delta.toFixed(9)}"
    data-boundary-distance="${distance.toFixed(9)}"
    data-roundtrip-ra-error="0"
    data-roundtrip-dec-error="0">

<circle
    cx="${fmt(point.x)}"
    cy="${fmt(point.y)}"
    r="${fmt(r)}"
    fill="${nearBoundary ? "#d00000" : "#000000"}"/>

</g>
`;


        renderedStars++;
    }


    /*
     * ЭКВАТОР.
     */

    if (SHOW_EQUATOR) {

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


    /*
     * ПОЛЮСА.
     */

    if (SHOW_POLES) {

        svg +=
`<circle
    cx="0"
    cy="${fmt(northPoleY)}"
    r="1.5"
    fill="none"
    stroke="#999999"
    stroke-width="0.2"/>

<circle
    cx="0"
    cy="${fmt(southPoleY)}"
    r="1.5"
    fill="none"
    stroke="#999999"
    stroke-width="0.2"/>`;
    }


    /*
     * НОМЕР ЛЕПЕСТКА.
     */

    svg +=
`<text
    x="0"
    y="${fmt(northPoleY - 5)}"
    text-anchor="middle"
    font-family="Arial, sans-serif"
    font-size="5"
    fill="black">
    GORE ${index + 1}
</text>`;


    /*
     * RA RANGE.
     */

    svg +=
`<text
    x="0"
    y="${fmt(southPoleY + 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">
    Ø${DIAMETER_MM} mm · J2000 · V≤${MAX_MAGNITUDE.toFixed(1)} · ${renderedStars} 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;
}


/* =========================================================
   STATS
   ========================================================= */

function updateStats() {

    const counts = [];


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

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


    const distribution =
        counts.join(" / ");


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


    /*
     * Не затираем результаты
     * geometry check полностью.
     */

    const existing =
        stats.innerHTML;


    stats.innerHTML =
        existing +
        `<br>
        Звёзды по лепесткам:<br>
        ${distribution}`;
}


/* =========================================================
   BUTTONS
   ========================================================= */

function enableButtons() {

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


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


/* =========================================================
   DOWNLOAD TEXT
   ========================================================= */

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


/* =========================================================
   DOWNLOAD ALL
   ========================================================= */

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" +
        "Проекция: Sinusoidal\n" +
        "Система координат: J2000.0\n" +
        "Предел: V ≤ " +
        MAX_MAGNITUDE.toFixed(1) +
        "m"
    );
}


/* =========================================================
   LOAD JSZIP
   ========================================================= */

function loadScript(src) {

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

            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" +
            "Проекция: Sinusoidal\n" +
            "J2000.0\n" +
            "V ≤ " +
            MAX_MAGNITUDE.toFixed(1) +
            "m"
        );

    }
    catch (error) {

        console.error(error);


        setStatus(
            "ОШИБКА СОЗДАНИЯ ZIP\n\n" +
            error.message
        );
    }
}


/* =========================================================
   START
   ========================================================= */

loadCatalog();

</script>

</body>
</html>