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


<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">

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

<style>
    body {
        margin: 0;
        padding: 24px;
        font-family: system-ui, sans-serif;
        background: #eeeeee;
        color: #222;
    }

    h1 {
        font-size: 24px;
        margin: 0 0 12px;
    }

    .panel {
        background: white;
        padding: 18px;
        border-radius: 8px;
        max-width: 900px;
    }

    button {
        font-size: 15px;
        padding: 9px 14px;
        margin: 4px;
        cursor: pointer;
    }

    input {
        width: 70px;
        padding: 6px;
        font-size: 15px;
    }

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

    #preview {
        margin-top: 20px;
        background: white;
        padding: 15px;
        overflow: auto;
    }

    .gorePreview {
        margin: 10px;
        border: 1px solid #ddd;
        display: inline-block;
        background: white;
    }

    .small {
        color: #666;
        font-size: 13px;
    }

    label {
        display: inline-block;
        margin: 5px 15px 5px 0;
    }
</style>
</head>

<body>

<div class="panel">

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

<p>
    Hipparcos · J2000.0 · Johnson V &lt; 5.0 · 24 лепестка
</p>

<div>

<label>
    Диаметр шара:
    <input id="diameter" type="number" value="1000" min="100">
    мм
</label>

<label>
    Мин. радиус звезды:
    <input id="starMin" type="number" value="0.45" step="0.05">
    мм
</label>

<label>
    Макс. радиус звезды:
    <input id="starMax" type="number" value="2.10" step="0.05">
    мм
</label>

</div>

<div>

<label>
    Толщина контура:
    <input id="edgeWidth" type="number" value="0.25" step="0.05">
    мм
</label>

<label>
    Контур:
    <input id="edgeColor" type="text" value="#c8c8c8">
</label>

</div>

<br>

<button id="loadBtn">
    1. Загрузить Hipparcos
</button>

<button id="generateBtn" disabled>
    2. Построить 24 SVG
</button>

<button id="downloadBtn" disabled>
    3. Скачать 24 SVG
</button>

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

</div>

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


<script>
"use strict";


/* =========================================================
   НАСТРОЙКИ
   ========================================================= */

const NUM_GORES = 24;

const CATALOG_EPOCH = 1991.25;
const TARGET_EPOCH  = 2000.0;

const MAG_LIMIT = 5.0;


/*
   Оригинальный Hipparcos Main Catalogue.

   CDS/VizieR:
   I/239/hip_main

   Это полный каталог, а не предварительно
   отфильтрованный набор.
*/
const HIP_URL =
    "https://cdsarc.cds.unistra.fr/ftp/cats/I/239/hip_main.dat";


/* =========================================================
   ГЛОБАЛЬНЫЕ ДАННЫЕ
   ========================================================= */

let DIAMETER_MM = 1000;
let R = 500;

let STAR_MIN = 0.45;
let STAR_MAX = 2.10;

let EDGE_WIDTH = 0.25;
let EDGE_COLOR = "#c8c8c8";

let stars = [];

let gores = [];


/* =========================================================
   МАТЕМАТИКА
   ========================================================= */

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

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


/*
    Приведение угла к [-180, +180)
*/
function normalizeDeg(x) {

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

    return x;
}


/*
    Ограничение значения
*/
function clamp(x, a, b) {
    return Math.max(a, Math.min(b, x));
}


/* =========================================================
   ПЕРЕНОС HIPPARCOS J1991.25 -> J2000
   =========================================================

   В Hipparcos:

       RA       — градусы
       DE       — градусы
       pmRA     — mas/year = mu_alpha * cos(delta)
       pmDE     — mas/year

   Поэтому:

       dRA =
           pmRA * dt /
           (3.6e6 * cos(delta))

       dDE =
           pmDE * dt /
           3.6e6

   Здесь dt = 8.75 лет.

   Это перенос координат по собственному движению.
*/


