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


<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">

<title>Celestial Globe 1500mm — 24 Orange Peel Gores</title>

<style>
body {
    font-family: Arial, sans-serif;
    background: #10131a;
    color: white;
    margin: 40px;
}

button {
    background: #2878ff;
    color: white;
    border: 0;
    padding: 15px 25px;
    font-size: 18px;
    border-radius: 8px;
    cursor: pointer;
}

button:hover {
    background: #4b91ff;
}

#status {
    margin-top: 25px;
    white-space: pre-line;
    font-family: monospace;
    color: #bcd7ff;
}

h1 {
    font-size: 28px;
}

.info {
    max-width: 800px;
    line-height: 1.5;
}
</style>
</head>

<body>

<h1>CELESTIAL GLOBE Ø1500 mm</h1>

<div class="info">
    <p>
        24 классических orange-peel лепестка.
        HYG 4.1. Только звёзды с <b>mag &lt; 5.0</b>.
    </p>

    <p>
        Нажми кнопку ниже. Браузер скачает каталог HYG,
        рассчитает положение звёзд и создаст SVG 1:1 в миллиметрах.
    </p>
</div>

<button onclick="generate()">СОЗДАТЬ SVG</button>

<div id="status">Готов.</div>


<script>

// ============================================================
// SETTINGS
// ============================================================

const DIAMETER_MM = 1500.0;
const RADIUS_MM = DIAMETER_MM / 2.0;

const NUM_GORES = 24;

const GORE_WIDTH_DEG = 360.0 / NUM_GORES;
const HALF_GORE_DEG = GORE_WIDTH_DEG / 2.0;

const MAG_LIMIT = 5.0;


// HYG 4.1
const HYG_URL =
"https://raw.githubusercontent.com/astronexus/HYG-Database/main/hyg/CURRENT/hyg_v41.csv";


// Звёзды
const STAR_MIN_MM = 0.8;
const STAR_MAX_MM = 4.5;


// Разрешение границ
const LAT_STEPS = 360;


// Раскладка
const COLUMNS = 6;
const ROWS = 4;

const GORE_GAP_X = 100.0;
const GORE_GAP_Y = 180.0;

const PAGE_MARGIN = 150.0;


// ============================================================
// DIMENSIONS
// ============================================================

// Максимальная ширина orange-peel gore на экваторе.
//
// Для синусоидальной orange-peel проекции:
//
// x = R * lambda * cos(phi)
//
// На экваторе:
//
// width = 2 * R * lambda
//
// lambda = 7.5 degrees
//
// ============================================================

const GORE_EQUATOR_WIDTH =
    2.0 *
    RADIUS_MM *
    Math.sin(
        HALF_GORE_DEG * Math.PI / 180.0
    );


// Высота от южного до северного полюса.

const GORE_HEIGHT =
    Math.PI * RADIUS_MM;


const CELL_WIDTH =
    GORE_EQUATOR_WIDTH + GORE_GAP_X;

const CELL_HEIGHT =
    GORE_HEIGHT + GORE_GAP_Y;


const SVG_WIDTH =
    2.0 * PAGE_MARGIN +
    COLUMNS * CELL_WIDTH;


const SVG_HEIGHT =
    2.0 * PAGE_MARGIN +
    ROWS * CELL_HEIGHT +
    200.0;


// ============================================================
// STATUS
// ============================================================

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


// ============================================================
// MATH
// ============================================================

function radians(deg) {
    return deg * Math.PI / 180.0;
}


function normalize360(deg) {
    return ((deg % 360.0) + 360.0) % 360.0;
}


function normalize180(deg) {
    return (
        ((deg + 180.0) % 360.0 + 360.0) % 360.0
    ) - 180.0;
}


// ============================================================
// RA
// ============================================================

function raHoursToDegrees(raHours) {
    return normalize360(
        raHours * 15.0
    );
}


// ============================================================
// GORE
// ============================================================

function goreCenter(goreIndex) {

    return goreIndex *
           GORE_WIDTH_DEG;
}


function getGore(raDeg) {

    const ra = normalize360(raDeg);

    return Math.floor(
        (
            ra +
            HALF_GORE_DEG
        ) /
        GORE_WIDTH_DEG
    ) % NUM_GORES;
}


