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


<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HYG Star Globe — Ø1000 mm — 24 Gores</title>

<style>
html,body {
  margin:0;
  padding:0;
  background:#101318;
  color:#e9edf4;
  font-family:system-ui,-apple-system,Segoe UI,sans-serif;
}

body {
  padding:20px;
}

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

h2 {
  margin:24px 0 10px;
  font-size:18px;
}

.panel {
  background:#181d25;
  border:1px solid #353d4b;
  border-radius:10px;
  padding:15px;
  margin:12px 0;
}

button {
  background:#263750;
  color:white;
  border:1px solid #526b8d;
  border-radius:7px;
  padding:9px 13px;
  margin:3px;
  cursor:pointer;
  font-size:14px;
}

button:hover {
  background:#345071;
}

button:disabled {
  opacity:.45;
  cursor:default;
}

.good {
  color:#46d597;
}

.warn {
  color:#ffca63;
}

.bad {
  color:#ff6978;
}

.muted {
  color:#9ca8b8;
}

.stats {
  display:grid;
  grid-template-columns:
    repeat(auto-fit,minmax(190px,1fr));
  gap:9px;
}

.stat {
  border:1px solid #353d4b;
  border-radius:8px;
  padding:10px;
}

.stat b {
  display:block;
  font-size:19px;
  margin-top:3px;
}

#preview {
  background:white;
  padding:15px;
  border-radius:8px;
  overflow:auto;
}

#preview svg {
  display:block;
  width:min(100%,700px);
  height:auto;
  margin:auto;
}

#downloads {
  display:grid;
  grid-template-columns:
    repeat(auto-fill,minmax(180px,1fr));
  gap:5px;
}

.progress {
  width:100%;
  height:8px;
  background:#303641;
  border-radius:10px;
  overflow:hidden;
  margin-top:10px;
}

.progress div {
  height:100%;
  width:0;
  background:#62a8ff;
  transition:width .1s;
}

code {
  color:#b9d7ff;
}

table {
  border-collapse:collapse;
  width:100%;
}

td,th {
  border-bottom:1px solid #303641;
  padding:5px;
  text-align:left;
}
</style>
</head>

<body>

<h1>Звёздный глобус Ø1000 мм — 24 лепестка</h1>

<div class="panel">

  <div id="status">
    Запуск...
  </div>

  <div class="progress">
    <div id="progressBar"></div>
  </div>

</div>

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

<h2>Лепесток №1</h2>

<div class="panel">
  <div id="preview">
    <span class="muted">
      Ожидание расчёта...
    </span>
  </div>
</div>

<h2>Проверка</h2>

<div class="panel" id="validation">
  Ожидание...
</div>

<h2>Скачать SVG</h2>

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


<script>
"use strict";

/*
============================================================
 ПАРАМЕТРЫ
============================================================
*/

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

/*
 Диаметр глобуса.
*/
const DIAMETER_MM = 1000;
const R = DIAMETER_MM / 2;

/*
 24 лепестка.
*/
const N_GORES = 24;

/*
 Ширина одного лепестка:
 360 / 24 = 15°.
*/
const GORE_DEG = 360 / N_GORES;
const HALF_GORE_DEG = GORE_DEG / 2;

const HALF_GORE_RAD =
  HALF_GORE_DEG * Math.PI / 180;

/*
 Только ярче 5.00m.
*/
const MAG_LIMIT = 5.0;

/*
 Мелкая служебная линия контура.
*/
const OUTLINE_WIDTH = 0.25;


/*
============================================================
 UI
============================================================
*/

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

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

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

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

const validationEl =
  document.getElementById("validation");

const downloadsEl =
  document.getElementById("downloads");


function status(text) {
  statusEl.innerHTML = text;
}

function progress(percent) {
  progressBar.style.width =
    Math.max(0,Math.min(100,percent)) + "%";
}


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

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

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