function moveToJ2000(raDeg, decDeg, pmRA, pmDE) {

    const dt =
        TARGET_EPOCH - CATALOG_EPOCH;

    let decRad =
        degToRad(decDeg);

    let cosDec =
        Math.cos(decRad);

    if (Math.abs(cosDec) < 1e-12) {
        cosDec = 1e-12;
    }

    const dRaDeg =
        pmRA * dt /
        (3600000 * cosDec);

    const dDecDeg =
        pmDE * dt /
        3600000;

    return {

        ra:
            normalizeDeg(
                raDeg + dRaDeg
            ),

        dec:
            clamp(
                decDeg + dDecDeg,
                -90,
                90
            )
    };
}


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

   Радиус зависит от V magnitude.

   Разница в 5 magnitudes = 100x brightness.

   При этом самые слабые звёзды не становятся
   настолько маленькими, чтобы исчезнуть при печати.
*/


function starRadius(mag) {

    const b =
        Math.pow(
            10,
            -0.4 * mag
        );

    const bBright =
        Math.pow(
            10,
            -0.4 * (-1.5)
        );

    const bFaint =
        Math.pow(
            10,
            -0.4 * MAG_LIMIT
        );

    let t =
        (b - bFaint) /
        (bBright - bFaint);

    t =
        clamp(t, 0, 1);

    /*
       Слегка усиливаем маленькие звёзды.
    */

    t =
        Math.pow(t, 0.42);

    return (
        STAR_MIN +
        t * (STAR_MAX - STAR_MIN)
    );
}


/* =========================================================
   ГЕОМЕТРИЯ 24-ЛЕПЕСТКОВ
   =========================================================

   Каждый gore имеет центральный меридиан:

       -172.5
       -157.5
       ...
       +172.5

   Полуширина:

       7.5°

   Проекция:

       x = R * lambda * cos(phi)

       y = R * phi

   Это развёртка поверхности шара
   вдоль меридианов.
*/


function goreCenterLongitude(index) {

    return (
        -180 +
        (index + 0.5) *
        (360 / NUM_GORES)
    );
}


function projectStar(star, goreIndex) {

    const center =
        goreCenterLongitude(
            goreIndex
        );

    const deltaLon =
        normalizeDeg(
            star.ra -
            center
        );

    const half =
        180 / NUM_GORES / 2;

    /*
       На точном шве выбираем только один
       из соседних лепестков.
    */

    const EPS =
        1e-9;

    if (
        deltaLon < -half - EPS ||
        deltaLon >  half + EPS
    ) {
        return null;
    }

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

    if (
        Math.abs(deltaLon - half) < EPS &&
        goreIndex < NUM_GORES - 1
    ) {
        return null;
    }

    const lambda =
        degToRad(deltaLon);

    const phi =
        degToRad(star.dec);

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

    const y =
        R * phi;

    return {
        x,
        y
    };
}


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

function goreBoundaryPath() {

    const half =
        degToRad(
            180 / NUM_GORES / 2
        );

    /*
       Шаг 0.25° даёт достаточно
       гладкую кривую для печати.
    */

    const STEP = 0.25;

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

    for (
        let latDeg = -90;
        latDeg <= 90.000001;
        latDeg += STEP
    ) {

        const lat =
            degToRad(
                Math.min(
                    latDeg,
                    90
                )
            );

        const y =
            R * lat;

        const x =
            R *
            half *
            Math.cos(lat);

        left.push({
            x: -x,
            y
        });

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


    let d = "";


    /*
       Южный полюс
    */

    d +=
        `M 0 ${(-left[0].y).toFixed(5)} `;


    /*
       Левая граница: Ю -> С
    */

    for (const p of left) {

        d +=
            `L ${p.x.toFixed(5)} ` +
            `${(-p.y).toFixed(5)} `;
    }


    /*
       Правая граница: С -> Ю
    */

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

        const p =
            right[i];

        d +=
            `L ${p.x.toFixed(5)} ` +
            `${(-p.y).toFixed(5)} `;
    }

    d += "Z";

    return d;
}


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

   Экватор полезен как контрольная линия
   и одновременно облегчает сборку.

   Цвет такой же бледно-серый,
   но линия немного тоньше.
