별자리 지도 — 실제 별 좌표를 항법 지도로
Hipparcos 카탈로그 기반 실제 별 좌표를 구면 투영으로 2D 화면에 그리고, 88개 별자리를 탐색하는 항법 지도 제작기
시작 — 별들을 실제 좌표로 배치하기
별자리 앱을 만들 때 처음 시도는 적당한 위치에 점을 찍고 선으로 연결하는 것이었습니다. 결과물은 그럴듯해 보였지만, 실제 밤하늘과 비교하면 위치가 달랐습니다.
제대로 만들려면 실제 별 카탈로그가 필요했습니다. Hipparcos 위성 미션에서 구축한 카탈로그는 11만 개 이상의 별에 대한 정밀 좌표와 밝기 데이터를 제공합니다. 그 중 육안으로 보이는 6등성 이하(magnitude ≤ 6.5)의 별들을 추출해 사용했습니다.
좌표계 — 적경과 적위
천문학에서 별의 위치는 적경(RA, Right Ascension)과 적위(Dec, Declination)로 표현합니다. 지구의 경위도 시스템과 유사하지만, 적경은 각도 대신 시(hour)·분·초 단위를 사용합니다.
interface Star {
ra: number; // 적경 (0~360, 도 단위로 변환)
dec: number; // 적위 (-90~90, 도)
mag: number; // 겉보기 등급 (낮을수록 밝음)
hip: number; // Hipparcos 카탈로그 번호
}
카탈로그 원본의 적경은 시각(hour angle)으로 제공되므로, ra_hours × 15로 도(degree)로 변환해 사용합니다.
구면 투영 — 하늘을 평면에 펼치기
구면(천구)의 좌표를 2D Canvas에 그리려면 투영(projection)이 필요합니다. 정사영 투영(Orthographic Projection)을 사용하면 특정 시야 중심에서 바라본 하늘 반구를 원형 영역에 투영할 수 있습니다.
function project(
ra: number,
dec: number,
centerRa: number,
centerDec: number,
scale: number,
W: number,
H: number,
): { x: number; y: number; visible: boolean } {
// 구면 삼각법으로 angular distance 계산
const cosDist =
Math.sin(deg2rad(dec)) * Math.sin(deg2rad(centerDec)) +
Math.cos(deg2rad(dec)) * Math.cos(deg2rad(centerDec)) * Math.cos(deg2rad(ra - centerRa));
if (cosDist < 0) return { x: 0, y: 0, visible: false }; // 뒤쪽
const sinDist = Math.sqrt(1 - cosDist * cosDist);
const dRa = deg2rad(ra - centerRa);
const px = Math.cos(deg2rad(dec)) * Math.sin(dRa);
const py =
Math.sin(deg2rad(dec)) * Math.cos(deg2rad(centerDec)) -
Math.cos(deg2rad(dec)) * Math.sin(deg2rad(centerDec)) * Math.cos(dRa);
return {
x: W / 2 + px * scale,
y: H / 2 - py * scale,
visible: true,
};
}
시야 중심(centerRa, centerDec)을 기준으로 180도 이상 떨어진 별은 visible: false로 처리해 화면 밖을 제외합니다.
별자리 중심 계산 — 각도 평균의 함정
88개 별자리 각각의 화면 중심 좌표를 구할 때 단순 산술 평균은 잘못된 결과를 냅니다. 적경 0도와 360도 근처의 별들을 평균하면 180도 부근이 나와버리는 wrap-around 문제가 있기 때문입니다.
삼각함수를 이용한 방향 평균으로 해결했습니다.
function getConstellationCenter(stars: Star[]): { ra: number; dec: number } {
const ras = stars.map(s => s.ra);
const decs = stars.map(s => s.dec);
const sinSum = ras.reduce((sum, r) => sum + Math.sin((r * Math.PI) / 180), 0);
const cosSum = ras.reduce((sum, r) => sum + Math.cos((r * Math.PI) / 180), 0);
const centerRa = ((Math.atan2(sinSum, cosSum) * 180) / Math.PI + 360) % 360;
const centerDec = decs.reduce((sum, d) => sum + d, 0) / decs.length;
return { ra: centerRa, dec: centerDec };
}
적경은 sin, cos의 벡터 평균으로, 적위는 극점에서의 왜곡이 작아 단순 평균으로 처리했습니다.
미니 별자리 미리보기
별자리 목록에서 선택하기 전 미리보기를 표시하기 위해 drawMiniConstellation 함수를 별도로 구현했습니다. 64×64 오프스크린 캔버스에 해당 별자리의 별과 연결선만 축소 렌더링합니다.
function drawMiniConstellation(
ctx: CanvasRenderingContext2D,
stars: Star[],
lines: [number, number][], // [hipId, hipId] 쌍
size: number,
): void {
// 별자리 경계 박스로 정규화
const minRa = Math.min(...stars.map(s => s.ra));
const maxRa = Math.max(...stars.map(s => s.ra));
const minDec = Math.min(...stars.map(s => s.dec));
const maxDec = Math.max(...stars.map(s => s.dec));
// 경계에 맞게 스케일 조정 후 선·점 그리기
}
드래그와 핀치로 시야 이동
마우스 드래그와 터치 핀치 제스처로 별자리 지도를 탐색할 수 있습니다. 드래그할 때 적경·적위를 직접 조정하지 않고, 화면 좌표 이동량을 각도 변화량으로 변환합니다.
const deltaRa = -(dx / scale) * (180 / Math.PI);
const deltaDec = (dy / scale) * (180 / Math.PI);
적위가 ±90도를 넘지 않도록 클램프하고, 적경은 0~360도로 wrap합니다.
성능 최적화 — rgba 문자열 캐시
별자리 지도는 매 프레임 1,900개 이상의 별을 렌더링합니다. 각 별의 밝기에 따라 rgba(...) 색상 문자열을 생성하는데, 매 프레임마다 이 문자열을 새로 만들면 1,900회 이상의 문자열 할당이 발생합니다. GC(가비지 컬렉션) 압박이 누적되면 간헐적인 프레임 드롭으로 이어집니다.
해결 방법은 Map 기반 캐시였습니다. 밝기 값을 키로 한 번 생성한 rgba 문자열을 캐시에 저장하고 재사용합니다.
const rgbaCache = new Map<number, string>();
function getCachedRgba(mag: number): string {
let cached = rgbaCache.get(mag);
if (!cached) {
const alpha = magToAlpha(mag);
cached = `rgba(255, 255, 255, ${alpha})`;
rgbaCache.set(mag, cached);
}
return cached;
}
밝기 값의 종류는 한정적이므로 캐시 히트율이 약 85%에 달합니다. 매 프레임 1,900회의 문자열 할당이 약 280회로 줄었습니다. 드래그로 시야를 빠르게 이동할 때 체감되는 부드러움이 개선되었습니다.
마치며
코드를 완성하고 오리온자리 중심으로 시야를 맞췄을 때, 삼성(오리온의 허리)이 정확히 일직선으로 배열된 것을 확인했습니다. 수천 년 전 사람들이 이 별들을 보며 사냥꾼의 허리띠를 상상했고, 지금 제 화면 위에서도 같은 좌표로 빛나고 있다는 것 — 그 연속성을 코드로 체험하는 순간이었습니다.