function norm360(x) {
  x %= 360;

  if (x < 0)
    x += 360;

  return x;
}

function angularDifferenceDeg(a,b) {
  let d = norm360(a-b);

  if (d > 180)
    d -= 360;

  return d;
}


/*
============================================================
 CSV
============================================================
*/

function parseCSV(text) {

  const rows = [];

  let row = [];
  let value = "";
  let quote = false;

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

    const c = text[i];

    if (quote) {

      if (c === '"') {

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

      }
      else {
        value += c;
      }

      continue;
    }

    if (c === '"') {
      quote = true;
    }

    else if (c === ',') {

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

    }

    else if (c === '\n') {

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

      if (
        row.length &&
        row.some(x => x.trim() !== "")
      ) {
        rows.push(row);
      }

      row = [];
    }

    else if (c !== '\r') {

      value += c;

    }
  }

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

    row.push(value);

    if (
      row.length &&
      row.some(x => x.trim() !== "")
    ) {
      rows.push(row);
    }
  }

  if (!rows.length)
    throw new Error("CSV пуст.");

  const headers =
    rows[0].map(x =>
      x.trim().replace(/^\uFEFF/,"")
    );

  const result = [];

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

    const obj = {};

    for (
      let j=0;
      j<headers.length;
      j++
    ) {
      obj[headers[j]] =
        (rows[i][j] || "").trim();
    }

    result.push(obj);
  }

  return {
    headers,
    rows:result
  };
}


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

function prepareStars(rows) {

  const stars = [];

  for (const s of rows) {

    const raHours =
      Number(s.ra);

    const decDeg =
      Number(s.dec);

    const mag =
      Number(s.mag);

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

    /*
     "Ярче 5 величины":
       mag < 5
    */
    if (!(mag < MAG_LIMIT))
      continue;

    if (
      raHours < 0 ||
      raHours >= 24 ||
      decDeg < -90 ||
      decDeg > 90
    ) {
      continue;
    }

    const raDeg =
      norm360(raHours * 15);

    stars.push({

      id:s.id || "",
      hip:s.hip || "",
      hr:s.hr || "",

      name:
        s.proper ||
        s.bf ||
        (s.hr ? "HR "+s.hr :
        s.hip ? "HIP "+s.hip :
        "HYG "+s.id),

      raHours,
      raDeg,
      decDeg,
      mag

    });
  }

  return stars;
}


/*
============================================================
 СФЕРИЧЕСКИЙ ВЕКТОР
============================================================

 Вектор единичного направления звезды:

 X = cos(dec) cos(RA)
 Y = cos(dec) sin(RA)
 Z = sin(dec)

 Это независимая проверка от плоской проекции.
============================================================
*/

function skyVector(raDeg,decDeg) {

  const a = deg(raDeg);
  const d = deg(decDeg);

  return {

    x:Math.cos(d)*Math.cos(a),
    y:Math.cos(d)*Math.sin(a),
    z:Math.sin(d)

  };
}


/*
============================================================
 GORE
============================================================

 Для каждого лепестка:

 центральный меридиан =
     i*15° + 7.5°

 Боковые границы =
     ±7.5°

 Положение внутри плоского лепестка:

 y = R * latitude

 x = R * tan(deltaLongitude) * cos(latitude)

 Эта форма даёт правильную синусоидальную границу:

 x_edge =
     R*tan(7.5°)*cos(latitude)

 или, если считать расстояние
 от северного полюса:

 width =
     R*tan(7.5°)*sin(colatitude)

============================================================
*/

function goreCenter(i) {

  return i*GORE_DEG +
         HALF_GORE_DEG;
}


function goreIndex(raDeg) {

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

  if (i < 0)
    i = 0;

  if (i >= N_GORES)
    i = N_GORES-1;

  return i;
}


function relativeLon(raDeg,centerDeg) {

  let d =
    norm360(raDeg-centerDeg);

  if (d > 180)
    d -= 360;

  return d;
}