*/


function equatorPath() {

    const half =
        R *
        degToRad(
            180 / NUM_GORES / 2
        );

    return (
        `M ${(-half).toFixed(5)} 0 ` +
        `L ${half.toFixed(5)} 0`
    );
}


/* =========================================================
   ПОЛЮСЫ
   =========================================================

   Это маленькие поперечные линии у полюсов.

   Они не являются "клапанами".
   Это только тонкая контрольная разметка.
*/


function poleMarkPaths() {

    const half =
        R *
        degToRad(
            180 / NUM_GORES / 2
        );

    const poleLength =
        Math.max(
            0.8,
            half * 0.20
        );

    return {

        south:
            `M ${(-poleLength).toFixed(5)} ` +
            `${(R * Math.PI / 2).toFixed(5)} ` +
            `L ${poleLength.toFixed(5)} ` +
            `${(R * Math.PI / 2).toFixed(5)}`,

        north:
            `M ${(-poleLength).toFixed(5)} ` +
            `${(-R * Math.PI / 2).toFixed(5)} ` +
            `L ${poleLength.toFixed(5)} ` +
            `${(-R * Math.PI / 2).toFixed(5)}`
    };
}


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

function createGoreSVG(index) {

    const goreHeight =
        Math.PI * R;

    const halfWidth =
        R *
        degToRad(
            180 / NUM_GORES / 2
        );

    const goreWidth =
        2 * halfWidth;

    /*
       Небольшой внешний запас.
       Он НЕ является клапаном.
    */

    const margin = 4;

    const width =
        goreWidth + margin * 2;

    const height =
        goreHeight + margin * 2;


    let svg = "";

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

<!--
  Star globe gore ${String(index + 1).padStart(2, "0")}
  Diameter: ${DIAMETER_MM} mm
  Hipparcos V < 5.0
  Coordinates: J2000.0
  Projection: sinusoidal gore
-->

<rect
    x="${(-goreWidth/2-margin).toFixed(5)}"
    y="${(-goreHeight/2-margin).toFixed(5)}"
    width="${width.toFixed(5)}"
    height="${height.toFixed(5)}"
    fill="#ffffff"/>

<g transform="translate(0,0)">

`;


    /* =====================================================
       ЗВЁЗДЫ
       ===================================================== */

    for (const star of gores[index]) {

        const p =
            projectStar(
                star,
                index
            );

        if (!p) {
            continue;
        }

        const radius =
            starRadius(
                star.mag
            );

        svg +=
`<circle
    cx="${p.x.toFixed(5)}"
    cy="${(-p.y).toFixed(5)}"
    r="${radius.toFixed(5)}"
    fill="#000000"/>`;

    }


    /* =====================================================
       КОНТУР
       ===================================================== */

    svg +=
`
<path
    d="${goreBoundaryPath()}"
    fill="none"
    stroke="${EDGE_COLOR}"
    stroke-width="${EDGE_WIDTH}"
    stroke-linejoin="round"
    stroke-linecap="round"/>`;


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

    svg +=
`
<path
    d="${equatorPath()}"
    fill="none"
    stroke="${EDGE_COLOR}"
    stroke-width="${Math.min(
        EDGE_WIDTH,
        0.20
    )}"
    stroke-linecap="round"/>`;


    /* =====================================================
       ПОЛЮСЫ
       ===================================================== */

    const poles =
        poleMarkPaths();

    svg +=
`
<path
    d="${poles.south}"
    fill="none"
    stroke="${EDGE_COLOR}"
    stroke-width="${Math.min(
        EDGE_WIDTH,
        0.20
    )}"/>

<path
    d="${poles.north}"
    fill="none"
    stroke="${EDGE_COLOR}"
    stroke-width="${Math.min(
        EDGE_WIDTH,
        0.20
    )}"/>`;


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

    return svg;
}


