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


<!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>
  body {
    font-family: Arial, sans-serif;
    margin: 30px;
    background: #f3f3f3;
    color: #111;
  }

  h1 { margin-bottom: 8px; }

  button {
    padding: 12px 18px;
    margin: 5px;
    font-size: 15px;
    cursor: pointer;
  }

  #status {
    margin: 15px 0;
    padding: 12px;
    background: white;
    border: 1px solid #ccc;
    white-space: pre-wrap;
  }

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

  .item {
    background: white;
    padding: 10px;
    border: 1px solid #ccc;
  }
</style>
</head>

<body>

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

<p>
  Каталог: Hipparcos / VizieR<br>
  Ограничение: <b>V &lt; 5.0</b><br>
  Эпоха координат: <b>J2000.0</b><br>
  24 лепестка × 15° долготы<br>
  Без клапанов для склейки
</p>

<button onclick="generate()">Получить каталог и построить SVG</button>
<button onclick="downloadAll()">Скачать все 24 SVG</button>

<div id="status">Готово к работе.</div>
<div id="downloads"></div>

<script>

/* ============================================================
   ПАРАМЕТРЫ ГЛОБУСА
   ============================================================ */

const DIAMETER_MM = 1000;
const R_MM = DIAMETER_MM / 2;

const GORES = 24;
const GORE_WIDTH_DEG = 360 / GORES;   // 15°

const LIMIT_MAG = 5.0;

/*
   Эпоха каталога.
   Используем J2000.0, поскольку это стандартная эпоха
   экваториальных координат ICRS/Hipparcos.
*/
const EPOCH = 2000.0;


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

/*
   Чем больше это число, тем точнее аппроксимация
   криволинейных границ лепестка.
*/
const EDGE_STEPS = 180;

/*
   Серые края.
   Это НЕ поля для склейки — только линия реза.
*/
const EDGE_COLOR = "#c8c8c8";
const EDGE_WIDTH = 0.35;


/* ============================================================
   СЮДА ПОПАДУТ ДАННЫЕ
   ============================================================ */

let stars = [];
let generatedSVG = [];


/* ============================================================
   УТИЛИТЫ
   ============================================================ */

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

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

function fmt(v, n = 3) {
  return Number(v).toFixed(n);
}


/*
   Приведение долготы к диапазону [-180, +180)
*/
function normalizeLon(lon) {
  lon = ((lon + 180) % 360 + 360) % 360 - 180;
  return lon;
}


/*
   Перевод RA в градусы.
*/
function raHoursToDeg(h) {
  return Number(h) * 15;
}


/*
   Магнитуда -> размер чёрной звезды.

   Самые яркие звёзды делаем крупнее.
   Это только графический размер символа:
   координаты от него НЕ зависят.
*/
function starRadius(mag) {
  /*
      mag = -1 ... 5

      Получаем примерно:
      -1  -> 2.8 mm
       0  -> 2.4 mm
       1  -> 2.0 mm
       2  -> 1.6 mm
       3  -> 1.2 mm
       4  -> 0.85 mm
       5  -> 0.55 mm
  */

  const r = 2.15 - 0.34 * mag;

  return Math.max(0.55, Math.min(3.0, r));
}


/* ============================================================
   ЗАПРОС К VIZIER / HIPPARCOS
   ============================================================ */

/*
   Используем TAP VizieR.

   Каталог I/239/hip_main — основной каталог Hipparcos.

   Нужны:
     HIP
     RA_ICRS
     DE_ICRS
     Vmag
     pmRA
     pmDE

   В данном варианте положение используется на эпохе J2000.
   Это исключает ненужную зависимость карты от даты печати.
*/

async function getHipparcos() {

  const query = `
SELECT
  HIP,
  RA_ICRS,
  DE_ICRS,
  Vmag,
  pmRA,
  pmDE
FROM "I/239/hip_main"
WHERE Vmag < ${LIMIT_MAG}
ORDER BY Vmag
`;

  const url =
    "https://tapvizier.cds.unistra.fr/TAPVizieR/tap/sync" +
    "?REQUEST=doQuery" +
    "&LANG=ADQL" +
    "&FORMAT=csv" +
    "&QUERY=" +
    encodeURIComponent(query);

  setStatus(
    "Запрашиваю Hipparcos через VizieR...\n" +
    "Отбираются звёзды с V < " + LIMIT_MAG + "."
  );

  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(
      "VizieR вернул HTTP " + response.status
    );
  }

  const text = await response.text();

  return parseCSV(text);
}


/* ============================================================
   ПРОСТОЙ CSV-PARSER
   ============================================================ */

