Загрузка данных
<!DOCTYPE html><html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Звёздный глобус Ø1000 мм — 24 лепестка — J2000</title><style>
body {
margin: 0;
padding: 20px;
font-family: Arial, sans-serif;
background: #eeeeee;
color: #111;
}
.panel {
max-width: 1000px;
margin: 0 auto 20px;
padding: 20px;
background: white;
border-radius: 8px;
}
button {
padding: 12px 18px;
margin: 5px;
border: 1px solid #333;
background: white;
border-radius: 5px;
cursor: pointer;
font-size: 15px;
}
button:hover {
background: #eeeeee;
}
#status {
margin-top: 15px;
padding: 12px;
background: #f5f5f5;
font-family: monospace;
white-space: pre-line;
}
#preview {
overflow: auto;
text-align: center;
}
#preview svg {
width: 100%;
max-width: 500px;
height: auto;
background: white;
border: 1px solid #aaa;
}
.small {
font-size: 13px;
color: #555;
line-height: 1.5;
}
</style></head><body><div class="panel"><h1>Звёздный глобус Ø1000 мм</h1><p>
<b>24 лепестка</b> · <b>J2000</b> · звёзды до <b>5.0m</b>
</p><p class="small">
Чёрные звёзды на белом фоне. Каждый лепесток охватывает 15°
прямого восхождения. SVG имеют физические размеры в миллиметрах.
При печати необходимо использовать масштаб 100%.
</p><button onclick="downloadAll()">
Скачать 24 SVG
</button><button onclick="downloadZIP()">
Скачать ZIP
</button><div id="status">
Загрузка каталога...
</div></div><div class="panel"><h2>Предпросмотр лепестка №1</h2><div id="preview"></div></div><script>
/* =========================================================
ОСНОВНЫЕ ПАРАМЕТРЫ
========================================================= */
const DIAMETER_MM = 1000;
const RADIUS_MM = DIAMETER_MM / 2;
const NUMBER_OF_GORES = 24;
const GORE_DEGREES = 360 / NUMBER_OF_GORES; // 15°
const MAX_MAGNITUDE = 5.0;
/*
* Ширина лепестка по экватору.
*
* Длина окружности = πD
*
* 3141.592 / 24 = 130.900 мм
*/
const EQUATOR_WIDTH =
Math.PI * DIAMETER_MM / NUMBER_OF_GORES;
/*
* Полная длина от полюса до полюса.
*/
const MERIDIAN_LENGTH =
Math.PI * RADIUS_MM;
/*
* Припуск под клапаны.
*/
const TAB_MM = 8;
/*
* Размер внешнего поля.
*/
const MARGIN_MM = 12;
/*
* Каталог Yale Bright Star Catalog.
*
* Используем J2000 координаты.
*/
const CATALOG_URL =
"https://brettonw.github.io/YaleBrightStarCatalog/bsc5.json";
let stars = [];
/* =========================================================
ЗАГРУЗКА КАТАЛОГА
========================================================= */
async function loadCatalog() {
setStatus("Загрузка Yale Bright Star Catalog...");
try {
const response =
await fetch(CATALOG_URL);
if (!response.ok) {
throw new Error(
"HTTP " + response.status
);
}
const data =
await response.json();
stars = [];
for (const s of data) {
const mag =
Number(s.Vmag);
if (!Number.isFinite(mag)) {
continue;
}
if (mag > MAX_MAG) {
continue;
}
/*
* RA.
*/
const rah = Number(s.RAh);
const ram = Number(s.RAm);
const ras = Number(s.RAs);
if (
!Number.isFinite(rah) ||
!Number.isFinite(ram) ||
!Number.isFinite(ras)
) {
continue;
}
const ra =
15 *
(
rah +
ram / 60 +
ras / 3600
);
/*
* DECLINATION.
*
* В BSC знак склонения находится
* отдельно от градусов.
*/
let deg =
Number(s.DEd);
const min =
Number(s.DEm);
const sec =
Number(s.DEs);
if (!Number.isFinite(deg)) {
continue;
}
const absDeg =
Math.abs(deg);
const absMin =
Number.isFinite(min) ? Math.abs(min) : 0;
const absSec =
Number.isFinite(sec) ? Math.abs(sec) : 0;
let dec =
absDeg +
absMin / 60 +
absSec / 3600;
/*
* Определяем знак.
*
* В разных выгрузках BSC знак может
* находиться в DE_ либо быть закодирован
* в самом DED.
*/
let negative = false;
const signFields = [
s.DE_,
s.DEsign,
s.decSign,
s.DE_SIGN
];
for (const field of signFields) {
if (
field !== undefined &&
field !== null
) {
const value =
String(field)
.trim()
.toUpperCase();
if (
value === "-" ||
value === "−" ||
value === "S"
) {
negative = true;
}
if (
value === "+" ||
value === "N"
) {
negative = false;
}
}
}
/*
* Если градусы уже отрицательные.
*/
if (deg < 0) {
negative = true;
}
if (negative) {
dec = -dec;
}
/*
* Защита от ошибок каталога.
*/
if (
ra < 0 ||
ra >= 360 ||
dec < -90 ||
dec > 90
) {
continue;
}
stars.push({
ra: ra,
dec: dec,
mag: mag
});
}
setStatus(
"Каталог загружен.\n" +
"Звёзд с V ≤ " +
MAX_MAG.toFixed(1) +
"m: " +
stars.length
);
renderPreview();
} catch (error) {
console.error(error);
setStatus(
"Ошибка загрузки каталога:\n\n" +
error.message +
"\n\n" +
"Если браузер блокирует загрузку из файла, " +
"запустите HTML через локальный сервер."
);
}
}
/* =========================================================
УГЛЫ
========================================================= */
function degToRad(deg) {
return deg * Math.PI / 180;
}
function radToDeg(rad) {
return rad * 180 / Math.PI;
}
/*
* Приведение угла к диапазону -180...+180.
*/
function signedAngle(deg) {
let x =
((deg + 180) % 360 + 360) % 360 - 180;
return x;
}
/* =========================================================
РАЗМЕР ЗВЕЗДЫ
=========================================================
Чем ярче звезда, тем крупнее чёрная точка.
Важно:
это не реальный угловой диаметр звезды,
а графическое представление её блеска.
========================================================= */
function starRadius(mag) {
/*
* 0m → около 3.6 мм
* 1m → около 3.0 мм
* 2m → около 2.5 мм
* 3m → около 2.0 мм
* 4m → около 1.5 мм
* 5m → около 1.1 мм
*/
const r =
1.0 +
Math.pow(
Math.max(0, 5 - mag) / 5,
0.65
) * 2.6;
return Math.max(
0.9,
Math.min(3.6, r)
);
}
/* =========================================================
ГЕОМЕТРИЯ ЛЕПЕСТКА
=========================================================
Используем "апельсиновую" развертку.
Координаты:
северный полюс
*
/ \
/ \
/ \
/ \
/ \
*-----------*
экватор
\ /
\ /
\ /
\ /
\ /
*
южный полюс
По меридиану расстояния сохраняются:
y = R * latitude
Поперечная координата:
x = R * cos(latitude) * sin(lambda)
Это стандартная практическая конструкция
узкого сферического лепестка.
========================================================= */
function projectPoint(deltaRA, dec) {
const lambda =
degToRad(deltaRA);
const phi =
degToRad(dec);
/*
* Поперёк лепестка.
*/
const x =
RADIUS_MM *
Math.cos(phi) *
Math.sin(lambda);
/*
* От экватора к полюсу.
*/
const y =
RADIUS_MM * phi;
return {
x: x,
y: y
};
}
/* =========================================================
ЦЕНТР ЛЕПЕСТКА
========================================================= */
function goreCenterRA(index) {
return index * GORE_DEGREES;
}
/* =========================================================
ЗВЁЗДЫ ЛЕПЕСТКА
========================================================= */
function starsForGore(index) {
const center =
goreCenterRA(index);
const half =
GORE_DEGREES / 2;
return stars.filter(star => {
const delta =
signedAngle(
star.ra - center
);
return (
delta >= -half - 1e-9 &&
delta <= half + 1e-9
);
});
}
/* =========================================================
ГРАНИЦА ЛЕПЕСТКА
========================================================= */
function goreBoundary(index) {
const half =
degToRad(
GORE_DEGREES / 2
);
const left = [];
const right = [];
/*
* Север → юг.
*/
for (
let i = 0;
i <= 360;
i++
) {
const phi =
Math.PI / 2 -
Math.PI * i / 360;
/*
* Левая граница.
*/
const x =
RADIUS_MM *
Math.cos(phi) *
Math.sin(-half);
const y =
RADIUS_MM * phi;
left.push({
x: x,
y: y
});
}
/*
* Юг → север.
*/
for (
let i = 360;
i >= 0;
i--
) {
const phi =
Math.PI / 2 -
Math.PI * i / 360;
const x =
RADIUS_MM *
Math.cos(phi) *
Math.sin(half);
const y =
RADIUS_MM * phi;
right.push({
x: x,
y: y
});
}
return left.concat(right);
}
/* =========================================================
SVG PATH
========================================================= */
function boundaryPath(points) {
let d = "";
points.forEach(
(p, i) => {
if (i === 0) {
d +=
"M " +
fmt(p.x) +
" " +
fmt(p.y);
} else {
d +=
" L " +
fmt(p.x) +
" " +
fmt(p.y);
}
}
);
d += " Z";
return d;
}
/* =========================================================
КЛАПАНЫ
=========================================================
Клапаны идут вдоль двух длинных сторон.
Они не являются частью точной поверхности:
это припуск для склейки.
========================================================= */
function tabPath(side) {
const half =
degToRad(
GORE_DEGREES / 2
);
const tab = degToRad(
GORE_DEGREES / 2
);
/*
* Используем 8 мм наружу.
*/
const TAB = TAB_MM;
const pts = [];
for (
let i = 0;
i <= 180;
i += 2
) {
const phi =
Math.PI / 2 -
Math.PI * i / 180;
let lambda;
if (side === "left") {
lambda = -half;
} else {
lambda = half;
}
const x =
RADIUS_MM *
Math.cos(phi) *
Math.sin(lambda);
const y =
RADIUS_MM * phi;
/*
* Нормаль наружу в плоскости.
*/
const sign =
side === "left" ? -1 : 1;
pts.push({
x: x + sign * TAB,
y: y
});
}
let d = "";
/*
* Не используем полноценную заливку клапана,
* а строим его контур.
*/
pts.forEach(
(p, i) => {
if (i === 0) {
d +=
"M " +
fmt(p.x) +
" " +
fmt(p.y);
} else {
d +=
" L " +
fmt(p.x) +
" " +
fmt(p.y);
}
}
);
return d;
}
/* =========================================================
SVG ОДНОГО ЛЕПЕСТКА
========================================================= */
function createGoreSVG(index) {
const starsHere =
starsForGore(index);
/*
* Геометрические размеры.
*/
const halfWidth =
RADIUS_MM *
Math.sin(
degToRad(
GORE_DEGREES / 2
)
);
const width =
halfWidth * 2 +
TAB_MM * 2 +
MARGIN_MM * 2;
const height =
MERIDIAN_LENGTH +
MARGIN_MM * 2;
const ox =
width / 2;
const oy =
height / 2;
let svg = "";
svg +=
`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="${fmt(width)}mm"
height="${fmt(height)}mm"
viewBox="${fmt(-width/2)} ${fmt(-height/2)} ${fmt(width)} ${fmt(height)}">
<rect
x="${fmt(-width/2)}"
y="${fmt(-height/2)}"
width="${fmt(width)}"
height="${fmt(height)}"
fill="white"/>
`;
/*
* Смещаем всё содержимое,
* чтобы вокруг был запас.
*/
svg +=
`<g transform="translate(0 0)">
`;
/*
* Сам лепесток.
*/
const boundary =
goreBoundary(index);
const path =
boundaryPath(boundary);
svg +=
`<path
d="${path}"
fill="white"
stroke="black"
stroke-width="0.35"/>
`;
/*
* Звёзды.
*/
for (const star of starsHere) {
const delta =
signedAngle(
star.ra -
goreCenterRA(index)
);
/*
* Звёзды точно на линии разреза
* не рисуем дважды.
*
* Разделяем их по номеру лепестка.
*/
if (
Math.abs(
Math.abs(delta) -
GORE_DEGREES / 2
) < 1e-7
) {
if (index % 2 === 1) {
continue;
}
}
const p =
projectPoint(
delta,
star.dec
);
const r =
starRadius(star.mag);
svg +=
`<circle
cx="${fmt(p.x)}"
cy="${fmt(p.y)}"
r="${fmt(r)}"
fill="black"/>
`;
}
/*
* Пунктирная центральная линия.
*
* Она используется только как ориентир.
*/
svg +=
`<line
x1="0"
y1="${fmt(-MERIDIAN_LENGTH/2)}"
x2="0"
y2="${fmt(MERIDIAN_LENGTH/2)}"
stroke="black"
stroke-width="0.15"
stroke-dasharray="2,2"/>
`;
/*
* Экватор.
*
* Очень тонкая линия для совмещения.
*/
svg +=
`<line
x1="${fmt(-halfWidth)}"
y1="0"
x2="${fmt(halfWidth)}"
y2="0"
stroke="black"
stroke-width="0.15"
stroke-dasharray="2,2"/>
`;
/*
* Отметка северного полюса.
*/
svg +=
`<circle
cx="0"
cy="${fmt(-MERIDIAN_LENGTH/2)}"
r="1.5"
fill="none"
stroke="black"
stroke-width="0.3"/>
`;
/*
* Отметка южного полюса.
*/
svg +=
`<circle
cx="0"
cy="${fmt(MERIDIAN_LENGTH/2)}"
r="1.5"
fill="none"
stroke="black"
stroke-width="0.3"/>
`;
/*
* Клапаны.
*
* Рисуем их только линиями,
* чтобы при вырезании было понятно,
* где сгибать.
*/
const leftTab =
tabPath("left");
const rightTab =
tabPath("right");
svg +=
`<path
d="${leftTab}"
fill="none"
stroke="black"
stroke-width="0.25"
stroke-dasharray="3,2"/>
`;
svg +=
`<path
d="${rightTab}"
fill="none"
stroke="black"
stroke-width="0.25"
stroke-dasharray="3,2"/>
`;
/*
* Номер лепестка.
*/
svg +=
`<text
x="0"
y="${fmt(-MERIDIAN_LENGTH/2 - 5)}"
text-anchor="middle"
font-family="Arial"
font-size="5"
fill="black">
GORE ${index + 1}
</text>
`;
/*
* Диапазон RA.
*/
const ra1 =
normalizeRA(
goreCenterRA(index) -
GORE_DEGREES / 2
);
const ra2 =
normalizeRA(
goreCenterRA(index) +
GORE_DEGREES / 2
);
svg +=
`<text
x="0"
y="${fmt(MERIDIAN_LENGTH/2 + 7)}"
text-anchor="middle"
font-family="Arial"
font-size="3.5"
fill="black">
RA ${formatRA(ra1)} — ${formatRA(ra2)}
</text>
`;
svg +=
`</g>
</svg>`;
return svg;
}
/* =========================================================
НОРМАЛИЗАЦИЯ RA
========================================================= */
function normalizeRA(ra) {
ra =
ra % 360;
if (ra < 0) {
ra += 360;
}
return ra;
}
function formatRA(deg) {
const hours =
normalizeRA(deg) / 15;
return hours.toFixed(2) + "h";
}
/* =========================================================
ОКРУГЛЕНИЕ
========================================================= */
function fmt(n) {
return Number(
n.toFixed(3)
);
}
/* =========================================================
СКАЧИВАНИЕ
========================================================= */
function downloadText(
text,
filename
) {
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
);
}
/* =========================================================
СКАЧАТЬ 24 ФАЙЛА
========================================================= */
async function downloadAll() {
if (!stars.length) {
await loadCatalog();
if (!stars.length) {
return;
}
}
setStatus(
"Создание 24 SVG-файлов..."
);
for (
let i = 0;
i < NUMBER_OF_GORES;
i++
) {
const svg =
createGoreSVG(i);
const name =
"star_globe_" +
String(i + 1).padStart(2, "0") +
"_of_24.svg";
downloadText(
svg,
name
);
await sleep(200);
}
setStatus(
"Готово.\n\n" +
"Создано: 24 SVG\n" +
"Диаметр: 1000 мм\n" +
"Система: J2000\n" +
"Предел: V ≤ 5.0m"
);
}
/* =========================================================
ZIP
========================================================= */
async function downloadZIP() {
if (!stars.length) {
await loadCatalog();
if (!stars.length) {
return;
}
}
setStatus(
"Создание ZIP..."
);
if (!window.JSZip) {
await loadScript(
"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"
);
}
const zip =
new JSZip();
for (
let i = 0;
i < NUMBER_OF_GORES;
i++
) {
const svg =
createGoreSVG(i);
const name =
"star_globe_" +
String(i + 1).padStart(2, "0") +
"_of_24.svg";
zip.file(
name,
svg
);
}
const blob =
await zip.generateAsync({
type: "blob"
});
const url =
URL.createObjectURL(blob);
const a =
document.createElement("a");
a.href = url;
a.download =
"star_globe_1000mm_J2000_24_gores.zip";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(
() => URL.revokeObjectURL(url),
1000
);
setStatus(
"ZIP-файл готов."
);
}
/* =========================================================
PREVIEW
========================================================= */
function renderPreview() {
if (!stars.length) {
return;
}
const svg =
createGoreSVG(0);
const container =
document.getElementById(
"preview"
);
container.innerHTML =
svg.replace(
"<svg ",
'<svg '
);
}
/* =========================================================
ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
========================================================= */
function sleep(ms) {
return new Promise(
resolve =>
setTimeout(
resolve,
ms
)
);
}
function loadScript(src) {
return new Promise(
(resolve, reject) => {
const script =
document.createElement(
"script"
);
script.src = src;
script.onload =
resolve;
script.onerror =
reject;
document.head.appendChild(
script
);
}
);
}
function setStatus(text) {
document.getElementById(
"status"
).textContent = text;
}
/* =========================================================
ЗАПУСК
========================================================= */
loadCatalog();
</script></body>
</html>