Загрузка данных
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Звёздный глобус — 24 лепестка — Hipparcos J2000</title>
<style>
body {
margin: 0;
padding: 24px;
font-family: system-ui, sans-serif;
background: #eeeeee;
color: #222;
}
h1 {
font-size: 24px;
margin: 0 0 12px;
}
.panel {
background: white;
padding: 18px;
border-radius: 8px;
max-width: 900px;
}
button {
font-size: 15px;
padding: 9px 14px;
margin: 4px;
cursor: pointer;
}
input {
width: 70px;
padding: 6px;
font-size: 15px;
}
#status {
margin-top: 14px;
padding: 12px;
background: #f5f5f5;
white-space: pre-line;
font-family: monospace;
}
#preview {
margin-top: 20px;
background: white;
padding: 15px;
overflow: auto;
}
.gorePreview {
margin: 10px;
border: 1px solid #ddd;
display: inline-block;
background: white;
}
.small {
color: #666;
font-size: 13px;
}
label {
display: inline-block;
margin: 5px 15px 5px 0;
}
</style>
</head>
<body>
<div class="panel">
<h1>Звёздный глобус Ø 1000 мм</h1>
<p>
Hipparcos · J2000.0 · Johnson V < 5.0 · 24 лепестка
</p>
<div>
<label>
Диаметр шара:
<input id="diameter" type="number" value="1000" min="100">
мм
</label>
<label>
Мин. радиус звезды:
<input id="starMin" type="number" value="0.45" step="0.05">
мм
</label>
<label>
Макс. радиус звезды:
<input id="starMax" type="number" value="2.10" step="0.05">
мм
</label>
</div>
<div>
<label>
Толщина контура:
<input id="edgeWidth" type="number" value="0.25" step="0.05">
мм
</label>
<label>
Контур:
<input id="edgeColor" type="text" value="#c8c8c8">
</label>
</div>
<br>
<button id="loadBtn">
1. Загрузить Hipparcos
</button>
<button id="generateBtn" disabled>
2. Построить 24 SVG
</button>
<button id="downloadBtn" disabled>
3. Скачать 24 SVG
</button>
<div id="status">
Готов к загрузке каталога.
</div>
</div>
<div id="preview"></div>
<script>
"use strict";
/* =========================================================
НАСТРОЙКИ
========================================================= */
const NUM_GORES = 24;
const CATALOG_EPOCH = 1991.25;
const TARGET_EPOCH = 2000.0;
const MAG_LIMIT = 5.0;
/*
Оригинальный Hipparcos Main Catalogue.
CDS/VizieR:
I/239/hip_main
Это полный каталог, а не предварительно
отфильтрованный набор.
*/
const HIP_URL =
"https://cdsarc.cds.unistra.fr/ftp/cats/I/239/hip_main.dat";
/* =========================================================
ГЛОБАЛЬНЫЕ ДАННЫЕ
========================================================= */
let DIAMETER_MM = 1000;
let R = 500;
let STAR_MIN = 0.45;
let STAR_MAX = 2.10;
let EDGE_WIDTH = 0.25;
let EDGE_COLOR = "#c8c8c8";
let stars = [];
let gores = [];
/* =========================================================
МАТЕМАТИКА
========================================================= */
function degToRad(x) {
return x * Math.PI / 180;
}
function radToDeg(x) {
return x * 180 / Math.PI;
}
/*
Приведение угла к [-180, +180)
*/
function normalizeDeg(x) {
x = ((x + 180) % 360 + 360) % 360 - 180;
return x;
}
/*
Ограничение значения
*/
function clamp(x, a, b) {
return Math.max(a, Math.min(b, x));
}
/* =========================================================
ПЕРЕНОС HIPPARCOS J1991.25 -> J2000
=========================================================
В Hipparcos:
RA — градусы
DE — градусы
pmRA — mas/year = mu_alpha * cos(delta)
pmDE — mas/year
Поэтому:
dRA =
pmRA * dt /
(3.6e6 * cos(delta))
dDE =
pmDE * dt /
3.6e6
Здесь dt = 8.75 лет.
Это перенос координат по собственному движению.
*/
function moveToJ2000(raDeg, decDeg, pmRA, pmDE) {
const dt =
TARGET_EPOCH - CATALOG_EPOCH;
let decRad =
degToRad(decDeg);
let cosDec =
Math.cos(decRad);
if (Math.abs(cosDec) < 1e-12) {
cosDec = 1e-12;
}
const dRaDeg =
pmRA * dt /
(3600000 * cosDec);
const dDecDeg =
pmDE * dt /
3600000;
return {
ra:
normalizeDeg(
raDeg + dRaDeg
),
dec:
clamp(
decDeg + dDecDeg,
-90,
90
)
};
}
/* =========================================================
РАЗМЕР ЗВЕЗДЫ
=========================================================
Радиус зависит от V magnitude.
Разница в 5 magnitudes = 100x brightness.
При этом самые слабые звёзды не становятся
настолько маленькими, чтобы исчезнуть при печати.
*/
function starRadius(mag) {
const b =
Math.pow(
10,
-0.4 * mag
);
const bBright =
Math.pow(
10,
-0.4 * (-1.5)
);
const bFaint =
Math.pow(
10,
-0.4 * MAG_LIMIT
);
let t =
(b - bFaint) /
(bBright - bFaint);
t =
clamp(t, 0, 1);
/*
Слегка усиливаем маленькие звёзды.
*/
t =
Math.pow(t, 0.42);
return (
STAR_MIN +
t * (STAR_MAX - STAR_MIN)
);
}
/* =========================================================
ГЕОМЕТРИЯ 24-ЛЕПЕСТКОВ
=========================================================
Каждый gore имеет центральный меридиан:
-172.5
-157.5
...
+172.5
Полуширина:
7.5°
Проекция:
x = R * lambda * cos(phi)
y = R * phi
Это развёртка поверхности шара
вдоль меридианов.
*/
function goreCenterLongitude(index) {
return (
-180 +
(index + 0.5) *
(360 / NUM_GORES)
);
}
function projectStar(star, goreIndex) {
const center =
goreCenterLongitude(
goreIndex
);
const deltaLon =
normalizeDeg(
star.ra -
center
);
const half =
180 / NUM_GORES / 2;
/*
На точном шве выбираем только один
из соседних лепестков.
*/
const EPS =
1e-9;
if (
deltaLon < -half - EPS ||
deltaLon > half + EPS
) {
return null;
}
/*
На правой границе разрешаем звезде
принадлежать следующему лепестку,
кроме последнего.
*/
if (
Math.abs(deltaLon - half) < EPS &&
goreIndex < NUM_GORES - 1
) {
return null;
}
const lambda =
degToRad(deltaLon);
const phi =
degToRad(star.dec);
const x =
R *
lambda *
Math.cos(phi);
const y =
R * phi;
return {
x,
y
};
}
/* =========================================================
КОНТУР ЛЕПЕСТКА
========================================================= */
function goreBoundaryPath() {
const half =
degToRad(
180 / NUM_GORES / 2
);
/*
Шаг 0.25° даёт достаточно
гладкую кривую для печати.
*/
const STEP = 0.25;
const left = [];
const right = [];
for (
let latDeg = -90;
latDeg <= 90.000001;
latDeg += STEP
) {
const lat =
degToRad(
Math.min(
latDeg,
90
)
);
const y =
R * lat;
const x =
R *
half *
Math.cos(lat);
left.push({
x: -x,
y
});
right.push({
x,
y
});
}
let d = "";
/*
Южный полюс
*/
d +=
`M 0 ${(-left[0].y).toFixed(5)} `;
/*
Левая граница: Ю -> С
*/
for (const p of left) {
d +=
`L ${p.x.toFixed(5)} ` +
`${(-p.y).toFixed(5)} `;
}
/*
Правая граница: С -> Ю
*/
for (
let i = right.length - 1;
i >= 0;
i--
) {
const p =
right[i];
d +=
`L ${p.x.toFixed(5)} ` +
`${(-p.y).toFixed(5)} `;
}
d += "Z";
return d;
}
/* =========================================================
ЭКВАТОР
=========================================================
Экватор полезен как контрольная линия
и одновременно облегчает сборку.
Цвет такой же бледно-серый,
но линия немного тоньше.
*/
function equatorPath() {
const half =
R *
degToRad(
180 / NUM_GORES / 2
);
return (
`M ${(-half).toFixed(5)} 0 ` +
`L ${half.toFixed(5)} 0`
);
}
/* =========================================================
ПОЛЮСЫ
=========================================================
Это маленькие поперечные линии у полюсов.
Они не являются "клапанами".
Это только тонкая контрольная разметка.
*/
function poleMarkPaths() {
const half =
R *
degToRad(
180 / NUM_GORES / 2
);
const poleLength =
Math.max(
0.8,
half * 0.20
);
return {
south:
`M ${(-poleLength).toFixed(5)} ` +
`${(R * Math.PI / 2).toFixed(5)} ` +
`L ${poleLength.toFixed(5)} ` +
`${(R * Math.PI / 2).toFixed(5)}`,
north:
`M ${(-poleLength).toFixed(5)} ` +
`${(-R * Math.PI / 2).toFixed(5)} ` +
`L ${poleLength.toFixed(5)} ` +
`${(-R * Math.PI / 2).toFixed(5)}`
};
}
/* =========================================================
SVG ОДНОГО ЛЕПЕСТКА
========================================================= */
function createGoreSVG(index) {
const goreHeight =
Math.PI * R;
const halfWidth =
R *
degToRad(
180 / NUM_GORES / 2
);
const goreWidth =
2 * halfWidth;
/*
Небольшой внешний запас.
Он НЕ является клапаном.
*/
const margin = 4;
const width =
goreWidth + margin * 2;
const height =
goreHeight + margin * 2;
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="${(-goreWidth/2-margin).toFixed(5)}
${(-goreHeight/2-margin).toFixed(5)}
${width.toFixed(5)}
${height.toFixed(5)}">
<!--
Star globe gore ${String(index + 1).padStart(2, "0")}
Diameter: ${DIAMETER_MM} mm
Hipparcos V < 5.0
Coordinates: J2000.0
Projection: sinusoidal gore
-->
<rect
x="${(-goreWidth/2-margin).toFixed(5)}"
y="${(-goreHeight/2-margin).toFixed(5)}"
width="${width.toFixed(5)}"
height="${height.toFixed(5)}"
fill="#ffffff"/>
<g transform="translate(0,0)">
`;
/* =====================================================
ЗВЁЗДЫ
===================================================== */
for (const star of gores[index]) {
const p =
projectStar(
star,
index
);
if (!p) {
continue;
}
const radius =
starRadius(
star.mag
);
svg +=
`<circle
cx="${p.x.toFixed(5)}"
cy="${(-p.y).toFixed(5)}"
r="${radius.toFixed(5)}"
fill="#000000"/>`;
}
/* =====================================================
КОНТУР
===================================================== */
svg +=
`
<path
d="${goreBoundaryPath()}"
fill="none"
stroke="${EDGE_COLOR}"
stroke-width="${EDGE_WIDTH}"
stroke-linejoin="round"
stroke-linecap="round"/>`;
/* =====================================================
ЭКВАТОР
===================================================== */
svg +=
`
<path
d="${equatorPath()}"
fill="none"
stroke="${EDGE_COLOR}"
stroke-width="${Math.min(
EDGE_WIDTH,
0.20
)}"
stroke-linecap="round"/>`;
/* =====================================================
ПОЛЮСЫ
===================================================== */
const poles =
poleMarkPaths();
svg +=
`
<path
d="${poles.south}"
fill="none"
stroke="${EDGE_COLOR}"
stroke-width="${Math.min(
EDGE_WIDTH,
0.20
)}"/>
<path
d="${poles.north}"
fill="none"
stroke="${EDGE_COLOR}"
stroke-width="${Math.min(
EDGE_WIDTH,
0.20
)}"/>`;
svg +=
`
</g>
</svg>
`;
return svg;
}
/* =========================================================
PARSE HIPPARCOS
=========================================================
Формат hip_main.dat:
поля разделены |
Нас интересуют:
[1] HIP
[5] Vmag
[8] RAdeg
[9] DEdeg
[11] Plx
[12] pmRA
[13] pmDE
Структура подтверждается загрузчиком
Skyfield для того же каталога.
*/
function parseHipparcos(text) {
const result = [];
const lines =
text.split(/\r?\n/);
for (const line of lines) {
if (!line.trim()) {
continue;
}
const fields =
line.split("|");
if (fields.length < 14) {
continue;
}
/*
Пропускаем заголовочные/служебные строки.
*/
const hip =
Number(
fields[1]?.trim()
);
const mag =
Number(
fields[5]?.trim()
);
const ra =
Number(
fields[8]?.trim()
);
const dec =
Number(
fields[9]?.trim()
);
const pmRA =
Number(
fields[12]?.trim()
);
const pmDE =
Number(
fields[13]?.trim()
);
if (
!Number.isFinite(hip) ||
!Number.isFinite(mag) ||
!Number.isFinite(ra) ||
!Number.isFinite(dec)
) {
continue;
}
/*
Только V < 5.0.
Строго "ярче пятой величины",
поэтому V=5.000 не включается.
*/
if (!(mag < MAG_LIMIT)) {
continue;
}
/*
Если собственные движения отсутствуют,
звезду всё равно можно оставить:
её координата тогда остаётся
практически исходной.
Для ярких звёзд это редкий случай.
*/
let ra2000 = ra;
let dec2000 = dec;
if (
Number.isFinite(pmRA) &&
Number.isFinite(pmDE)
) {
const pos =
moveToJ2000(
ra,
dec,
pmRA,
pmDE
);
ra2000 =
pos.ra;
dec2000 =
pos.dec;
}
result.push({
hip,
mag,
ra: ra2000,
dec: dec2000,
pmRA,
pmDE
});
}
return result;
}
/* =========================================================
РАСКЛАДКА ПО 24 ЛЕПЕСТКАМ
========================================================= */
function distributeStars() {
gores =
Array.from(
{
length:
NUM_GORES
},
() => []
);
for (const star of stars) {
let assigned = false;
for (
let i = 0;
i < NUM_GORES;
i++
) {
const p =
projectStar(
star,
i
);
if (p !== null) {
gores[i].push(
star
);
assigned = true;
break;
}
}
/*
Полюс.
На самом полюсе x=0 и звезда
геометрически принадлежит всем лепесткам.
Для печати достаточно одного экземпляра
на первом лепестке.
*/
if (
!assigned &&
Math.abs(
Math.abs(star.dec) - 90
) < 1e-7
) {
gores[0].push(star);
}
}
}
/* =========================================================
ЗАГРУЗКА КАТАЛОГА
========================================================= */
async function loadCatalog() {
const status =
document.getElementById(
"status"
);
try {
status.textContent =
"Загружаю полный Hipparcos Main Catalogue...\n" +
HIP_URL;
const response =
await fetch(
HIP_URL,
{
cache: "no-cache"
}
);
if (!response.ok) {
throw new Error(
"HTTP " +
response.status
);
}
const text =
await response.text();
status.textContent =
"Каталог загружен.\n" +
"Разбираю записи V < 5.0...";
stars =
parseHipparcos(
text
);
distributeStars();
let minMag =
Infinity;
let maxMag =
-Infinity;
for (const star of stars) {
minMag =
Math.min(
minMag,
star.mag
);
maxMag =
Math.max(
maxMag,
star.mag
);
}
let distribution = "";
for (
let i = 0;
i < NUM_GORES;
i++
) {
distribution +=
`\n ${String(i+1).padStart(2,"0")}: ` +
`${gores[i].length} звёзд`;
}
status.textContent =
"Готово.\n\n" +
"Каталог: Hipparcos Main Catalogue\n" +
"Исходная эпоха: J1991.25\n" +
"Целевая эпоха: J2000.0\n" +
"Фотометрия: Johnson V\n" +
"Условие: V < 5.0\n" +
"Звёзд: " +
stars.length +
"\n" +
"V диапазон: " +
minMag.toFixed(2) +
" ... " +
maxMag.toFixed(2) +
"\n\nРаспределение:" +
distribution;
document
.getElementById(
"generateBtn"
)
.disabled = false;
} catch (error) {
console.error(
error
);
status.textContent =
"ОШИБКА:\n\n" +
error.message +
"\n\n" +
"Если HTML открыт через file://,\n" +
"запусти его через локальный HTTP-сервер.\n\n" +
"Например:\n" +
"python -m http.server 8000\n\n" +
"и открой:\n" +
"http://localhost:8000/";
}
}
/* =========================================================
ПАРАМЕТРЫ ИЗ UI
========================================================= */
function readSettings() {
DIAMETER_MM =
Number(
document.getElementById(
"diameter"
).value
);
R =
DIAMETER_MM / 2;
STAR_MIN =
Number(
document.getElementById(
"starMin"
).value
);
STAR_MAX =
Number(
document.getElementById(
"starMax"
).value
);
EDGE_WIDTH =
Number(
document.getElementById(
"edgeWidth"
).value
);
EDGE_COLOR =
document.getElementById(
"edgeColor"
).value;
}
/* =========================================================
ПРЕДПРОСМОТР
========================================================= */
function generatePreview() {
readSettings();
const preview =
document.getElementById(
"preview"
);
preview.innerHTML = "";
/*
Показываем первые несколько
лепестков в браузере.
Все 24 SVG всё равно генерируются.
*/
for (
let i = 0;
i < NUM_GORES;
i++
) {
const svg =
createGoreSVG(i);
const div =
document.createElement(
"div"
);
div.className =
"gorePreview";
/*
SVG имеет реальные физические
размеры, но для просмотра
ограничиваем его CSS.
*/
const blob =
new Blob(
[svg],
{
type:
"image/svg+xml"
}
);
const url =
URL.createObjectURL(
blob
);
div.innerHTML =
`<div style="
padding:5px;
font-size:12px;
text-align:center;
color:#666;
">
Лепесток ${i + 1}
</div>
<img
src="${url}"
style="
max-height:420px;
max-width:170px;
display:block;
">`;
preview.appendChild(
div
);
}
document
.getElementById(
"downloadBtn"
)
.disabled = false;
}
/* =========================================================
СКАЧИВАНИЕ ОДНОГО SVG
========================================================= */
function downloadFile(
filename,
content
) {
const blob =
new Blob(
[content],
{
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
),
2000
);
}
/* =========================================================
СКАЧАТЬ 24 ОТДЕЛЬНЫХ SVG
========================================================= */
async function downloadAll() {
readSettings();
if (!gores.length) {
return;
}
const status =
document.getElementById(
"status"
);
status.textContent =
"Генерирую 24 SVG...";
/*
Небольшая задержка между скачиваниями,
чтобы браузер не посчитал их
подозрительной серией.
*/
for (
let i = 0;
i < NUM_GORES;
i++
) {
const svg =
createGoreSVG(i);
const number =
String(
i + 1
).padStart(
2,
"0"
);
downloadFile(
`star_globe_gore_${number}.svg`,
svg
);
await new Promise(
resolve =>
setTimeout(
resolve,
150
)
);
}
status.textContent =
"Готово.\n\n" +
"Скачаны файлы:\n" +
Array.from(
{
length:
NUM_GORES
},
(_, i) =>
`star_globe_gore_${String(i+1).padStart(2,"0")}.svg`
).join("\n");
}
/* =========================================================
КНОПКИ
========================================================= */
document
.getElementById(
"loadBtn"
)
.addEventListener(
"click",
loadCatalog
);
document
.getElementById(
"generateBtn"
)
.addEventListener(
"click",
generatePreview
);
document
.getElementById(
"downloadBtn"
)
.addEventListener(
"click",
downloadAll
);
</script>
</body>
</html>