Загрузка данных
<!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>
Центральный меридиан каждого лепестка проходит
через его середину. Границы искривлены согласно
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°
Исключение:
последний/первый лепесток корректно обрабатывает
переход RA = 24h -> RA = 0h.
*/
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;
}
/*
Центральный меридиан лепестка.
*/
function goreCenterLon(index) {
return index * GORE_DEG;
}
/*
Координаты звезды на плоскости лепестка.
x = R * Δλ * cos φ
y = R * φ
y:
северный полюс = -R*pi/2
экватор = 0
южный полюс = +R*pi/2
SVG имеет ось Y вниз, поэтому ниже знак инвертируется.
*/
function projectStar(raDeg, decDeg, goreIndexValue, R) {
const center = goreCenterLon(goreIndexValue);
let delta = wrap180(raDeg - center);
/*
На всякий случай нормализуем ещё раз.
Для данного лепестка должно быть:
-7.5 <= delta <= +7.5
*/
const lambda = degToRad(delta);
const phi = degToRad(decDeg);
const x = R * lambda * Math.cos(phi);
/*
SVG:
верх = север
низ = юг
*/
const y = -R * phi;
return {x, y};
}
/* ------------------------------------------------------------
CSV parser
------------------------------------------------------------ */
/*
CSV parser с поддержкой:
- кавычек
- запятых внутри кавычек
- CRLF/LF
- escaped quotes ""
HYG обычно представляет собой обычный CSV.
*/
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();
}
/*
HYG v4.1 имеет поля:
ra
dec
mag
Но здесь дополнительно допускаются альтернативные
названия, чтобы файл было труднее сломать случайным
переименованием колонок.
*/
function findColumn(headers, candidates) {
for (const candidate of candidates) {
const i = headers.indexOf(candidate);
if (i >= 0) return i;
}
return -1;
}
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;
}
/* ------------------------------------------------------------
Размер звезды
------------------------------------------------------------ */
/*
Визуальный радиус/диаметр зависит от magnitude.
Чем меньше magnitude, тем ярче звезда.
Используется экспоненциальное соответствие,
но ограниченное min/max.
Это не попытка физически представить угловой диаметр
звезды — звёзды не имеют различимого диска на таком
масштабе. Это размер печатной точки.
*/
function starDiameter(mag, minD, maxD) {
/*
При mag=0:
maxD
При mag=5:
близко к minD
*/
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);
/*
SVG viewBox с небольшим геометрическим запасом
НЕ является полем для склейки.
Внутренняя бумажная геометрия всё равно ровно
соответствует диаметру глобуса.
*/
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
*/
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 мм.
Поэтому width/height непосредственно задают
физический размер при печати.
*/
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 в редакторе.
*/
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) {
/*
Один и тот же CSV-row не должен быть
самостоятельно размножен между лепестками.
*/
if (arr.length !== 1) duplicates++;
}
return {
bad,
boundary,
assigned,
duplicates
};
}
/* ------------------------------------------------------------
Построение
------------------------------------------------------------ */
function buildGores(stars, options) {
const selected =
stars.filter(s => s.mag < options.magLimit);
/*
24 массива.
*/
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;
/*
Стилизация download-кнопок.
*/
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>