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


<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Звёздный глобус — 24 лепестка / Ø1000 мм</title>

<style>
    :root {
        color-scheme: dark;
        --bg:#111318;
        --panel:#1b1e26;
        --line:#343946;
        --text:#eef1f7;
        --muted:#9da5b5;
        --accent:#62a0ff;
    }

    * { box-sizing:border-box; }

    body {
        margin:0;
        background:var(--bg);
        color:var(--text);
        font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
    }

    header {
        padding:18px 24px;
        border-bottom:1px solid var(--line);
    }

    h1 {
        margin:0 0 5px;
        font-size:21px;
    }

    header p {
        margin:0;
        color:var(--muted);
        font-size:13px;
    }

    main {
        display:grid;
        grid-template-columns:360px 1fr;
        min-height:calc(100vh - 76px);
    }

    aside {
        padding:18px;
        background:var(--panel);
        border-right:1px solid var(--line);
        overflow:auto;
    }

    section.preview {
        padding:20px;
        overflow:auto;
    }

    .box {
        border:1px solid var(--line);
        border-radius:10px;
        padding:14px;
        margin-bottom:14px;
    }

    label {
        display:block;
        font-size:13px;
        margin-bottom:8px;
        color:var(--muted);
    }

    input[type=file] {
        width:100%;
    }

    input[type=number] {
        width:100%;
        padding:8px;
        border:1px solid var(--line);
        background:#101218;
        color:white;
        border-radius:6px;
    }

    button {
        width:100%;
        padding:9px 10px;
        margin-top:7px;
        border:1px solid #465064;
        border-radius:7px;
        background:#252b37;
        color:white;
        cursor:pointer;
    }

    button:hover {
        background:#303949;
    }

    button.primary {
        background:#2468d8;
        border-color:#3c83f0;
    }

    button:disabled {
        opacity:.45;
        cursor:not-allowed;
    }

    .stats {
        font-size:13px;
        line-height:1.55;
    }

    .stats b {
        color:white;
    }

    #svgHost {
        display:flex;
        justify-content:center;
        align-items:flex-start;
        min-height:500px;
    }

    #svgHost svg {
        width:min(850px,100%);
        height:auto;
        background:white;
        box-shadow:0 4px 25px #0008;
    }

    .downloads {
        display:grid;
        grid-template-columns:repeat(4,1fr);
        gap:6px;
        margin-top:15px;
    }

    .downloads button {
        margin:0;
        font-size:12px;
    }

    .notice {
        color:var(--muted);
        font-size:12px;
        line-height:1.5;
    }

    .ok { color:#67dc93; }
    .warn { color:#ffca62; }
    .error { color:#ff7272; }

    @media(max-width:850px) {
        main { grid-template-columns:1fr; }
        aside { border-right:0; border-bottom:1px solid var(--line); }
        .downloads { grid-template-columns:repeat(3,1fr); }
    }
</style>
</head>

<body>

<header>
    <h1>Звёздный бумажный глобус Ø1000 мм — 24 лепестка</h1>
    <p>HYG 4.1 · J2000.0 · звёзды ярче 5-й величины · SVG 1:1</p>
</header>

<main>

<aside>

    <div class="box">
        <label>Файл HYG 4.1 CSV</label>
        <input id="csvFile" type="file" accept=".csv,text/csv">

        <button id="generate" class="primary" disabled>
            Построить глобус
        </button>
    </div>

    <div class="box">
        <label>Диаметр глобуса, мм</label>
        <input id="diameter" type="number" value="1000" min="100" step="1">

        <label style="margin-top:12px">
            Порог звёздной величины
        </label>
        <input id="magLimit" type="number" value="5" step="0.1">

        <label style="margin-top:12px">
            Размер самой яркой звезды, мм
        </label>
        <input id="maxStar" type="number" value="3.2" min="0.2" step="0.1">

        <label style="margin-top:12px">
            Минимальный размер звезды, мм
        </label>
        <input id="minStar" type="number" value="0.55" min="0.1" step="0.05">
    </div>

    <div class="box stats" id="stats">
        CSV ещё не загружен.
    </div>

    <div class="box">
        <button id="downloadCurrent" disabled>
            Скачать текущий лепесток SVG
        </button>

        <div class="notice" style="margin-top:10px">
            SVG имеет физический размер в миллиметрах.
            При печати необходимо выбрать «Actual size / 100%»,
            а не «Fit to page».
        </div>
    </div>

    <div class="box">
        <div class="notice">
            <b>Геометрия:</b><br>
            24 лепестка × 15° по прямому восхождению.<br>
            Центральный меридиан каждого лепестка проходит
            через его середину. Границы искривлены согласно
            cos(δ).<br><br>

            <b>Координаты HYG:</b><br>
            RA — часы, Dec — градусы, эпоха/equinox J2000.0.
        </div>
    </div>

</aside>

<section class="preview">

    <div id="svgHost">
        <div class="notice">
            После загрузки CSV здесь появится первый лепесток.
        </div>
    </div>

    <div class="downloads" id="downloads"></div>

</section>

</main>


<script>
"use strict";

/*
    ============================================================
    ЗВЁЗДНЫЙ ГЛОБУС
    ============================================================

    Геометрическая модель:

        R = D / 2

        y = R * latitude

        x = R * deltaLongitude * cos(latitude)

    где:
        latitude = Dec
        deltaLongitude = разность долготы относительно
                         центрального меридиана лепестка.

    Это sinusoidal gore projection.

    24 лепестка:
        360 / 24 = 15 градусов.

    Полоса каждого лепестка:
        -7.5° <= deltaLongitude < +7.5°

    Исключение:
        последний/первый лепесток корректно обрабатывает
        переход RA = 24h -> RA = 0h.
*/


const N_GORES = 24;
const FULL_DEG = 360;
const GORE_DEG = FULL_DEG / N_GORES;
const HALF_GORE_DEG = GORE_DEG / 2;

let gores = [];
let currentGore = 0;
let sourceFileName = "";


/* ------------------------------------------------------------
   Утилиты
------------------------------------------------------------ */

function esc(s) {
    return String(s ?? "")
        .replace(/&/g,"&amp;")
        .replace(/</g,"&lt;")
        .replace(/>/g,"&gt;")
        .replace(/"/g,"&quot;");
}


function num(v) {
    if (v === null || v === undefined) return NaN;

    const s = String(v)
        .trim()
        .replace(",", ".");

    if (!s) return NaN;

    return Number(s);
}


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


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


/*
    Приведение долготы к [-180, +180).
*/
function wrap180(deg) {
    return ((deg + 180) % 360 + 360) % 360 - 180;
}


/*
    RA из HYG:
        0...24 часов
    преобразуется в:
        0...360 градусов
*/
function raHoursToDeg(raHours) {
    return raHours * 15;
}


/*
    Определяем индекс лепестка.

    Границы проходят по:
        0°, 15°, 30°, ...

    Чтобы звезда на границе не попала сразу
    в два лепестка, используется half-open interval.
*/
function goreIndex(raDeg) {

    raDeg = ((raDeg % 360) + 360) % 360;

    let i = Math.floor((raDeg + HALF_GORE_DEG) / GORE_DEG);

    /*
        Центральные меридианы:
            0°, 15°, 30° ...

        На границе используем следующий лепесток.
    */
    i = ((i % N_GORES) + N_GORES) % N_GORES;

    return i;
}


/*
    Центральный меридиан лепестка.
*/
function goreCenterLon(index) {
    return index * GORE_DEG;
}


/*
    Координаты звезды на плоскости лепестка.

    x = R * Δλ * cos φ
    y = R * φ

    y:
        северный полюс = -R*pi/2
        экватор        = 0
        южный полюс    = +R*pi/2

    SVG имеет ось Y вниз, поэтому ниже знак инвертируется.
*/
function projectStar(raDeg, decDeg, goreIndexValue, R) {

    const center = goreCenterLon(goreIndexValue);

    let delta = wrap180(raDeg - center);

    /*
        На всякий случай нормализуем ещё раз.
        Для данного лепестка должно быть:
            -7.5 <= delta <= +7.5
    */

    const lambda = degToRad(delta);
    const phi = degToRad(decDeg);

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

    /*
        SVG:
            верх = север
            низ  = юг
    */
    const y = -R * phi;

    return {x, y};
}


/* ------------------------------------------------------------
   CSV parser
------------------------------------------------------------ */

/*
    CSV parser с поддержкой:
        - кавычек
        - запятых внутри кавычек
        - CRLF/LF
        - escaped quotes ""

    HYG обычно представляет собой обычный CSV.
*/
function parseCSV(text) {

    const rows = [];
    let row = [];
    let cell = "";
    let quoted = false;

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

        const c = text[i];

        if (quoted) {

            if (c === '"') {

                if (text[i+1] === '"') {
                    cell += '"';
                    i++;
                } else {
                    quoted = false;
                }

            } else {
                cell += c;
            }

        } else {

            if (c === '"') {
                quoted = true;

            } else if (c === ',') {
                row.push(cell);
                cell = "";

            } else if (c === '\n') {
                row.push(cell);
                rows.push(row);
                row = [];
                cell = "";

            } else if (c === '\r') {
                /*
                    CRLF обработается через LF.
                */

            } else {
                cell += c;
            }
        }
    }

    if (cell.length || row.length) {
        row.push(cell);
        rows.push(row);
    }

    return rows;
}


function normaliseHeader(s) {
    return String(s ?? "")
        .trim()
        .replace(/^\uFEFF/, "")
        .toLowerCase();
}


/*
    HYG v4.1 имеет поля:
        ra
        dec
        mag

    Но здесь дополнительно допускаются альтернативные
    названия, чтобы файл было труднее сломать случайным
    переименованием колонок.
*/
function findColumn(headers, candidates) {

    for (const candidate of candidates) {

        const i = headers.indexOf(candidate);

        if (i >= 0) return i;
    }

    return -1;
}


function csvToStars(text) {

    const rows = parseCSV(text);

    if (!rows.length) {
        throw new Error("CSV-файл пуст.");
    }

    const headers = rows[0].map(normaliseHeader);

    const raCol = findColumn(headers, [
        "ra",
        "right ascension",
        "rightascension"
    ]);

    const decCol = findColumn(headers, [
        "dec",
        "declination"
    ]);

    const magCol = findColumn(headers, [
        "mag",
        "magnitude",
        "vmag"
    ]);

    if (raCol < 0 || decCol < 0 || magCol < 0) {

        throw new Error(
            "Не найдены необходимые колонки ra, dec, mag. " +
            "Это не похоже на HYG CSV."
        );
    }

    const stars = [];

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

        const row = rows[r];

        if (!row.length) continue;

        const ra = num(row[raCol]);
        const dec = num(row[decCol]);
        const mag = num(row[magCol]);

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

        if (ra < 0 || ra >= 24) continue;
        if (dec < -90 || dec > 90) continue;

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

    return stars;
}


/* ------------------------------------------------------------
   Размер звезды
------------------------------------------------------------ */

/*
    Визуальный радиус/диаметр зависит от magnitude.

    Чем меньше magnitude, тем ярче звезда.

    Используется экспоненциальное соответствие,
    но ограниченное min/max.

    Это не попытка физически представить угловой диаметр
    звезды — звёзды не имеют различимого диска на таком
    масштабе. Это размер печатной точки.
*/
function starDiameter(mag, minD, maxD) {

    /*
        При mag=0:
            maxD

        При mag=5:
            близко к minD
    */

    const t = Math.max(0, Math.min(1, mag / 5));

    /*
        Немного нелинейнее, чтобы яркие звёзды
        заметно отличались.
    */
    const d = maxD - (maxD - minD) * Math.pow(t, 0.65);

    return Math.max(minD, Math.min(maxD, d));
}


/* ------------------------------------------------------------
   SVG геометрия
------------------------------------------------------------ */

function makeGoreSVG(index, stars, options) {

    const D = options.diameter;
    const R = D / 2;

    /*
        Максимальная ширина на экваторе:
            2 R * π/24
    */
    const halfWidthEquator =
        R * degToRad(HALF_GORE_DEG);

    /*
        SVG viewBox с небольшим геометрическим запасом
        НЕ является полем для склейки.

        Внутренняя бумажная геометрия всё равно ровно
        соответствует диаметру глобуса.
    */
    const width = 2 * halfWidthEquator;
    const height = Math.PI * R;

    /*
        Координатная система SVG:
            x = 0 ... width
            y = 0 ... height

        Центр лепестка:
            cx = width/2
            cy = height/2
    */

    const cx = width / 2;
    const cy = height / 2;

    /*
        Кривые границы лепестка.

        x = ±R * 7.5° * cos(phi)
        y = -R*phi
    */
    const pointsLeft = [];
    const pointsRight = [];

    const samples = 180;

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

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

        const y = cy - R * phi;

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

        pointsLeft.push([
            cx - x,
            y
        ]);

        pointsRight.push([
            cx + x,
            y
        ]);
    }

    function pathFromPoints(arr) {

        return arr.map((p,i) =>
            (i === 0 ? "M " : "L ") +
            p[0].toFixed(4) + " " +
            p[1].toFixed(4)
        ).join(" ");
    }

    const leftPath = pathFromPoints(pointsLeft);
    const rightPath = pathFromPoints(pointsRight);

    /*
        Полный контур.
    */
    const boundary =
        leftPath + " " +
        pathFromPoints([...pointsRight].reverse()) +
        " Z";

    /*
        Полезная SVG-единица = 1 мм.
        Поэтому width/height непосредственно задают
        физический размер при печати.
    */

    let svg = "";

    svg += `<?xml version="1.0" encoding="UTF-8"?>\n`;

    svg += `<svg xmlns="http://www.w3.org/2000/svg" ` +
           `width="${width.toFixed(4)}mm" ` +
           `height="${height.toFixed(4)}mm" ` +
           `viewBox="0 0 ${width.toFixed(4)} ${height.toFixed(4)}">\n`;

    svg += `<title>Star globe gore ${index+1}/24</title>\n`;

    /*
        Белая бумага.
    */
    svg += `<path d="${boundary}" fill="white" stroke="none"/>\n`;

    /*
        Контур резки.
        Это НЕ поле для склейки.
    */
    svg += `<path d="${boundary}" ` +
           `fill="none" stroke="#000" stroke-width="0.15"/>\n`;

    /*
        Звёзды.
    */
    for (const star of stars) {

        const p = projectStar(
            star.raDeg,
            star.dec,
            index,
            R
        );

        const x = cx + p.x;
        const y = cy + p.y;

        const d = starDiameter(
            star.mag,
            options.minStar,
            options.maxStar
        );

        const r = d / 2;

        /*
            Маленькая звезда — чёрная точка.
            Это удобно для печати.
        */
        svg += `<circle ` +
            `cx="${x.toFixed(4)}" ` +
            `cy="${y.toFixed(4)}" ` +
            `r="${r.toFixed(4)}" ` +
            `fill="black">`;

        /*
            SVG metadata:
            координаты позволяют проверить конкретную звезду
            даже после открытия SVG в редакторе.
        */
        svg += `<title>` +
            `RA ${star.raHours.toFixed(6)}h, ` +
            `Dec ${star.dec.toFixed(6)}°, ` +
            `mag ${star.mag.toFixed(3)}` +
            `</title>`;

        svg += `</circle>\n`;
    }

    svg += `</svg>`;

    return svg;
}


/* ------------------------------------------------------------
   Проверка геометрии
------------------------------------------------------------ */

function validateStars(stars) {

    let bad = 0;
    let boundary = 0;
    let assigned = 0;

    const seen = new Map();

    for (const s of stars) {

        const g = goreIndex(s.raDeg);

        if (g < 0 || g >= N_GORES) {
            bad++;
            continue;
        }

        assigned++;

        /*
            Проверяем обратное преобразование.
        */
        const center = goreCenterLon(g);
        const p = projectStar(
            s.raDeg,
            s.dec,
            g,
            1
        );

        const phi = -p.y;

        const cosPhi = Math.cos(phi);

        let recoveredDelta;

        if (Math.abs(cosPhi) < 1e-12) {
            /*
                На полюсе долгота физически неопределима.
            */
            recoveredDelta = 0;
        } else {
            recoveredDelta =
                radToDeg(p.x / cosPhi);
        }

        const recoveredRA =
            ((center + recoveredDelta) % 360 + 360) % 360;

        const errLon =
            Math.abs(wrap180(recoveredRA - s.raDeg));

        const errDec =
            Math.abs(radToDeg(-p.y) - s.dec);

        if (errLon > 1e-9 || errDec > 1e-9) {
            bad++;
        }

        /*
            Считаем звёзды у границ.
        */
        const delta =
            Math.abs(wrap180(s.raDeg - center));

        if (Math.abs(delta - HALF_GORE_DEG) < 1e-8) {
            boundary++;
        }

        /*
            Каждая запись должна назначаться ровно одному
            лепестку.
        */
        const key = `${s.raHours}|${s.dec}|${s.mag}`;

        if (!seen.has(key)) seen.set(key, []);
        seen.get(key).push(g);
    }

    let duplicates = 0;

    for (const [, arr] of seen) {

        /*
            Один и тот же CSV-row не должен быть
            самостоятельно размножен между лепестками.
        */
        if (arr.length !== 1) duplicates++;
    }

    return {
        bad,
        boundary,
        assigned,
        duplicates
    };
}


/* ------------------------------------------------------------
   Построение
------------------------------------------------------------ */

function buildGores(stars, options) {

    const selected =
        stars.filter(s => s.mag < options.magLimit);

    /*
        24 массива.
    */
    const result =
        Array.from({length:N_GORES}, () => []);

    for (const star of selected) {

        const g = goreIndex(star.raDeg);

        result[g].push(star);
    }

    return result;
}


/* ------------------------------------------------------------
   Отображение
------------------------------------------------------------ */

function showGore(index) {

    if (!gores.length) return;

    currentGore = index;

    const options = getOptions();

    const svgText =
        makeGoreSVG(
            index,
            gores[index],
            options
        );

    const host =
        document.getElementById("svgHost");

    host.innerHTML = svgText;

    /*
        Кнопка текущего лепестка.
    */
    document.getElementById("downloadCurrent").disabled = false;

    /*
        Стилизация download-кнопок.
    */
    document
        .querySelectorAll(".downloads button")
        .forEach((button, i) => {
            button.classList.toggle(
                "primary",
                i === index
            );
        });
}


function getOptions() {

    return {
        diameter:
            num(document.getElementById("diameter").value),

        magLimit:
            num(document.getElementById("magLimit").value),

        maxStar:
            num(document.getElementById("maxStar").value),

        minStar:
            num(document.getElementById("minStar").value)
    };
}


/* ------------------------------------------------------------
   Download
------------------------------------------------------------ */

function downloadText(filename, text) {

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


function downloadGore(index) {

    const options = getOptions();

    const svg =
        makeGoreSVG(
            index,
            gores[index],
            options
        );

    const name =
        `globe_gore_${String(index+1).padStart(2,"0")}_of_24.svg`;

    downloadText(name, svg);
}


/* ------------------------------------------------------------
   Интерфейс
------------------------------------------------------------ */

document
    .getElementById("csvFile")
    .addEventListener("change", async function() {

        const file = this.files[0];

        if (!file) return;

        sourceFileName = file.name;

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

        try {

            stats.innerHTML =
                "Читаю CSV…";

            const text =
                await file.text();

            const stars =
                csvToStars(text);

            window.__allStars = stars;

            stats.innerHTML =
                `<span class="ok">CSV прочитан.</span><br>` +
                `Записей со корректными координатами: ` +
                `<b>${stars.length.toLocaleString("ru-RU")}</b><br><br>` +
                `Файл: <b>${esc(file.name)}</b>`;

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

        } catch (err) {

            stats.innerHTML =
                `<span class="error">Ошибка:</span><br>` +
                `${esc(err.message)}`;

            document
                .getElementById("generate")
                .disabled = true;
        }
    });


document
    .getElementById("generate")
    .addEventListener("click", function() {

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

        try {

            const options = getOptions();

            if (!Number.isFinite(options.diameter) ||
                options.diameter <= 0) {

                throw new Error(
                    "Диаметр должен быть положительным."
                );
            }

            if (!Number.isFinite(options.magLimit)) {
                throw new Error(
                    "Неверный предел звёздной величины."
                );
            }

            const all =
                window.__allStars || [];

            const selected =
                all.filter(
                    s => s.mag < options.magLimit
                );

            gores =
                buildGores(all, options);

            const validation =
                validateStars(selected);

            /*
                Статистика.
            */
            let distribution = "";

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

                distribution +=
                    `${i+1}: ` +
                    `${gores[i].length.toLocaleString("ru-RU")} ` +
                    `звёзд<br>`;
            }

            stats.innerHTML =
                `<span class="ok">Готово.</span><br>` +
                `Всего в HYG: ` +
                `<b>${all.length.toLocaleString("ru-RU")}</b><br>` +
                `Ярче ${options.magLimit}: ` +
                `<b>${selected.length.toLocaleString("ru-RU")}</b><br>` +
                `Распределено: ` +
                `<b>${validation.assigned.toLocaleString("ru-RU")}</b><br>` +
                `Ошибок обратной проверки: ` +
                `<b class="${validation.bad ? "error":"ok"}">` +
                `${validation.bad}</b><br>` +
                `Граничных случаев: ` +
                `<b>${validation.boundary}</b><br>` +
                `Дубликатов назначения: ` +
                `<b class="${validation.duplicates ? "warn":"ok"}">` +
                `${validation.duplicates}</b><hr>` +
                `<b>Звёзды по лепесткам:</b><br>` +
                distribution;

            /*
                Кнопки 1...24.
            */
            const downloads =
                document.getElementById("downloads");

            downloads.innerHTML = "";

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

                const b =
                    document.createElement("button");

                b.textContent =
                    `Лепесток ${i+1}`;

                b.title =
                    `Показать/скачать лепесток ${i+1}`;

                b.addEventListener(
                    "click",
                    () => {
                        showGore(i);
                    }
                );

                downloads.appendChild(b);
            }

            /*
                Первый лепесток.
            */
            showGore(0);

        } catch (err) {

            stats.innerHTML =
                `<span class="error">Ошибка построения:</span><br>` +
                `${esc(err.message)}`;
        }
    });


document
    .getElementById("downloadCurrent")
    .addEventListener("click", function() {

        if (!gores.length) return;

        downloadGore(currentGore);
    });

</script>

</body>
</html>