function localLongitude(
    raDeg,
    goreIndex
) {

    return normalize180(
        raDeg -
        goreCenter(goreIndex)
    );
}


// ============================================================
// CLASSICAL ORANGE-PEEL PROJECTION
// ============================================================
//
// latitude = -90 ... +90
// longitude = -7.5 ... +7.5
//
// x = R * lambda * cos(latitude)
// y = R * latitude
//
// ВАЖНО:
//
// ЭТА ФУНКЦИЯ ИСПОЛЬЗУЕТСЯ И ДЛЯ ГРАНИЦ,
// И ДЛЯ ЗВЁЗД.
//
// ============================================================

function orangePeel(
    latitudeDeg,
    longitudeDeg
) {

    const phi =
        radians(latitudeDeg);

    const lambda =
        radians(longitudeDeg);


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


    const y =
        RADIUS_MM *
        phi;


    return {
        x: x,
        y: y
    };
}


// ============================================================
// LAYOUT
// ============================================================

function layoutOffset(goreIndex) {

    const col =
        goreIndex % COLUMNS;

    const row =
        Math.floor(
            goreIndex / COLUMNS
        );


    return {

        x:
            PAGE_MARGIN +
            col * CELL_WIDTH +
            GORE_EQUATOR_WIDTH / 2.0,

        y:
            PAGE_MARGIN +
            row * CELL_HEIGHT +
            GORE_HEIGHT / 2.0
    };
}


// ============================================================
// SVG ESCAPE
// ============================================================

function esc(text) {

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


// ============================================================
// PATH
// ============================================================

function pointsToPath(
    points,
    close
) {

    if (!points.length)
        return "";


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


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

        d +=
            " L " +
            points[i].x.toFixed(4) +
            " " +
            points[i].y.toFixed(4);
    }


    if (close)
        d += " Z";


    return d;
}


// ============================================================
// GORE OUTLINE
// ============================================================

function createGorePath(
    goreIndex
) {

    const offset =
        layoutOffset(goreIndex);


    let points = [];


    // LEFT EDGE

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

        const lat =
            -90.0 +
            180.0 *
            i /
            LAT_STEPS;


        const p =
            orangePeel(
                lat,
                -HALF_GORE_DEG
            );


        points.push({

            x:
                offset.x + p.x,

            y:
                offset.y - p.y
        });
    }


    // RIGHT EDGE

    let right = [];


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

        const lat =
            -90.0 +
            180.0 *
            i /
            LAT_STEPS;


        const p =
            orangePeel(
                lat,
                HALF_GORE_DEG
            );


        right.push({

            x:
                offset.x + p.x,

            y:
                offset.y - p.y
        });
    }


    // Возвращаем правую сторону
    // от северного полюса к южному.

    right.reverse();

    points =
        points.concat(right);


    return pointsToPath(
        points,
        true
    );
}


// ============================================================
// STAR SIZE
// ============================================================

function starDiameter(mag) {

    let t =
        (5.0 - mag) / 6.5;


    t =
        Math.max(
            0.0,
            Math.min(
                1.0,
                t
            )
        );


    t =
        Math.pow(
            t,
            0.55
        );


    return (
        STAR_MIN_MM +
        t *
        (
            STAR_MAX_MM -
            STAR_MIN_MM
        )
    );
}


// ============================================================
// CSV PARSER
// ============================================================

function parseCSV(text) {

    const rows = [];

    let row = [];
    let field = "";

    let insideQuotes = false;


    for (
        let i = 0;
        i < text.length;
        i++
    ) {

        const c = text[i];


        if (c === '"') {

            if (
                insideQuotes &&
                text[i + 1] === '"'
            ) {

                field += '"';
                i++;

            } else {

                insideQuotes =
                    !insideQuotes;
            }

            continue;
        }


        if (
            c === "," &&
            !insideQuotes
        ) {

            row.push(field);
            field = "";
            continue;
        }


        if (
            (
                c === "\n" ||
                c === "\r"
            ) &&
            !insideQuotes
        ) {

            if (
                c === "\r" &&
                text[i + 1] === "\n"
            ) {
                i++;
            }


            row.push(field);
            field = "";


            if (row.length)
                rows.push(row);


            row = [];

            continue;
        }


        field += c;
    }


    if (field.length || row.length) {

        row.push(field);

        if (row.length)
            rows.push(row);
    }


    return rows;
}