function parseCSV(text) {

  const lines = text
    .trim()
    .split(/\r?\n/)
    .filter(x => x.trim() !== "");

  if (lines.length < 2) {
    throw new Error("Каталог пуст.");
  }

  /*
     Убираем кавычки вокруг полей.
     Для данного ответа VizieR этого достаточно.
  */

  function splitCSV(line) {

    const result = [];
    let current = "";
    let quoted = false;

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

      const c = line[i];

      if (c === '"') {
        quoted = !quoted;
        continue;
      }

      if (c === "," && !quoted) {
        result.push(current.trim());
        current = "";
      } else {
        current += c;
      }
    }

    result.push(current.trim());

    return result;
  }

  const header = splitCSV(lines[0]);

  const index = {};
  header.forEach((h, i) => {
    index[h.trim()] = i;
  });

  /*
     Иногда VizieR добавляет пробелы или слегка меняет
     оформление имён колонок.
  */

  function col(row, name) {
    if (index[name] === undefined) return null;
    return row[index[name]];
  }

  const result = [];

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

    const row = splitCSV(lines[i]);

    const ra = parseFloat(col(row, "RA_ICRS"));
    const dec = parseFloat(col(row, "DE_ICRS"));
    const mag = parseFloat(col(row, "Vmag"));
    const hip = parseInt(col(row, "HIP"), 10);

    if (
      Number.isFinite(ra) &&
      Number.isFinite(dec) &&
      Number.isFinite(mag)
    ) {

      result.push({
        hip: hip,
        ra: ra,
        dec: dec,
        mag: mag
      });
    }
  }

  return result;
}


/* ============================================================
   СИНУСОИДАЛЬНАЯ РАЗВЁРТКА ЛЕПЕСТКА
   ============================================================ */

/*
   Для сферической точки:

       longitude = λ
       latitude  = φ

   в локальной системе лепестка:

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

   где Δλ — долгота относительно центрального меридиана лепестка.

   Это даёт классическую форму лепестка:
   на экваторе он широкий,
   к полюсам сужается до точки.
*/

function projectStar(star, centerLonDeg) {

  let dLon =
    normalizeLon(star.ra - centerLonDeg);

  /*
     Каждая звезда должна принадлежать одному лепестку.
     Полоса лепестка:
       -7.5° ... +7.5°
  */

  const halfWidth = GORE_WIDTH_DEG / 2;

  if (dLon < -halfWidth || dLon > halfWidth) {
    return null;
  }

  const lambda = rad(dLon);
  const phi = rad(star.dec);

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

  const y = R_MM * phi;

  return {
    x: x,
    y: y,
    mag: star.mag,
    hip: star.hip
  };
}


/* ============================================================
   КРИВЫЕ ГРАНИЦЫ ЛЕПЕСТКА
   ============================================================ */

function goreBoundaryPoints(centerLonDeg, side) {

  const halfWidth = GORE_WIDTH_DEG / 2;

  const boundaryLon =
    centerLonDeg + side * halfWidth;

  const points = [];

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

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

    const dLon =
      normalizeLon(boundaryLon - centerLonDeg);

    const x =
      R_MM * rad(dLon) * Math.cos(rad(lat));

    const y =
      R_MM * rad(lat);

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

  return points;
}


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

function gorePath() {

  const left =
    goreBoundaryPoints(0, -1);

  const right =
    goreBoundaryPoints(0, +1);

  /*
     Левая граница идёт от южного полюса к северному.
     Правая — обратно.
  */

  let d = "";

  d += `M ${fmt(left[0].x)} ${fmt(left[0].y)} `;

  for (let p of left) {
    d += `L ${fmt(p.x)} ${fmt(p.y)} `;
  }

  for (let i = right.length - 1; i >= 0; i--) {
    const p = right[i];
    d += `L ${fmt(p.x)} ${fmt(p.y)} `;
  }

  d += "Z";

  return d;
}


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

