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


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

            Центральный меридиан каждого лепестка используется
            только как математическая ось проекции и
            <b>не рисуется в SVG</b>.

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

   ВАЖНО:

   Центральный меридиан не является графическим элементом.

   В SVG остаётся только внешний контур лепестка,
   звёзды и белая область бумаги.
   ============================================================ */


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

    Границы:

        ±7.5°

    Используется half-open interval.
*/

function goreIndex(raDeg) {

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

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

    i =
        ((i % N_GORES) + N_GORES)
        % N_GORES;

    return i;
}


/*
    Центральная математическая ось лепестка.

    Используется только для расчёта координат.

    В SVG эта линия НЕ РИСУЕТСЯ.
*/

function goreCenterLon(index) {

    return index * GORE_DEG;
}


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

    x = R * Δλ * cos φ

    y = -R * φ

    Центральный меридиан здесь является только
    математическим нулём координат X.
*/

function projectStar(
    raDeg,
    decDeg,
    goreIndexValue,
    R
) {

    const center =
        goreCenterLon(goreIndexValue);

    let delta =
        wrap180(
            raDeg - center
        );

    const lambda =
        degToRad(delta);

    const phi =
        degToRad(decDeg);

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

    const y =
        -R * phi;

    return {
        x,
        y
    };
}


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

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


function findColumn(
    headers,
    candidates
) {

    for (
        const candidate
        of candidates
    ) {

        const i =
            headers.indexOf(candidate);

        if (i >= 0) {

            return i;
        }
    }

    return -1;
}


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

        ra
        dec
        mag

    Дополнительно допускаются альтернативные
    названия колонок.
*/

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


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

function starDiameter(
    mag,
    minD,
    maxD
) {

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


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


    /*
        ВАЖНО:

        Старый код использовал pathFromPoints()
        для обеих сторон.

        Это создавало:

            M ... L ... M ... L ... Z

        Второй M начинал новый subpath.

        После Z SVG закрывал второй subpath
        прямой линией от нижнего полюса
        к верхнему полюсу.

        Поскольку оба полюса имеют x = cx,
        эта линия визуально выглядела как
        центральный меридиан.

        Поэтому здесь контур строится
        ОДНИМ непрерывным subpath:

            M левая нижняя точка
            L ... левая граница
            L ... правая граница
            Z

        Никакого второго M нет.
    */


    const firstLeft =
        pointsLeft[0];


    let boundary =
        "M " +
        firstLeft[0].toFixed(4) +
        " " +
        firstLeft[1].toFixed(4);


    /*
        Левая граница.
    */

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

        const p =
            pointsLeft[i];

        boundary +=
            " L " +
            p[0].toFixed(4) +
            " " +
            p[1].toFixed(4);
    }


    /*
        Правая граница.

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

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

        const p =
            pointsRight[i];

        boundary +=
            " L " +
            p[0].toFixed(4) +
            " " +
            p[1].toFixed(4);
    }


    /*
        Закрываем внешний контур.

        ВАЖНО:

        Z соединяет последнюю точку
        с первой точкой.

        Обе эти точки находятся
        в одном полюсе.

        Центральный меридиан здесь
        НЕ образуется.
    */

    boundary += " Z";


    /*
        Полезная SVG-единица = 1 мм.

        Центральный меридиан НЕ добавляется
        в SVG как отдельный path или line.
    */

    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 +=
            `<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
    ) {

        if (
            arr.length !== 1
        ) {

            duplicates++;
        }
    }


    return {

        bad,

        boundary,

        assigned,

        duplicates

    };
}


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

function buildGores(
    stars,
    options
) {

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


    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;


    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>