Загрузка данных
<!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>
:root {
color-scheme: dark;
--bg:#111318;
--panel:#1b1e26;
--line:#343946;
--text:#eef1f7;
--muted:#9da5b5;
--accent:#62a0ff;
}
* {
box-sizing:border-box;
}
body {
margin:0;
background:var(--bg);
color:var(--text);
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
}
header {
padding:18px 24px;
border-bottom:1px solid var(--line);
}
h1 {
margin:0 0 5px;
font-size:21px;
}
header p {
margin:0;
color:var(--muted);
font-size:13px;
}
main {
display:grid;
grid-template-columns:360px 1fr;
min-height:calc(100vh - 76px);
}
aside {
padding:18px;
background:var(--panel);
border-right:1px solid var(--line);
overflow:auto;
}
section.preview {
padding:20px;
overflow:auto;
}
.box {
border:1px solid var(--line);
border-radius:10px;
padding:14px;
margin-bottom:14px;
}
label {
display:block;
font-size:13px;
margin-bottom:8px;
color:var(--muted);
}
input[type=file] {
width:100%;
}
input[type=number] {
width:100%;
padding:8px;
border:1px solid var(--line);
background:#101218;
color:white;
border-radius:6px;
}
button {
width:100%;
padding:9px 10px;
margin-top:7px;
border:1px solid #465064;
border-radius:7px;
background:#252b37;
color:white;
cursor:pointer;
}
button:hover {
background:#303949;
}
button.primary {
background:#2468d8;
border-color:#3c83f0;
}
button:disabled {
opacity:.45;
cursor:not-allowed;
}
.stats {
font-size:13px;
line-height:1.55;
}
.stats b {
color:white;
}
#svgHost {
display:flex;
justify-content:center;
align-items:flex-start;
min-height:500px;
}
#svgHost svg {
width:min(850px,100%);
height:auto;
background:white;
box-shadow:0 4px 25px #0008;
}
.downloads {
display:grid;
grid-template-columns:repeat(4,1fr);
gap:6px;
margin-top:15px;
}
.downloads button {
margin:0;
font-size:12px;
}
.notice {
color:var(--muted);
font-size:12px;
line-height:1.5;
}
.ok {
color:#67dc93;
}
.warn {
color:#ffca62;
}
.error {
color:#ff7272;
}
@media(max-width:850px) {
main {
grid-template-columns:1fr;
}
aside {
border-right:0;
border-bottom:1px solid var(--line);
}
.downloads {
grid-template-columns:repeat(3,1fr);
}
}
</style>
</head>
<body>
<header>
<h1>Звёздный бумажный глобус Ø1000 мм — 24 лепестка</h1>
<p>HYG 4.1 · J2000.0 · звёзды ярче 5-й величины · SVG 1:1</p>
</header>
<main>
<aside>
<div class="box">
<label>Файл HYG 4.1 CSV</label>
<input
id="csvFile"
type="file"
accept=".csv,text/csv"
>
<button
id="generate"
class="primary"
disabled
>
Построить глобус
</button>
</div>
<div class="box">
<label>Диаметр глобуса, мм</label>
<input
id="diameter"
type="number"
value="1000"
min="100"
step="1"
>
<label style="margin-top:12px">
Порог звёздной величины
</label>
<input
id="magLimit"
type="number"
value="5"
step="0.1"
>
<label style="margin-top:12px">
Размер самой яркой звезды, мм
</label>
<input
id="maxStar"
type="number"
value="3.2"
min="0.2"
step="0.1"
>
<label style="margin-top:12px">
Минимальный размер звезды, мм
</label>
<input
id="minStar"
type="number"
value="0.55"
min="0.1"
step="0.05"
>
</div>
<div class="box stats" id="stats">
CSV ещё не загружен.
</div>
<div class="box">
<button id="downloadCurrent" disabled>
Скачать текущий лепесток SVG
</button>
<div
class="notice"
style="margin-top:10px"
>
SVG имеет физический размер в миллиметрах.
При печати необходимо выбрать «Actual size / 100%»,
а не «Fit to page».
</div>
</div>
<div class="box">
<div class="notice">
<b>Геометрия:</b><br>
24 лепестка × 15° по прямому восхождению.<br>
Центральный меридиан каждого лепестка используется
только как математическая ось проекции и
<b>не рисуется в SVG</b>.<br>
Границы искривлены согласно cos(δ).
<br><br>
<b>Координаты HYG:</b><br>
RA — часы, Dec — градусы, эпоха/equinox J2000.0.
</div>
</div>
</aside>
<section class="preview">
<div id="svgHost">
<div class="notice">
После загрузки CSV здесь появится первый лепесток.
</div>
</div>
<div
class="downloads"
id="downloads"
></div>
</section>
</main>
<script>
"use strict";
/* ============================================================
ЗВЁЗДНЫЙ ГЛОБУС
============================================================
Центральный меридиан НЕ РИСУЕТСЯ.
Он используется только математически для определения
положения звезды внутри соответствующего лепестка.
Геометрическая модель:
R = D / 2
y = R * latitude
x = R * deltaLongitude * cos(latitude)
где:
latitude =
Dec
deltaLongitude =
разность долготы относительно
центральной математической оси лепестка.
Это sinusoidal gore projection.
24 лепестка:
360 / 24 = 15 градусов.
Полоса каждого лепестка:
-7.5° <= deltaLongitude < +7.5°
ВАЖНО:
Центральный меридиан не является графическим элементом.
В SVG остаётся только внешний контур лепестка,
звёзды и белая область бумаги.
*/
const N_GORES = 24;
const FULL_DEG = 360;
const GORE_DEG =
FULL_DEG / N_GORES;
const HALF_GORE_DEG =
GORE_DEG / 2;
let gores = [];
let currentGore = 0;
let sourceFileName = "";
/* ------------------------------------------------------------
Утилиты
------------------------------------------------------------ */
function esc(s) {
return String(s ?? "")
.replace(/&/g,"&")
.replace(/</g,"<")
.replace(/>/g,">")
.replace(/"/g,""");
}
function num(v) {
if (
v === null ||
v === undefined
) {
return NaN;
}
const s =
String(v)
.trim()
.replace(",", ".");
if (!s) {
return NaN;
}
return Number(s);
}
function degToRad(x) {
return x * Math.PI / 180;
}
function radToDeg(x) {
return x * 180 / Math.PI;
}
/*
Приведение долготы к [-180, +180).
*/
function wrap180(deg) {
return (
((deg + 180) % 360 + 360) % 360
- 180
);
}
/*
RA из HYG:
0...24 часов
преобразуется в:
0...360 градусов
*/
function raHoursToDeg(raHours) {
return raHours * 15;
}
/*
Определяем индекс лепестка.
Границы проходят по:
0°, 15°, 30°, ...
Чтобы звезда на границе не попала сразу
в два лепестка, используется half-open interval.
*/
function goreIndex(raDeg) {
raDeg =
((raDeg % 360) + 360) % 360;
let i =
Math.floor(
(raDeg + HALF_GORE_DEG)
/ GORE_DEG
);
/*
Центральные математические оси:
0°, 15°, 30° ...
Они НЕ РИСУЮТСЯ.
*/
i =
((i % N_GORES) + N_GORES)
% N_GORES;
return i;
}
/*
Центральная математическая ось лепестка.
Используется только для расчёта координат.
В SVG эта линия НЕ строится.
*/
function goreCenterLon(index) {
return index * GORE_DEG;
}
/*
Координаты звезды на плоскости лепестка.
x = R * Δλ * cos φ
y = -R * φ
Центральный меридиан здесь является только
математическим нулём координат X.
*/
function projectStar(
raDeg,
decDeg,
goreIndexValue,
R
) {
const center =
goreCenterLon(goreIndexValue);
let delta =
wrap180(
raDeg - center
);
const lambda =
degToRad(delta);
const phi =
degToRad(decDeg);
const x =
R *
lambda *
Math.cos(phi);
const y =
-R * phi;
return {
x,
y
};
}
/* ------------------------------------------------------------
CSV parser
------------------------------------------------------------ */
function parseCSV(text) {
const rows = [];
let row = [];
let cell = "";
let quoted = false;
for (
let i = 0;
i < text.length;
i++
) {
const c =
text[i];
if (quoted) {
if (c === '"') {
if (
text[i + 1] === '"'
) {
cell += '"';
i++;
} else {
quoted = false;
}
} else {
cell += c;
}
} else {
if (c === '"') {
quoted = true;
} else if (c === ',') {
row.push(cell);
cell = "";
} else if (c === '\n') {
row.push(cell);
rows.push(row);
row = [];
cell = "";
} else if (c === '\r') {
/*
CRLF обработается через LF.
*/
} else {
cell += c;
}
}
}
if (
cell.length ||
row.length
) {
row.push(cell);
rows.push(row);
}
return rows;
}
function normaliseHeader(s) {
return String(s ?? "")
.trim()
.replace(/^\uFEFF/, "")
.toLowerCase();
}
function findColumn(
headers,
candidates
) {
for (
const candidate
of candidates
) {
const i =
headers.indexOf(candidate);
if (i >= 0) {
return i;
}
}
return -1;
}
/*
HYG v4.1 имеет поля:
ra
dec
mag
Дополнительно допускаются альтернативные
названия колонок.
*/
function csvToStars(text) {
const rows =
parseCSV(text);
if (!rows.length) {
throw new Error(
"CSV-файл пуст."
);
}
const headers =
rows[0]
.map(normaliseHeader);
const raCol =
findColumn(
headers,
[
"ra",
"right ascension",
"rightascension"
]
);
const decCol =
findColumn(
headers,
[
"dec",
"declination"
]
);
const magCol =
findColumn(
headers,
[
"mag",
"magnitude",
"vmag"
]
);
if (
raCol < 0 ||
decCol < 0 ||
magCol < 0
) {
throw new Error(
"Не найдены необходимые колонки ra, dec, mag. " +
"Это не похоже на HYG CSV."
);
}
const stars = [];
for (
let r = 1;
r < rows.length;
r++
) {
const row =
rows[r];
if (!row.length) {
continue;
}
const ra =
num(row[raCol]);
const dec =
num(row[decCol]);
const mag =
num(row[magCol]);
if (
!Number.isFinite(ra) ||
!Number.isFinite(dec) ||
!Number.isFinite(mag)
) {
continue;
}
if (
ra < 0 ||
ra >= 24
) {
continue;
}
if (
dec < -90 ||
dec > 90
) {
continue;
}
stars.push({
raHours: ra,
raDeg:
raHoursToDeg(ra),
dec,
mag
});
}
return stars;
}
/* ------------------------------------------------------------
Размер звезды
------------------------------------------------------------ */
function starDiameter(
mag,
minD,
maxD
) {
const t =
Math.max(
0,
Math.min(
1,
mag / 5
)
);
const d =
maxD -
(maxD - minD) *
Math.pow(
t,
0.65
);
return Math.max(
minD,
Math.min(
maxD,
d
)
);
}
/* ------------------------------------------------------------
SVG геометрия
------------------------------------------------------------ */
function makeGoreSVG(
index,
stars,
options
) {
const D =
options.diameter;
const R =
D / 2;
/*
Максимальная ширина на экваторе:
2 R * π/24
*/
const halfWidthEquator =
R *
degToRad(
HALF_GORE_DEG
);
const width =
2 *
halfWidthEquator;
const height =
Math.PI * R;
/*
Координатная система SVG:
x = 0 ... width
y = 0 ... height
Центр лепестка:
cx = width/2
cy = height/2
*/
const cx =
width / 2;
const cy =
height / 2;
/*
Кривые границы лепестка:
x =
±R * 7.5° * cos(phi)
y =
-R * phi
Центральный меридиан здесь НЕ РИСУЕТСЯ.
Он используется только как математическая
ось x = cx.
*/
const pointsLeft = [];
const pointsRight = [];
const samples = 180;
for (
let i = 0;
i <= samples;
i++
) {
const phi =
-Math.PI / 2 +
Math.PI *
i /
samples;
const y =
cy -
R * phi;
const x =
R *
degToRad(
HALF_GORE_DEG
) *
Math.cos(phi);
pointsLeft.push([
cx - x,
y
]);
pointsRight.push([
cx + x,
y
]);
}
function pathFromPoints(arr) {
return arr
.map(
(p, i) =>
(
i === 0
? "M "
: "L "
) +
p[0].toFixed(4) +
" " +
p[1].toFixed(4)
)
.join(" ");
}
const leftPath =
pathFromPoints(
pointsLeft
);
const rightPath =
pathFromPoints(
pointsRight
);
/*
Полный контур.
*/
const boundary =
leftPath +
" " +
pathFromPoints(
[...pointsRight].reverse()
) +
" Z";
/*
Полезная SVG-единица = 1 мм.
Центральный меридиан НЕ добавляется
в SVG как отдельный path или line.
*/
let svg = "";
svg +=
`<?xml version="1.0" encoding="UTF-8"?>\n`;
svg +=
`<svg xmlns="http://www.w3.org/2000/svg" ` +
`width="${width.toFixed(4)}mm" ` +
`height="${height.toFixed(4)}mm" ` +
`viewBox="0 0 ${width.toFixed(4)} ${height.toFixed(4)}">\n`;
svg +=
`<title>Star globe gore ${index + 1}/24</title>\n`;
/*
Белая бумага.
*/
svg +=
`<path d="${boundary}" ` +
`fill="white" stroke="none"/>\n`;
/*
Контур резки.
Это единственная геометрическая линия
на лепестке.
Центральный меридиан отсутствует.
*/
svg +=
`<path d="${boundary}" ` +
`fill="none" ` +
`stroke="#000" ` +
`stroke-width="0.15"/>\n`;
/*
Звёзды.
*/
for (
const star
of stars
) {
const p =
projectStar(
star.raDeg,
star.dec,
index,
R
);
const x =
cx + p.x;
const y =
cy + p.y;
const d =
starDiameter(
star.mag,
options.minStar,
options.maxStar
);
const r =
d / 2;
/*
Маленькая звезда —
чёрная печатная точка.
*/
svg +=
`<circle ` +
`cx="${x.toFixed(4)}" ` +
`cy="${y.toFixed(4)}" ` +
`r="${r.toFixed(4)}" ` +
`fill="black">`;
/*
SVG metadata.
*/
svg +=
`<title>` +
`RA ${star.raHours.toFixed(6)}h, ` +
`Dec ${star.dec.toFixed(6)}°, ` +
`mag ${star.mag.toFixed(3)}` +
`</title>`;
svg +=
`</circle>\n`;
}
svg +=
`</svg>`;
return svg;
}
/* ------------------------------------------------------------
Проверка геометрии
------------------------------------------------------------ */
function validateStars(stars) {
let bad = 0;
let boundary = 0;
let assigned = 0;
const seen =
new Map();
for (
const s
of stars
) {
const g =
goreIndex(
s.raDeg
);
if (
g < 0 ||
g >= N_GORES
) {
bad++;
continue;
}
assigned++;
/*
Проверяем обратное преобразование.
*/
const center =
goreCenterLon(g);
const p =
projectStar(
s.raDeg,
s.dec,
g,
1
);
const phi =
-p.y;
const cosPhi =
Math.cos(phi);
let recoveredDelta;
if (
Math.abs(cosPhi) < 1e-12
) {
/*
На полюсе долгота
физически неопределима.
*/
recoveredDelta = 0;
} else {
recoveredDelta =
radToDeg(
p.x / cosPhi
);
}
const recoveredRA =
(
(
center +
recoveredDelta
) %
360 +
360
) % 360;
const errLon =
Math.abs(
wrap180(
recoveredRA -
s.raDeg
)
);
const errDec =
Math.abs(
radToDeg(-p.y) -
s.dec
);
if (
errLon > 1e-9 ||
errDec > 1e-9
) {
bad++;
}
/*
Считаем звёзды у границ.
*/
const delta =
Math.abs(
wrap180(
s.raDeg -
center
)
);
if (
Math.abs(
delta -
HALF_GORE_DEG
) < 1e-8
) {
boundary++;
}
/*
Каждая запись должна назначаться
ровно одному лепестку.
*/
const key =
`${s.raHours}|${s.dec}|${s.mag}`;
if (!seen.has(key)) {
seen.set(
key,
[]
);
}
seen
.get(key)
.push(g);
}
let duplicates = 0;
for (
const [, arr]
of seen
) {
if (
arr.length !== 1
) {
duplicates++;
}
}
return {
bad,
boundary,
assigned,
duplicates
};
}
/* ------------------------------------------------------------
Построение
------------------------------------------------------------ */
function buildGores(
stars,
options
) {
const selected =
stars.filter(
s =>
s.mag <
options.magLimit
);
const result =
Array.from(
{
length:
N_GORES
},
() => []
);
for (
const star
of selected
) {
const g =
goreIndex(
star.raDeg
);
result[g].push(
star
);
}
return result;
}
/* ------------------------------------------------------------
Отображение
------------------------------------------------------------ */
function showGore(index) {
if (!gores.length) {
return;
}
currentGore =
index;
const options =
getOptions();
const svgText =
makeGoreSVG(
index,
gores[index],
options
);
const host =
document.getElementById(
"svgHost"
);
host.innerHTML =
svgText;
document
.getElementById(
"downloadCurrent"
)
.disabled = false;
document
.querySelectorAll(
".downloads button"
)
.forEach(
(button, i) => {
button.classList.toggle(
"primary",
i === index
);
}
);
}
/* ------------------------------------------------------------
Параметры
------------------------------------------------------------ */
function getOptions() {
return {
diameter:
num(
document
.getElementById(
"diameter"
)
.value
),
magLimit:
num(
document
.getElementById(
"magLimit"
)
.value
),
maxStar:
num(
document
.getElementById(
"maxStar"
)
.value
),
minStar:
num(
document
.getElementById(
"minStar"
)
.value
)
};
}
/* ------------------------------------------------------------
Download
------------------------------------------------------------ */
function downloadText(
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
);
}
function downloadGore(index) {
const options =
getOptions();
const svg =
makeGoreSVG(
index,
gores[index],
options
);
const name =
`globe_gore_` +
`${String(index + 1).padStart(2,"0")}` +
`_of_24.svg`;
downloadText(
name,
svg
);
}
/* ------------------------------------------------------------
Интерфейс
------------------------------------------------------------ */
document
.getElementById(
"csvFile"
)
.addEventListener(
"change",
async function() {
const file =
this.files[0];
if (!file) {
return;
}
sourceFileName =
file.name;
const stats =
document.getElementById(
"stats"
);
try {
stats.innerHTML =
"Читаю CSV…";
const text =
await file.text();
const stars =
csvToStars(
text
);
window.__allStars =
stars;
stats.innerHTML =
`<span class="ok">CSV прочитан.</span><br>` +
`Записей со корректными координатами: ` +
`<b>${stars.length.toLocaleString("ru-RU")}</b><br><br>` +
`Файл: <b>${esc(file.name)}</b>`;
document
.getElementById(
"generate"
)
.disabled = false;
} catch (err) {
stats.innerHTML =
`<span class="error">Ошибка:</span><br>` +
`${esc(err.message)}`;
document
.getElementById(
"generate"
)
.disabled = true;
}
}
);
document
.getElementById(
"generate"
)
.addEventListener(
"click",
function() {
const stats =
document.getElementById(
"stats"
);
try {
const options =
getOptions();
if (
!Number.isFinite(
options.diameter
) ||
options.diameter <= 0
) {
throw new Error(
"Диаметр должен быть положительным."
);
}
if (
!Number.isFinite(
options.magLimit
)
) {
throw new Error(
"Неверный предел звёздной величины."
);
}
const all =
window.__allStars ||
[];
const selected =
all.filter(
s =>
s.mag <
options.magLimit
);
gores =
buildGores(
all,
options
);
const validation =
validateStars(
selected
);
/*
Статистика.
*/
let distribution =
"";
for (
let i = 0;
i < N_GORES;
i++
) {
distribution +=
`${i + 1}: ` +
`${gores[i].length.toLocaleString("ru-RU")} ` +
`звёзд<br>`;
}
stats.innerHTML =
`<span class="ok">Готово.</span><br>` +
`Всего в HYG: ` +
`<b>${all.length.toLocaleString("ru-RU")}</b><br>` +
`Ярче ${options.magLimit}: ` +
`<b>${selected.length.toLocaleString("ru-RU")}</b><br>` +
`Распределено: ` +
`<b>${validation.assigned.toLocaleString("ru-RU")}</b><br>` +
`Ошибок обратной проверки: ` +
`<b class="${validation.bad ? "error" : "ok"}">` +
`${validation.bad}</b><br>` +
`Граничных случаев: ` +
`<b>${validation.boundary}</b><br>` +
`Дубликатов назначения: ` +
`<b class="${validation.duplicates ? "warn" : "ok"}">` +
`${validation.duplicates}</b><hr>` +
`<b>Звёзды по лепесткам:</b><br>` +
distribution;
/*
Кнопки 1...24.
*/
const downloads =
document.getElementById(
"downloads"
);
downloads.innerHTML =
"";
for (
let i = 0;
i < N_GORES;
i++
) {
const b =
document.createElement(
"button"
);
b.textContent =
`Лепесток ${i + 1}`;
b.title =
`Показать/скачать лепесток ${i + 1}`;
b.addEventListener(
"click",
() => {
showGore(i);
}
);
downloads.appendChild(
b
);
}
/*
Первый лепесток.
*/
showGore(0);
} catch (err) {
stats.innerHTML =
`<span class="error">Ошибка построения:</span><br>` +
`${esc(err.message)}`;
}
}
);
document
.getElementById(
"downloadCurrent"
)
.addEventListener(
"click",
function() {
if (!gores.length) {
return;
}
downloadGore(
currentGore
);
}
);
</script>
</body>
</html>