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


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

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

    button:hover {
        background: #eeeeee;
    }

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

    #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;
    }
</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 onclick="downloadAll()">
    Скачать 24 SVG
</button><button onclick="downloadZIP()">
    Скачать ZIP
</button><div id="status">
    Загрузка каталога...
</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; // 15°

const MAX_MAGNITUDE = 5.0;


/*
 * Ширина лепестка по экватору.
 *
 * Длина окружности = πD
 *
 * 3141.592 / 24 = 130.900 мм
 */

const EQUATOR_WIDTH =
    Math.PI * DIAMETER_MM / NUMBER_OF_GORES;


/*
 * Полная длина от полюса до полюса.
 */

const MERIDIAN_LENGTH =
    Math.PI * RADIUS_MM;


/*
 * Припуск под клапаны.
 */

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 = [];


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

async function loadCatalog() {

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

    try {

        const response =
            await fetch(CATALOG_URL);

        if (!response.ok) {
            throw new Error(
                "HTTP " + response.status
            );
        }

        const data =
            await response.json();

        stars = [];

        for (const s of data) {

            const mag =
                Number(s.Vmag);

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

            if (mag > MAX_MAG) {
                continue;
            }


            /*
             * RA.
             */

            const rah = Number(s.RAh);
            const ram = Number(s.RAm);
            const ras = Number(s.RAs);

            if (
                !Number.isFinite(rah) ||
                !Number.isFinite(ram) ||
                !Number.isFinite(ras)
            ) {
                continue;
            }

            const ra =
                15 *
                (
                    rah +
                    ram / 60 +
                    ras / 3600
                );


            /*
             * DECLINATION.
             *
             * В BSC знак склонения находится
             * отдельно от градусов.
             */

            let deg =
                Number(s.DEd);

            const min =
                Number(s.DEm);

            const sec =
                Number(s.DEs);


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


            const absDeg =
                Math.abs(deg);

            const absMin =
                Number.isFinite(min) ? Math.abs(min) : 0;

            const absSec =
                Number.isFinite(sec) ? Math.abs(sec) : 0;


            let dec =
                absDeg +
                absMin / 60 +
                absSec / 3600;


            /*
             * Определяем знак.
             *
             * В разных выгрузках BSC знак может
             * находиться в DE_ либо быть закодирован
             * в самом DED.
             */

            let negative = false;

            const signFields = [
                s.DE_,
                s.DEsign,
                s.decSign,
                s.DE_SIGN
            ];

            for (const field of signFields) {

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

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

                    if (
                        value === "-" ||
                        value === "−" ||
                        value === "S"
                    ) {
                        negative = true;
                    }

                    if (
                        value === "+" ||
                        value === "N"
                    ) {
                        negative = false;
                    }
                }
            }


            /*
             * Если градусы уже отрицательные.
             */

            if (deg < 0) {
                negative = true;
            }


            if (negative) {
                dec = -dec;
            }


            /*
             * Защита от ошибок каталога.
             */

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


            stars.push({
                ra: ra,
                dec: dec,
                mag: mag
            });
        }


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


        renderPreview();

    } catch (error) {

        console.error(error);

        setStatus(
            "Ошибка загрузки каталога:\n\n" +
            error.message +
            "\n\n" +
            "Если браузер блокирует загрузку из файла, " +
            "запустите HTML через локальный сервер."
        );
    }
}


/* =========================================================
   УГЛЫ
   ========================================================= */

function degToRad(deg) {
    return deg * Math.PI / 180;
}

function radToDeg(rad) {
    return rad * 180 / Math.PI;
}


/*
 * Приведение угла к диапазону -180...+180.
 */

function signedAngle(deg) {

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

    return x;
}


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

   Чем ярче звезда, тем крупнее чёрная точка.

   Важно:
   это не реальный угловой диаметр звезды,
   а графическое представление её блеска.
   ========================================================= */