// ============================================================
// GENERATE
// ============================================================

async function generate() {

    try {

        status(
            "Загружаю HYG 4.1...\n"
        );


        // ----------------------------------------------------
        // DOWNLOAD
        // ----------------------------------------------------

        const response =
            await fetch(HYG_URL);


        if (!response.ok) {

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


        const csvText =
            await response.text();


        status(
            "HYG загружен.\n" +
            "Разбираю каталог..."
        );


        // ----------------------------------------------------
        // CSV
        // ----------------------------------------------------

        const rows =
            parseCSV(csvText);


        if (!rows.length) {

            throw new Error(
                "CSV пустой."
            );
        }


        const header =
            rows[0];


        const index = {};


        for (
            let i = 0;
            i < header.length;
            i++
        ) {

            index[
                header[i]
            ] = i;
        }


        const stars = [];


        // ----------------------------------------------------
        // READ STARS
        // ----------------------------------------------------

        for (
            let r = 1;
            r < rows.length;
            r++
        ) {

            const row =
                rows[r];


            try {

                const ra =
                    parseFloat(
                        row[index.ra]
                    );

                const dec =
                    parseFloat(
                        row[index.dec]
                    );

                const mag =
                    parseFloat(
                        row[index.mag]
                    );


                if (
                    !Number.isFinite(ra) ||
                    !Number.isFinite(dec) ||
                    !Number.isFinite(mag)
                ) {

                    continue;
                }


                // STRICT:
                //
                // mag < 5.0
                //
                // mag = 5.0 исключается.

                if (!(mag < MAG_LIMIT))
                    continue;


                if (
                    dec < -90.0 ||
                    dec > 90.0
                )
                    continue;


                stars.push({

                    ra: ra,

                    dec: dec,

                    mag: mag,

                    proper:
                        index.proper !== undefined
                        ? row[index.proper]
                        : "",

                    con:
                        index.con !== undefined
                        ? row[index.con]
                        : ""
                });


            } catch(e) {

                continue;
            }
        }


        status(
            "Звёзд mag < 5.0: " +
            stars.length +
            "\n" +
            "Строю 24 лепестка..."
        );


        // ====================================================
        // SVG
        // ====================================================

        let svg = [];


        svg.push(
`<?xml version="1.0" encoding="UTF-8"?>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="${SVG_WIDTH.toFixed(3)}mm"
height="${SVG_HEIGHT.toFixed(3)}mm"
viewBox="0 0 ${SVG_WIDTH.toFixed(3)} ${SVG_HEIGHT.toFixed(3)}">

<defs>

<style>

.gore {
fill:#07101f;
stroke:#ff3218;
stroke-width:1.2;
}

.grid {
fill:none;
stroke:#285481;
stroke-width:0.35;
}

.equator {
fill:none;
stroke:#4c91d0;
stroke-width:0.7;
}

.star {
fill:#ffd15a;
stroke:none;
}

.label {
fill:#dcecff;
font-family:Arial,sans-serif;
}

.info {
fill:#ffffff;
font-family:Arial,sans-serif;
}

.cut {
fill:none;
stroke:#ff3218;
stroke-width:1.2;
}

</style>

</defs>`
        );


        // ====================================================
        // BACKGROUND
        // ====================================================

        svg.push(
`<rect
x="0"
y="0"
width="${SVG_WIDTH}"
height="${SVG_HEIGHT}"
fill="#03060d"/>`
        );


        // ====================================================
        // GORES
        // ====================================================

        svg.push(
`<g
inkscape:groupmode="layer"
inkscape:label="01 GORES">`
        );


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

            const path =
                createGorePath(g);


            const center =
                goreCenter(g);


            svg.push(
`<path
id="GORE_${String(g + 1).padStart(2,"0")}"
class="gore"
d="${path}">

<title>
GORE ${String(g + 1).padStart(2,"0")}
CENTER RA ${center.toFixed(3)} degrees
</title>

</path>`
            );
        }


        svg.push("</g>");


        // ====================================================
        // GRID
        // ====================================================

        svg.push(
`<g
inkscape:groupmode="layer"
inkscape:label="02 COORDINATE GRID"
class="grid">`
        );


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

            const offset =
                layoutOffset(g);


            // Latitude lines

            for (
                const lat of
                [-60, -30, 30, 60]
            ) {

                let points = [];


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

                    const lon =
                        -HALF_GORE_DEG +
                        GORE_WIDTH_DEG *
                        i /
                        72;


                    const p =
                        orangePeel(
                            lat,
                            lon
                        );


                    points.push({

                        x:
                            offset.x + p.x,

                        y:
                            offset.y - p.y
                    });
                }


                svg.push(
                    `<path d="${pointsToPath(points,false)}"/>`
                );
            }


            // Central meridian

            let meridian = [];


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

                const lat =
                    -90 +
                    180 *
                    i /
                    LAT_STEPS;


                const p =
                    orangePeel(
                        lat,
                        0
                    );


                meridian.push({

                    x:
                        offset.x + p.x,

                    y:
                        offset.y - p.y
                });
            }


            svg.push(
                `<path d="${pointsToPath(meridian,false)}"/>`
            );
        }


        svg.push("</g>");


        // ====================================================
        // EQUATOR
        // ====================================================

        svg.push(
`<g
inkscape:groupmode="layer"
inkscape:label="03 EQUATOR"
class="equator">`
        );


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

            const offset =
                layoutOffset(g);


            let points = [];


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

                const lon =
                    -HALF_GORE_DEG +
                    GORE_WIDTH_DEG *
                    i /
                    72;


                const p =
                    orangePeel(
                        0,
                        lon
                    );


                points.push({

                    x:
                        offset.x + p.x,

                    y:
                        offset.y - p.y
                });
            }


            svg.push(
                `<path d="${pointsToPath(points,false)}"/>`
            );
        }


        svg.push("</g>");


        // ====================================================
        // STARS
        // ====================================================

        status(
            "Строю звёзды...\n" +
            "0 / " +
            stars.length
        );


        svg.push(
`<g
inkscape:groupmode="layer"
inkscape:label="04 STARS mag less than 5.0"
class="star">`
        );


        for (
            let i = 0;
            i < stars.length;
            i++
        ) {

            const star =
                stars[i];


            const raDeg =
                raHoursToDegrees(
                    star.ra
                );


            const decDeg =
                star.dec;


            const gore =
                getGore(
                    raDeg
                );


            const localLon =
                localLongitude(
                    raDeg,
                    gore
                );


            const p =
                orangePeel(
                    decDeg,
                    localLon
                );


            const offset =
                layoutOffset(gore);


            const x =
                offset.x + p.x;


            const y =
                offset.y - p.y;


            const diameter =
                starDiameter(
                    star.mag
                );


            const radius =
                diameter / 2.0;


            const title =
                "RA=" +
                raDeg.toFixed(6) +
                " deg | " +
                "Dec=" +
                decDeg.toFixed(6) +
                " deg | " +
                "mag=" +
                star.mag.toFixed(2);


            svg.push(
`<circle
id="STAR_${String(i).padStart(5,"0")}"
cx="${x.toFixed(4)}"
cy="${y.toFixed(4)}"
r="${radius.toFixed(4)}"
class="star">

<title>${esc(title)}</title>

</circle>`
            );


            if (
                i % 500 === 0
            ) {

                status(
                    "Строю звёзды...\n" +
                    i +
                    " / " +
                    stars.length
                );
            }
        }


        svg.push("</g>");


        // ====================================================
        // LABELS
        // ====================================================

        svg.push(
`<g
inkscape:groupmode="layer"
inkscape:label="05 LABELS"
class="label">`
        );


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

            const offset =
                layoutOffset(g);


            const center =
                goreCenter(g);


            svg.push(
`<text
x="${offset.x.toFixed(3)}"
y="${(
    offset.y +
    GORE_HEIGHT / 2 +
    80
).toFixed(3)}"
text-anchor="middle"
font-size="18mm">

GORE ${String(g + 1).padStart(2,"0")}
 / RA ${center.toFixed(1)}°

</text>`
            );
        }


        svg.push("</g>");


        // ====================================================
        // INFO
        // ====================================================

        svg.push(
`<g
inkscape:groupmode="layer"
inkscape:label="06 PRINT INFORMATION"
class="info">`
        );


        svg.push(
`<text
x="${(SVG_WIDTH / 2).toFixed(3)}"
y="60"
text-anchor="middle"
font-size="25mm"
font-weight="bold">

CELESTIAL GLOBE Ø1500 mm

</text>`
        );


        svg.push(
`<text
x="${(SVG_WIDTH / 2).toFixed(3)}"
y="95"
text-anchor="middle"
font-size="11mm">

24 ORANGE-PEEL GORES /
HYG 4.1 /
MAG &lt; 5.0 /
J2000.0

</text>`
        );


        // ====================================================
        // SCALE BAR
        // ====================================================

        const sx =
            PAGE_MARGIN;


        const sy =
            SVG_HEIGHT - 110;


        svg.push(
`<line
x1="${sx}"
y1="${sy}"
x2="${sx + 100}"
y2="${sy}"
stroke="white"
stroke-width="2"/>`
        );


        svg.push(
`<line
x1="${sx}"
y1="${sy - 10}"
x2="${sx}"
y2="${sy + 10}"
stroke="white"
stroke-width="2"/>`
        );


        svg.push(
`<line
x1="${sx + 100}"
y1="${sy - 10}"
x2="${sx + 100}"
y2="${sy + 10}"
stroke="white"
stroke-width="2"/>`
        );


        svg.push(
`<text
x="${sx + 50}"
y="${sy - 20}"
text-anchor="middle"
font-size="8mm">

100 mm — PRINT 100%

</text>`
        );


        // ====================================================
        // INFO BLOCK
        // ====================================================

        const infoX =
            SVG_WIDTH -
            PAGE_MARGIN -
            500;


        const infoY =
            SVG_HEIGHT -
            260;


        const info = [

            "DIAMETER: 1500 mm",

            "RADIUS: 750 mm",

            "GORES: 24",

            "GORE WIDTH: 15 deg",

            "STARS: mag < 5.0",

            "STAR COUNT: " +
            stars.length,

            "CATALOG: HYG 4.1",

            "EPOCH: J2000.0",

            "PROJECTION: ORANGE-PEEL",

            "SCALE: 1:1"
        ];


        for (
            let i = 0;
            i < info.length;
            i++
        ) {

            svg.push(
`<text
x="${infoX}"
y="${infoY + i * 30}"
font-size="7mm">

${esc(info[i])}

</text>`
            );
        }


        svg.push("</g>");


        // ====================================================
        // CLOSE
        // ====================================================

        svg.push("</svg>");


        // ====================================================
        // DOWNLOAD SVG
        // ====================================================

        const blob =
            new Blob(
                [
                    svg.join("\n")
                ],
                {
                    type:
                    "image/svg+xml;charset=utf-8"
                }
            );


        const url =
            URL.createObjectURL(
                blob
            );


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


        link.href = url;

        link.download =
            "celestial_globe_1500mm_24_orange_peel_mag_lt_5.svg";


        document.body.appendChild(
            link
        );


        link.click();


        document.body.removeChild(
            link
        );


        URL.revokeObjectURL(
            url
        );


        // ====================================================
        // DONE
        // ====================================================

        status(
            "ГОТОВО!\n\n" +

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

            "Создан файл:\n" +

            "celestial_globe_1500mm_24_orange_peel_mag_lt_5.svg\n\n" +

            "Теперь открой его в Inkscape.\n\n" +

            "В Inkscape:\n" +

            "File → Export\n" +

            "Выбери Page\n" +

            "Export"
        );


    } catch(error) {

        console.error(error);


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

            "Если браузер заблокировал загрузку HYG,\n" +
            "попробуй Chrome или Edge."
        );
    }
}

</script>

</body>
</html>