← DEVLOG
우주 과학2026.01.117 min read

별자리 지도 — 실제 별 좌표를 항법 지도로

Hipparcos 카탈로그 기반 실제 별 좌표를 구면 투영으로 2D 화면에 그리고, 88개 별자리를 탐색하는 항법 지도 제작기

canvasastronomystar-catalogprojection

시작 — 별들을 실제 좌표로 배치하기

별자리 앱을 만들 때 처음 시도는 적당한 위치에 점을 찍고 선으로 연결하는 것이었습니다. 결과물은 그럴듯해 보였지만, 실제 밤하늘과 비교하면 위치가 달랐습니다.

제대로 만들려면 실제 별 카탈로그가 필요했습니다. 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회로 줄었습니다. 드래그로 시야를 빠르게 이동할 때 체감되는 부드러움이 개선되었습니다.


마치며

코드를 완성하고 오리온자리 중심으로 시야를 맞췄을 때, 삼성(오리온의 허리)이 정확히 일직선으로 배열된 것을 확인했습니다. 수천 년 전 사람들이 이 별들을 보며 사냥꾼의 허리띠를 상상했고, 지금 제 화면 위에서도 같은 좌표로 빛나고 있다는 것 — 그 연속성을 코드로 체험하는 순간이었습니다.

이 포스트와 연결된 콘텐츠

직접 체험하기