function starRadius(mag) {

    /*
     * 0m  → около 3.6 мм
     * 1m  → около 3.0 мм
     * 2m  → около 2.5 мм
     * 3m  → около 2.0 мм
     * 4m  → около 1.5 мм
     * 5m  → около 1.1 мм
     */

    const r =
        1.0 +
        Math.pow(
            Math.max(0, 5 - mag) / 5,
            0.65
        ) * 2.6;

    return Math.max(
        0.9,
        Math.min(3.6, r)
    );
}


/* =========================================================
   ГЕОМЕТРИЯ ЛЕПЕСТКА
   =========================================================

   Используем "апельсиновую" развертку.

   Координаты:

       северный полюс
             *
            / \
           /   \
          /     \
         /       \
        /         \
       *-----------*
             экватор
        \         /
         \       /
          \     /
           \   /
            \ /
             *
       южный полюс


   По меридиану расстояния сохраняются:
       y = R * latitude

   Поперечная координата:
       x = R * cos(latitude) * sin(lambda)

   Это стандартная практическая конструкция
   узкого сферического лепестка.
   ========================================================= */

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 goreCenterRA(index) {

    return index * GORE_DEGREES;
}


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

function starsForGore(index) {

    const center =
        goreCenterRA(index);

    const half =
        GORE_DEGREES / 2;


    return stars.filter(star => {

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

        return (
            delta >= -half - 1e-9 &&
            delta <= half + 1e-9
        );
    });
}


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

function goreBoundary(index) {

    const half =
        degToRad(
            GORE_DEGREES / 2
        );


    const left = [];
    const right = [];


    /*
     * Север → юг.
     */

    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;


        left.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;


        right.push({
            x: x,
            y: y
        });
    }


    return left.concat(right);
}


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

function boundaryPath(points) {

    let d = "";

    points.forEach(
        (p, i) => {

            if (i === 0) {

                d +=
                    "M " +
                    fmt(p.x) +
                    " " +
                    fmt(p.y);

            } else {

                d +=
                    " L " +
                    fmt(p.x) +
                    " " +
                    fmt(p.y);
            }
        }
    );

    d += " Z";

    return d;
}


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

   Клапаны идут вдоль двух длинных сторон.

   Они не являются частью точной поверхности:
   это припуск для склейки.
   ========================================================= */

function tabPath(side) {

    const half =
        degToRad(
            GORE_DEGREES / 2
        );


    const tab = degToRad(
        GORE_DEGREES / 2
    );


    /*
     * Используем 8 мм наружу.
     */

    const TAB = TAB_MM;


    const pts = [];


    for (
        let i = 0;
        i <= 180;
        i += 2
    ) {

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


        let lambda;

        if (side === "left") {
            lambda = -half;
        } else {
            lambda = half;
        }


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


        const y =
            RADIUS_MM * phi;


        /*
         * Нормаль наружу в плоскости.
         */

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


        pts.push({
            x: x + sign * TAB,
            y: y
        });
    }


    let d = "";


    /*
     * Не используем полноценную заливку клапана,
     * а строим его контур.
     */

    pts.forEach(
        (p, i) => {

            if (i === 0) {

                d +=
                    "M " +
                    fmt(p.x) +
                    " " +
                    fmt(p.y);

            } else {

                d +=
                    " L " +
                    fmt(p.x) +
                    " " +
                    fmt(p.y);
            }
        }
    );


    return d;
}


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