/* =========================================================
   PARSE HIPPARCOS
   =========================================================

   Формат hip_main.dat:

   поля разделены |

   Нас интересуют:

   [1]  HIP
   [5]  Vmag
   [8]  RAdeg
   [9]  DEdeg
   [11] Plx
   [12] pmRA
   [13] pmDE

   Структура подтверждается загрузчиком
   Skyfield для того же каталога.
*/


function parseHipparcos(text) {

    const result = [];

    const lines =
        text.split(/\r?\n/);


    for (const line of lines) {

        if (!line.trim()) {
            continue;
        }


        const fields =
            line.split("|");


        if (fields.length < 14) {
            continue;
        }


        /*
           Пропускаем заголовочные/служебные строки.
        */

        const hip =
            Number(
                fields[1]?.trim()
            );

        const mag =
            Number(
                fields[5]?.trim()
            );

        const ra =
            Number(
                fields[8]?.trim()
            );

        const dec =
            Number(
                fields[9]?.trim()
            );

        const pmRA =
            Number(
                fields[12]?.trim()
            );

        const pmDE =
            Number(
                fields[13]?.trim()
            );


        if (
            !Number.isFinite(hip) ||
            !Number.isFinite(mag) ||
            !Number.isFinite(ra) ||
            !Number.isFinite(dec)
        ) {
            continue;
        }


        /*
           Только V < 5.0.

           Строго "ярче пятой величины",
           поэтому V=5.000 не включается.
        */

        if (!(mag < MAG_LIMIT)) {
            continue;
        }


        /*
           Если собственные движения отсутствуют,
           звезду всё равно можно оставить:
           её координата тогда остаётся
           практически исходной.

           Для ярких звёзд это редкий случай.
        */

        let ra2000 = ra;
        let dec2000 = dec;


        if (
            Number.isFinite(pmRA) &&
            Number.isFinite(pmDE)
        ) {

            const pos =
                moveToJ2000(
                    ra,
                    dec,
                    pmRA,
                    pmDE
                );

            ra2000 =
                pos.ra;

            dec2000 =
                pos.dec;
        }


        result.push({

            hip,

            mag,

            ra: ra2000,

            dec: dec2000,

            pmRA,

            pmDE
        });
    }


    return result;
}


/* =========================================================
   РАСКЛАДКА ПО 24 ЛЕПЕСТКАМ
   ========================================================= */

function distributeStars() {

    gores =
        Array.from(
            {
                length:
                    NUM_GORES
            },
            () => []
        );


    for (const star of stars) {

        let assigned = false;


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

            const p =
                projectStar(
                    star,
                    i
                );


            if (p !== null) {

                gores[i].push(
                    star
                );

                assigned = true;

                break;
            }
        }


        /*
           Полюс.

           На самом полюсе x=0 и звезда
           геометрически принадлежит всем лепесткам.

           Для печати достаточно одного экземпляра
           на первом лепестке.
        */

        if (
            !assigned &&
            Math.abs(
                Math.abs(star.dec) - 90
            ) < 1e-7
        ) {

            gores[0].push(star);
        }
    }
}


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

async function loadCatalog() {

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


    try {

        status.textContent =
            "Загружаю полный Hipparcos Main Catalogue...\n" +
            HIP_URL;


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


        if (!response.ok) {

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


        const text =
            await response.text();


        status.textContent =
            "Каталог загружен.\n" +
            "Разбираю записи V < 5.0...";


        stars =
            parseHipparcos(
                text
            );


        distributeStars();


        let minMag =
            Infinity;

        let maxMag =
            -Infinity;


        for (const star of stars) {

            minMag =
                Math.min(
                    minMag,
                    star.mag
                );

            maxMag =
                Math.max(
                    maxMag,
                    star.mag
                );
        }


        let distribution = "";

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

            distribution +=
                `\n  ${String(i+1).padStart(2,"0")}: ` +
                `${gores[i].length} звёзд`;
        }


        status.textContent =
            "Готово.\n\n" +

            "Каталог: Hipparcos Main Catalogue\n" +
            "Исходная эпоха: J1991.25\n" +
            "Целевая эпоха: J2000.0\n" +
            "Фотометрия: Johnson V\n" +
            "Условие: V < 5.0\n" +

            "Звёзд: " +
            stars.length +
            "\n" +

            "V диапазон: " +
            minMag.toFixed(2) +
            " ... " +
            maxMag.toFixed(2) +

            "\n\nРаспределение:" +
            distribution;


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


    } catch (error) {

        console.error(
            error
        );


        status.textContent =
            "ОШИБКА:\n\n" +
            error.message +
            "\n\n" +

            "Если HTML открыт через file://,\n" +
            "запусти его через локальный HTTP-сервер.\n\n" +

            "Например:\n" +
            "python -m http.server 8000\n\n" +
            "и открой:\n" +
            "http://localhost:8000/";
    }
}