function project(raDeg,decDeg,gore) {

  const center =
    goreCenter(gore);

  const dl =
    deg(relativeLon(raDeg,center));

  const lat =
    deg(decDeg);

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

  const y =
    R * lat;

  return {x,y};
}


/*
============================================================
 ОБРАТНАЯ ПРОЕКЦИЯ
============================================================
*/

function inverse(x,y,gore) {

  const center =
    goreCenter(gore);

  const lat =
    y/R;

  const c =
    Math.cos(lat);

  let dl = 0;

  if (Math.abs(c)>1e-15) {

    dl =
      Math.atan(
        x/(R*c)
      );
  }

  return {

    raDeg:
      norm360(
        center + rad(dl)
      ),

    decDeg:
      rad(lat)

  };
}


/*
============================================================
 НЕЗАВИСИМАЯ СФЕРИЧЕСКАЯ ПРОВЕРКА
============================================================

 Сначала координата переводится в единичный
 3D-вектор.

 Затем направление восстанавливается из
 положения на gore.

 Проверяем angular separation.

 Это НЕ просто сравнение числа с самим собой.
============================================================
*/

function vectorAngularError(
  ra1,
  dec1,
  ra2,
  dec2
) {

  const a =
    skyVector(ra1,dec1);

  const b =
    skyVector(ra2,dec2);

  const dot =
    a.x*b.x +
    a.y*b.y +
    a.z*b.z;

  const angle =
    Math.acos(
      Math.max(-1,Math.min(1,dot))
    );

  return rad(angle)*3600;
}


/*
============================================================
 ПРОВЕРКА ГРАНИЦ
============================================================
*/

function boundaryError(
  raDeg,
  decDeg,
  gore
) {

  const center =
    goreCenter(gore);

  const dl =
    Math.abs(
      relativeLon(
        raDeg,
        center
      )
    );

  return HALF_GORE_DEG - dl;
}


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

function starRadius(mag) {

  /*
   Ярчайшие — крупнее.
  */

  return Math.max(
    0.32,
    Math.min(
      2.2,
      0.35 + (5-mag)*0.28
    )
  );
}


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

const GORE_HEIGHT =
  Math.PI * R;

const GORE_MAX_WIDTH =
  2 * R *
  Math.tan(HALF_GORE_RAD);


/*
============================================================
 SVG COORDINATES
============================================================
*/

function svgY(y) {
  return -y;
}


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

function goreSide(sign) {

  const points = [];

  const steps = 361;

  /*
   x = R*latitude
   от -90° до +90°.

   Ширина:
   R*tan(half)*cos(latitude)
  */

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

    const lat =
      -Math.PI/2 +
      Math.PI*i/(steps-1);

    const x =
      sign *
      R *
      Math.tan(HALF_GORE_RAD) *
      Math.cos(lat);

    const y =
      R*lat;

    points.push([
      x,
      svgY(y)
    ]);
  }

  let d="";

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

    const p=points[i];

    d +=
      (i===0 ? "M " : " L ") +
      p[0].toFixed(5) +
      " " +
      p[1].toFixed(5);
  }

  return d;
}


/*
============================================================
 SVG ЛЕПЕСТКА
============================================================
*/

