Загрузка данных
<!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 < 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>