/* =========================================================
   ПАРАМЕТРЫ ИЗ UI
   ========================================================= */

function readSettings() {

    DIAMETER_MM =
        Number(
            document.getElementById(
                "diameter"
            ).value
        );

    R =
        DIAMETER_MM / 2;


    STAR_MIN =
        Number(
            document.getElementById(
                "starMin"
            ).value
        );


    STAR_MAX =
        Number(
            document.getElementById(
                "starMax"
            ).value
        );


    EDGE_WIDTH =
        Number(
            document.getElementById(
                "edgeWidth"
            ).value
        );


    EDGE_COLOR =
        document.getElementById(
            "edgeColor"
        ).value;
}


/* =========================================================
   ПРЕДПРОСМОТР
   ========================================================= */

function generatePreview() {

    readSettings();


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


    preview.innerHTML = "";


    /*
       Показываем первые несколько
       лепестков в браузере.

       Все 24 SVG всё равно генерируются.
    */

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

        const svg =
            createGoreSVG(i);


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

        div.className =
            "gorePreview";


        /*
           SVG имеет реальные физические
           размеры, но для просмотра
           ограничиваем его CSS.
        */

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


        const url =
            URL.createObjectURL(
                blob
            );


        div.innerHTML =
            `<div style="
                padding:5px;
                font-size:12px;
                text-align:center;
                color:#666;
            ">
                Лепесток ${i + 1}
            </div>
            <img
                src="${url}"
                style="
                    max-height:420px;
                    max-width:170px;
                    display:block;
                ">`;


        preview.appendChild(
            div
        );
    }


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


/* =========================================================
   СКАЧИВАНИЕ ОДНОГО SVG
   ========================================================= */

function downloadFile(
    filename,
    content
) {

    const blob =
        new Blob(
            [content],
            {
                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
            ),
        2000
    );
}


/* =========================================================
   СКАЧАТЬ 24 ОТДЕЛЬНЫХ SVG
   ========================================================= */

async function downloadAll() {

    readSettings();


    if (!gores.length) {
        return;
    }


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


    status.textContent =
        "Генерирую 24 SVG...";


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

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

        const svg =
            createGoreSVG(i);


        const number =
            String(
                i + 1
            ).padStart(
                2,
                "0"
            );


        downloadFile(
            `star_globe_gore_${number}.svg`,
            svg
        );


        await new Promise(
            resolve =>
                setTimeout(
                    resolve,
                    150
                )
        );
    }


    status.textContent =
        "Готово.\n\n" +
        "Скачаны файлы:\n" +

        Array.from(
            {
                length:
                    NUM_GORES
            },
            (_, i) =>
                `star_globe_gore_${String(i+1).padStart(2,"0")}.svg`
        ).join("\n");
}


/* =========================================================
   КНОПКИ
   ========================================================= */

document
    .getElementById(
        "loadBtn"
    )
    .addEventListener(
        "click",
        loadCatalog
    );


document
    .getElementById(
        "generateBtn"
    )
    .addEventListener(
        "click",
        generatePreview
    );


document
    .getElementById(
        "downloadBtn"
    )
    .addEventListener(
        "click",
        downloadAll
    );


</script>

</body>
</html>