function buildSVG(
  stars,
  gore,
  includeGrid=false
) {

  const center =
    goreCenter(gore);

  const left =
    goreSide(-1);

  const right =
    goreSide(+1);

  /*
   SVG viewBox.
  */

  const margin=3;

  const width =
    GORE_MAX_WIDTH + margin*2;

  const height =
    GORE_HEIGHT + margin*2;

  const minX =
    -width/2;

  const minY =
    -height/2;

  let svg="";

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

<title>
Star globe gore ${gore+1}/24
</title>

<desc>
HYG v4.1.
Stars brighter than magnitude 5.
J2000.0.
Globe diameter 1000 mm.
24 gores.
</desc>
`;

  /*
   Полезная тонкая центральная ось
   НЕ является частью производственного
   контура.
  */

  if (includeGrid) {

    svg += `
<g id="verification-grid"
   fill="none"
   stroke="#999"
   stroke-width="0.15"
   opacity="0.55">
`;

    /*
     Экватор.
    */

    const eq =
      R*Math.tan(HALF_GORE_RAD);

    svg +=
`<line x1="${(-eq).toFixed(4)}"
 y1="0"
 x2="${eq.toFixed(4)}"
 y2="0"/>
`;

    /*
     Параллели.
    */

    for (
      let lat=-75;
      lat<=75;
      lat+=15
    ) {

      const lr=deg(lat);

      const half =
        R *
        Math.tan(HALF_GORE_RAD) *
        Math.cos(lr);

      const yy =
        svgY(R*lr);

      svg +=
`<line
 x1="${(-half).toFixed(4)}"
 y1="${yy.toFixed(4)}"
 x2="${half.toFixed(4)}"
 y2="${yy.toFixed(4)}"/>
`;
    }

    svg += `</g>`;
  }


  /*
   Контур.
  */

  svg += `
<g id="gore-outline"
   fill="none"
   stroke="#000"
   stroke-width="${OUTLINE_WIDTH}">
`;

  svg +=
`<path d="${left}"/>`;

  svg +=
`<path d="${right}"/>`;

  svg +=
`</g>
`;


  /*
   Звёзды.
  */

  svg += `
<g id="stars"
   fill="#000"
   stroke="none">
`;

  for (const star of stars) {

    if (
      goreIndex(star.raDeg) !== gore
    )
      continue;

    const p =
      project(
        star.raDeg,
        star.decDeg,
        gore
      );

    const r =
      starRadius(star.mag);

    const title =
      `${star.name} | ` +
      `RA ${star.raHours.toFixed(6)} h | ` +
      `Dec ${star.decDeg.toFixed(6)}° | ` +
      `mag ${star.mag.toFixed(2)}`;

    svg +=
`<circle
 cx="${p.x.toFixed(5)}"
 cy="${svgY(p.y).toFixed(5)}"
 r="${r.toFixed(5)}">
<title>${escapeXML(title)}</title>
</circle>
`;
  }

  svg += `
</g>
`;


  /*
   Служебная надпись.
   Она включена только для первого
   превью / проверки.
  */

  if (includeGrid) {

    svg += `
<g
 font-family="Arial,sans-serif"
 font-size="2.5"
 fill="#555">

<text
 x="${(-GORE_MAX_WIDTH/2).toFixed(3)}"
 y="${(GORE_HEIGHT/2).toFixed(3)}">
GORE ${gore+1}/24 — RA ${center.toFixed(1)}°
</text>

</g>
`;
  }

  svg += `</svg>`;

  return svg;
}


function escapeXML(s) {

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


/*
============================================================
 НЕЗАВИСИМАЯ ПРОВЕРКА ВСЕХ ЗВЁЗД
============================================================
*/

function validateStars(stars) {

  let maxError=0;
  let sumError=0;

  let maxBoundaryViolation=0;

  let worst=null;

  let badCount=0;

  for (const star of stars) {

    const g =
      goreIndex(star.raDeg);

    /*
     Плоская координата.
    */

    const p =
      project(
        star.raDeg,
        star.decDeg,
        g
      );

    /*
     Обратное восстановление.
    */

    const inv =
      inverse(
        p.x,
        p.y,
        g
      );

    /*
     Сферическая угловая ошибка.
    */

    const error =
      vectorAngularError(
        star.raDeg,
        star.decDeg,
        inv.raDeg,
        inv.decDeg
      );

    sumError += error;

    if (error > maxError) {

      maxError=error;

      worst={
        star,
        gore:g,
        error
      };
    }

    /*
     Проверяем, не вышла ли звезда
     за границу своего лепестка.
    */

    const margin =
      boundaryError(
        star.raDeg,
        star.decDeg,
        g
      );

    if (margin < -1e-10) {

      badCount++;

      maxBoundaryViolation =
        Math.max(
          maxBoundaryViolation,
          -margin
        );
    }
  }

  return {

    count:stars.length,

    maxErrorArcsec:maxError,

    meanErrorArcsec:
      stars.length ?
      sumError/stars.length :
      0,

    badCount,

    maxBoundaryViolationDeg,

    worst

  };
}


/*
============================================================
 ИСПРАВЛЕННОЕ ИМЯ ПОЛЯ ПРОВЕРКИ
============================================================
*/

function runValidation(stars) {

  let maxError=0;
  let sumError=0;
  let worst=null;

  let boundaryBad=0;
  let maxBoundary=0;

  for (const star of stars) {

    const g =
      goreIndex(star.raDeg);

    const p =
      project(
        star.raDeg,
        star.decDeg,
        g
      );

    const inv =
      inverse(
        p.x,
        p.y,
        g
      );

    const err =
      vectorAngularError(
        star.raDeg,
        star.decDeg,
        inv.raDeg,
        inv.decDeg
      );

    sumError += err;

    if (err > maxError) {

      maxError=err;

      worst={
        star,
        gore:g,
        error:err,
        recoveredRA:inv.raDeg,
        recoveredDec:inv.decDeg
      };
    }

    const margin =
      boundaryError(
        star.raDeg,
        star.decDeg,
        g
      );

    if (margin < 0) {

      boundaryBad++;

      maxBoundary =
        Math.max(
          maxBoundary,
          -margin
        );
    }
  }

  return {

    count:stars.length,

    maxErrorArcsec:maxError,

    meanErrorArcsec:
      stars.length ?
      sumError/stars.length :
      0,

    boundaryBad,
    maxBoundary,

    worst

  };
}


/*
============================================================
 DOWNLOAD
============================================================
*/

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


/*
============================================================
 ОСНОВНОЙ ЗАПУСК
============================================================
*/

async function main() {

  try {

    status(
      "Загрузка HYG v4.1..."
    );

    progress(5);

    /*
     Загружаем официальный CSV.
    */

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

    if (!response.ok) {

      throw new Error(
        "HYG не загрузился: HTTP " +
        response.status
      );
    }

    progress(20);

    const text =
      await response.text();

    progress(55);

    status(
      "CSV загружен. Разбор..."
    );

    const parsed =
      parseCSV(text);

    progress(65);

    const stars =
      prepareStars(
        parsed.rows
      );

    if (!stars.length) {

      throw new Error(
        "Нет звёзд с mag < 5."
      );
    }

    status(
      `Найдено <b>${stars.length}</b>
       звёзд ярче 5m. Выполняется
       независимая проверка координат...`
    );

    progress(72);

    /*
     Проверяем ВСЕ звёзды.
    */

    const check =
      runValidation(stars);

    progress(82);

    /*
     Создаём SVG.
    */

    const svgs=[];

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

      svgs.push(
        buildSVG(
          stars,
          i,
          i===0
        )
      );

      progress(
        82 +
        16*(i+1)/N_GORES
      );
    }

    /*
     Первый лепесток.
    */

    previewEl.innerHTML =
      svgs[0];

    /*
     Статистика.
    */

    const counts =
      Array.from(
        {length:N_GORES},
        (_,g) =>
          stars.filter(
            s =>
              goreIndex(s.raDeg)===g
          ).length
      );

    statsEl.innerHTML = `

<div class="stat">
  CSV строк
  <b>${parsed.rows.length.toLocaleString("ru-RU")}</b>
</div>

<div class="stat">
  Звёзд mag &lt; 5
  <b>${stars.length.toLocaleString("ru-RU")}</b>
</div>

<div class="stat">
  Лепестков
  <b>${N_GORES}</b>
</div>

<div class="stat">
  Ширина лепестка
  <b>${GORE_DEG}°</b>
</div>

<div class="stat">
  Диаметр
  <b>${DIAMETER_MM} мм</b>
</div>

<div class="stat">
  Радиус
  <b>${R} мм</b>
</div>

<div class="stat">
  Длина лепестка
  <b>${GORE_HEIGHT.toFixed(3)} мм</b>
</div>

<div class="stat">
  Максимальная ошибка
  <b class="good">
    ${check.maxErrorArcsec.toExponential(3)}″
  </b>
</div>

<div class="stat">
  Средняя ошибка
  <b class="good">
    ${check.meanErrorArcsec.toExponential(3)}″
  </b>
</div>

`;

    /*
     Проверка.
    */

    let validationHTML = "";

    if (
      check.boundaryBad===0 &&
      check.maxErrorArcsec < 0.001
    ) {

      validationHTML +=
`<span class="good">
✓ ПРОВЕРКА ПРОЙДЕНА
</span><br><br>`;

    }
    else {

      validationHTML +=
`<span class="warn">
⚠ Расчёт выполнен, но обнаружена
заметная ошибка.
</span><br><br>`;

    }

    validationHTML += `

<table>

<tr>
<th>Параметр</th>
<th>Результат</th>
</tr>

<tr>
<td>Проверено звёзд</td>
<td>${check.count}</td>
</tr>

<tr>
<td>Максимальная сферическая ошибка</td>
<td>
${check.maxErrorArcsec.toExponential(6)}″
</td>
</tr>

<tr>
<td>Средняя сферическая ошибка</td>
<td>
${check.meanErrorArcsec.toExponential(6)}″
</td>
</tr>

<tr>
<td>Звёзд за границей лепестка</td>
<td>
${check.boundaryBad}
</td>
</tr>

<tr>
<td>Максимальный выход за границу</td>
<td>
${check.maxBoundary.toExponential(6)}°
</td>
</tr>

</table>
`;

    if (check.worst) {

      validationHTML += `
<br>
Худший случай:
<b>${escapeXML(
  check.worst.star.name
)}</b><br>

RA исходный:
${check.worst.star.raDeg.toFixed(9)}°<br>

Dec исходный:
${check.worst.star.decDeg.toFixed(9)}°<br>

RA восстановленный:
${check.worst.recoveredRA.toFixed(9)}°<br>

Dec восстановленный:
${check.worst.recoveredDec.toFixed(9)}°<br>

Ошибка:
${check.worst.error.toExponential(6)}″
`;
    }

    validationEl.innerHTML =
      validationHTML;


    /*
     Кнопки.
    */

    downloadsEl.innerHTML="";

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

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

      const count =
        counts[i];

      const center =
        goreCenter(i);

      button.textContent =
        `Лепесток ${i+1} — ${count} ★`;

      button.title =
        `Центральный меридиан RA ${center}°`;

      button.onclick =
        () => {

          downloadSVG(
            `star_globe_gore_${String(i+1).padStart(2,"0")}.svg`,
            svgs[i]
          );

        };

      downloadsEl.appendChild(button);
    }


    /*
     Служебная информация.
    */

    status(
`<span class="good">
✓ Готово.
</span>
 HYG v4.1 обработан автоматически.
 Звёзд ярче 5m: <b>${stars.length}</b>.
 Все 24 лепестка рассчитаны.`
    );

    progress(100);

  }

  catch(error) {

    console.error(error);

    progress(0);

    status(
`<span class="bad">
✗ Ошибка:
</span>
${escapeXML(error.message)}

<br><br>

Если браузер запрещает сетевой запрос
для страницы, открытой через
<code>file://</code>, это ограничение
безопасности браузера, а не ошибка
расчёта.
`
    );
  }
}


/*
 Запуск сразу после открытия HTML.
*/

main();

</script>

</body>
</html>