Загрузка данных
<!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,"&")
.replace(/</g,"<")
.replace(/>/g,">")
.replace(/"/g,""")
.replace(/'/g,"'");
}
/*
============================================================
НЕЗАВИСИМАЯ ПРОВЕРКА ВСЕХ ЗВЁЗД
============================================================
*/
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 < 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>