function createGoreSVG(index) {

    const starsHere =
        starsForGore(index);


    /*
     * Геометрические размеры.
     */

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


    const width =
        halfWidth * 2 +
        TAB_MM * 2 +
        MARGIN_MM * 2;


    const height =
        MERIDIAN_LENGTH +
        MARGIN_MM * 2;


    const ox =
        width / 2;


    const oy =
        height / 2;


    let svg = "";


    svg +=
`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
     width="${fmt(width)}mm"
     height="${fmt(height)}mm"
     viewBox="${fmt(-width/2)} ${fmt(-height/2)} ${fmt(width)} ${fmt(height)}">

<rect
    x="${fmt(-width/2)}"
    y="${fmt(-height/2)}"
    width="${fmt(width)}"
    height="${fmt(height)}"
    fill="white"/>

`;


    /*
     * Смещаем всё содержимое,
     * чтобы вокруг был запас.
     */

    svg +=
`<g transform="translate(0 0)">
`;


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

    const boundary =
        goreBoundary(index);


    const path =
        boundaryPath(boundary);


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


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

    for (const star of starsHere) {

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


        /*
         * Звёзды точно на линии разреза
         * не рисуем дважды.
         *
         * Разделяем их по номеру лепестка.
         */

        if (
            Math.abs(
                Math.abs(delta) -
                GORE_DEGREES / 2
            ) < 1e-7
        ) {

            if (index % 2 === 1) {
                continue;
            }
        }


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


        const r =
            starRadius(star.mag);


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


    /*
     * Пунктирная центральная линия.
     *
     * Она используется только как ориентир.
     */

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


    /*
     * Экватор.
     *
     * Очень тонкая линия для совмещения.
     */

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


    /*
     * Отметка северного полюса.
     */

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


    /*
     * Отметка южного полюса.
     */

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


    /*
     * Клапаны.
     *
     * Рисуем их только линиями,
     * чтобы при вырезании было понятно,
     * где сгибать.
     */

    const leftTab =
        tabPath("left");


    const rightTab =
        tabPath("right");


    svg +=
`<path
    d="${leftTab}"
    fill="none"
    stroke="black"
    stroke-width="0.25"
    stroke-dasharray="3,2"/>
`;


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


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

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


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

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

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


    svg +=
`<text
    x="0"
    y="${fmt(MERIDIAN_LENGTH/2 + 7)}"
    text-anchor="middle"
    font-family="Arial"
    font-size="3.5"
    fill="black">
RA ${formatRA(ra1)} — ${formatRA(ra2)}
</text>
`;


    svg +=
`</g>
</svg>`;


    return svg;
}


/* =========================================================
   НОРМАЛИЗАЦИЯ RA
   ========================================================= */

function normalizeRA(ra) {

    ra =
        ra % 360;

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

    return ra;
}


function formatRA(deg) {

    const hours =
        normalizeRA(deg) / 15;

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


/* =========================================================
   ОКРУГЛЕНИЕ
   ========================================================= */

function fmt(n) {

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


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

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


/* =========================================================
   СКАЧАТЬ 24 ФАЙЛА
   ========================================================= */

async function downloadAll() {

    if (!stars.length) {

        await loadCatalog();

        if (!stars.length) {
            return;
        }
    }


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


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

        const svg =
            createGoreSVG(i);


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


        downloadText(
            svg,
            name
        );


        await sleep(200);
    }


    setStatus(
        "Готово.\n\n" +
        "Создано: 24 SVG\n" +
        "Диаметр: 1000 мм\n" +
        "Система: J2000\n" +
        "Предел: V ≤ 5.0m"
    );
}


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

async function downloadZIP() {

    if (!stars.length) {

        await loadCatalog();

        if (!stars.length) {
            return;
        }
    }


    setStatus(
        "Создание ZIP..."
    );


    if (!window.JSZip) {

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


    const zip =
        new JSZip();


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

        const svg =
            createGoreSVG(i);


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


        zip.file(
            name,
            svg
        );
    }


    const blob =
        await zip.generateAsync({
            type: "blob"
        });


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


    setStatus(
        "ZIP-файл готов."
    );
}


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

function renderPreview() {

    if (!stars.length) {
        return;
    }


    const svg =
        createGoreSVG(0);


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


    container.innerHTML =
        svg.replace(
            "<svg ",
            '<svg '
        );
}


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

function sleep(ms) {

    return new Promise(
        resolve =>
            setTimeout(
                resolve,
                ms
            )
    );
}


function loadScript(src) {

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

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

            script.src = src;

            script.onload =
                resolve;

            script.onerror =
                reject;

            document.head.appendChild(
                script
            );
        }
    );
}


function setStatus(text) {

    document.getElementById(
        "status"
    ).textContent = text;
}


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

loadCatalog();

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