function makeGoreSVG(goreIndex) {

  /*
     Центральные меридианы:

     0°
     15°
     30°
     ...
     345°
  */

  const centerLon =
    goreIndex * GORE_WIDTH_DEG;

  const halfWidth =
    GORE_WIDTH_DEG / 2;

  const starObjects = [];

  for (const star of stars) {

    let dLon =
      normalizeLon(star.ra - centerLon);

    /*
       Для последнего лепестка учитываем переход
       360° -> 0°.
    */

    if (
      dLon >= -halfWidth &&
      dLon <= halfWidth
    ) {

      const p =
        projectStar(star, centerLon);

      if (p) {
        starObjects.push(p);
      }
    }
  }


  /*
     Размер SVG.

     Используем viewBox непосредственно в миллиметрах.
     Поэтому SVG сохраняет физический размер при
     печати без масштабирования.
  */

  const maxX =
    R_MM * rad(halfWidth);

  const minX = -maxX;
  const maxY = R_MM * Math.PI / 2;
  const minY = -maxY;

  const width =
    maxX - minX;

  const height =
    maxY - minY;


  let svg = "";

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

<rect
 x="${minX}"
 y="${minY}"
 width="${width}"
 height="${height}"
 fill="white"/>

<!-- ЗВЁЗДЫ -->

<g fill="black" stroke="none">`;


  for (const s of starObjects) {

    const r = starRadius(s.mag);

    svg +=
`\n<circle
 cx="${fmt(s.x,4)}"
 cy="${fmt(-s.y,4)}"
 r="${fmt(r,3)}">
<title>HIP ${s.hip}, V=${s.mag.toFixed(2)}</title>
</circle>`;
  }

  svg += `
</g>

<!-- КОНТУР ЛЕПЕСТКА -->

<path
 d="${gorePath()}"
 fill="none"
 stroke="${EDGE_COLOR}"
 stroke-width="${EDGE_WIDTH}"
 stroke-linejoin="round"/>

</svg>`;

  return svg;
}


/* ============================================================
   СОЗДАНИЕ ВСЕХ 24 SVG
   ============================================================ */

function generateAllSVG() {

  generatedSVG = [];

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

    const svg =
      makeGoreSVG(i);

    generatedSVG.push(svg);
  }
}


/* ============================================================
   СОЗДАНИЕ КНОПОК СКАЧИВАНИЯ
   ============================================================ */

function showDownloads() {

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

  container.innerHTML = "";

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

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

    div.className = "item";

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

    title.innerHTML =
      `<b>Лепесток ${String(i+1).padStart(2,"0")}</b>
       <br>
       Центральный меридиан:
       ${(i * GORE_WIDTH_DEG).toFixed(1)}°`;

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

    button.textContent =
      "Скачать SVG";

    button.onclick = () => {

      downloadText(
        generatedSVG[i],
        `globe_gore_${String(i+1).padStart(2,"0")}.svg`,
        "image/svg+xml"
      );

    };

    div.appendChild(title);
    div.appendChild(button);

    container.appendChild(div);
  }
}


/* ============================================================
   СКАЧИВАНИЕ ТЕКСТОВОГО ФАЙЛА
   ============================================================ */

function downloadText(
  text,
  filename,
  mime
) {

  const blob =
    new Blob(
      [text],
      { type: mime }
    );

  const url =
    URL.createObjectURL(blob);

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

  a.href = url;
  a.download = filename;

  document.body.appendChild(a);

  a.click();

  a.remove();

  URL.revokeObjectURL(url);
}


/* ============================================================
   СКАЧАТЬ ВСЕ 24 SVG
   ============================================================ */

function downloadAll() {

  if (generatedSVG.length !== 24) {

    alert(
      "Сначала нажмите «Получить каталог и построить SVG»."
    );

    return;
  }

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

  generatedSVG.forEach((svg, i) => {

    setTimeout(() => {

      downloadText(
        svg,
        `globe_gore_${String(i+1).padStart(2,"0")}.svg`,
        "image/svg+xml"
      );

    }, i * 250);
  });
}


/* ============================================================
   СТАТУС
   ============================================================ */

function setStatus(text) {

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


/* ============================================================
   ГЛАВНАЯ ФУНКЦИЯ
   ============================================================ */

async function generate() {

  try {

    setStatus(
      "Начинаю загрузку точных астрометрических данных..."
    );

    stars =
      await getHipparcos();

    if (!stars.length) {

      throw new Error(
        "Не удалось получить звёзды из Hipparcos."
      );
    }

    setStatus(
      "Получено звёзд: " + stars.length +
      "\n" +
      "Порог: V < " + LIMIT_MAG +
      "\n" +
      "Эпоха: J2000.0" +
      "\n" +
      "Строю 24 лепестка..."
    );

    generateAllSVG();

    showDownloads();

    setStatus(
      "ГОТОВО.\n\n" +
      "Звёзд: " + stars.length +
      "\n" +
      "Лепестков: 24" +
      "\n" +
      "Ширина каждого: 15°" +
      "\n" +
      "Диаметр глобуса: 1000 мм" +
      "\n" +
      "Порог: V < 5.0\n" +
      "Координаты: ICRS / J2000.0\n\n" +
      "SVG можно скачивать и печатать."
    );

  } catch (error) {

    console.error(error);

    setStatus(
      "ОШИБКА:\n\n" +
      error.message +
      "\n\n" +
      "Если файл открыт напрямую через file://, " +
      "браузер может блокировать запрос к VizieR. " +
      "В таком случае запустите локальный HTTP-сервер."
    );
  }
}

</script>

</body>
</html>