logo
홈블로그소개
3,416

Built with Next.js, Bun, Tailwind CSS and Shadcn/UI

ReactJavaScript

react-konva로 2D 맵 시각화 구현하기

Toma
2026년 4월 30일
목차
🎨 Konva란 무엇인가
Konva로 만들 수 있는 것들
🔷 Konva가 지원하는 도형 종류
기본 도형 목록
도형 예시
모든 도형에 공통 적용되는 스타일 props
🖱️ 이벤트 시스템
지원하는 이벤트 종류
이벤트 핸들러 예시
🖐️ 드래그 & 드롭
기본 드래그
드래그 중 시각 피드백
드래그 영역 제한 (dragBoundFunc)
🧱 Konva 핵심 개념: Stage / Layer / Shape
🖥️ 반응형 핵심: 가상 좌표계 + Stage Scale 패턴
스케일 계산 유틸리티
Stage에 적용
📏 실제 단위 → 픽셀 변환
📦 영역 컴포넌트 구현 (Rect + Text + Group)
🔲 INSET 패턴: 인접 도형 경계선 겹침 해결
📐 ResizeObserver로 반응형 컨테이너 크기 감지
🗂️ 다중 레이어로 오버레이 구성
🔧 전체 조합: 완성된 2D 맵 컴포넌트
📌 핵심 패턴 정리
📦 설치
이전 포스트React 라이브러리 패키지 만들기 — tsup 빌드부터 Verdaccio 로컬 배포까지

목차

🎨 Konva란 무엇인가
Konva로 만들 수 있는 것들
🔷 Konva가 지원하는 도형 종류
기본 도형 목록
도형 예시
모든 도형에 공통 적용되는 스타일 props
🖱️ 이벤트 시스템
지원하는 이벤트 종류
이벤트 핸들러 예시
🖐️ 드래그 & 드롭
기본 드래그
드래그 중 시각 피드백
드래그 영역 제한 (dragBoundFunc)
🧱 Konva 핵심 개념: Stage / Layer / Shape
🖥️ 반응형 핵심: 가상 좌표계 + Stage Scale 패턴
스케일 계산 유틸리티
Stage에 적용
📏 실제 단위 → 픽셀 변환
📦 영역 컴포넌트 구현 (Rect + Text + Group)
🔲 INSET 패턴: 인접 도형 경계선 겹침 해결
📐 ResizeObserver로 반응형 컨테이너 크기 감지
🗂️ 다중 레이어로 오버레이 구성
🔧 전체 조합: 완성된 2D 맵 컴포넌트
📌 핵심 패턴 정리
📦 설치

캔버스 기반의 2D 맵을 React에서 구현해야 할 때, Konva와 react-konva는 강력한 선택지입니다. 이 글에서는 실제 구현 경험을 바탕으로, Konva를 처음 사용하는 프론트엔드 개발자가 실용적인 2D 시각화를 구현할 수 있도록 핵심 패턴을 소개합니다.

💡 이 글에서 다루는 내용

  • Konva란 무엇인가, 어떤 것을 만들 수 있는가
  • Konva가 지원하는 다양한 도형과 선 그리기
  • 이벤트 시스템 (클릭, 마우스, 키보드)
  • 드래그 & 드롭 구현
  • Konva의 핵심 개념 (Stage / Layer / Shape 계층 구조)
  • 가상 좌표계 + Scale 기반 반응형 구현 패턴
  • 실제 단위(m, km 등) → 픽셀 좌표 변환
  • 인접 도형 경계선 겹침 해결 (INSET 패턴)
  • ResizeObserver로 컨테이너 크기 반응형 감지
  • 다중 레이어로 오버레이 구성

🎨 Konva란 무엇인가

Konva는 HTML5 Canvas를 쉽게 다룰 수 있게 해주는 JavaScript 라이브러리입니다. 브라우저의 <canvas> API를 직접 사용하면 명령형 코드(ctx.fillRect(...))로만 작성해야 하지만, Konva는 도형을 객체로 관리하고 이벤트, 애니메이션, 드래그&드롭을 간단히 추가할 수 있게 해줍니다.

react-konva는 Konva를 React 컴포넌트 방식으로 사용할 수 있게 해주는 공식 바인딩입니다.

Konva로 만들 수 있는 것들

분야예시
데이터 시각화차트, 맵, 다이어그램, 히트맵
인터랙티브 UI드래그 가능한 보드, 칸반, 화이트보드
그래픽 편집기이미지 편집, 도형 편집기, 설계 도구
게임2D 게임, 퍼즐, 시뮬레이션
시트 배치좌석 배치도, 평면도 편집

💡 핵심 특징

  • 도형을 객체로 관리 → 개별 클릭/이벤트 처리 가능
  • draggable prop 하나로 드래그&드롭 즉시 활성화
  • 레이어 분리로 부분 렌더링 최적화
  • React, Vue, Svelte, Angular 모두 지원

🔷 Konva가 지원하는 도형 종류

Konva는 다양한 기본 도형(Primitive)을 제공합니다.

기본 도형 목록

도형컴포넌트주요 props
사각형Rectx, y, width, height, cornerRadius
원Circlex, y, radius
타원Ellipsex, y, radiusX, radiusY
선 / 다각형 / 곡선Linepoints, closed, tension
화살표Arrowpoints, pointerLength, pointerWidth
정다각형RegularPolygonx, y, sides, radius
별StarnumPoints, innerRadius, outerRadius
텍스트Texttext, fontSize, fontStyle, fontFamily
이미지Imageimage (HTMLImageElement)
SVG PathPathdata (SVG path d 문자열)
호 / 부채꼴ArcinnerRadius, outerRadius, angle
도넛(링)RinginnerRadius, outerRadius
부채꼴Wedgeradius, angle

도형 예시

typescript
import { Stage, Layer, Rect, Circle, Ellipse, Line, Arrow, Star, RegularPolygon, Text } from 'react-konva'

function ShapeShowcase() {
  return (
    <Stage width={600} height={400}>
      <Layer>
        {/* 사각형 (둥근 모서리) */}
        <Rect x={20} y={20} width={100} height={60} fill="#3b82f6" cornerRadius={8} />

        {/* 원 */}
        <Circle x={200} y={60} radius={40} fill="#10b981" />

        {/* 타원 */}
        <Ellipse x={320} y={60} radiusX={60} radiusY={30} fill="#f59e0b" />

        {/* 직선 */}
        <Line points={[20, 150, 200, 150]} stroke="#6b7280" strokeWidth={2} />

        {/* 꺾인 선 (다각형 외곽선) */}
        <Line
          points={[20, 200, 80, 160, 140, 200, 140, 260, 20, 260]}
          closed
          stroke="#ef4444"
          strokeWidth={2}
          fill="rgba(239,68,68,0.1)"
        />

        {/* 부드러운 곡선 (tension 사용) */}
        <Line
          points={[200, 180, 260, 140, 320, 200, 380, 160]}
          stroke="#8b5cf6"
          strokeWidth={3}
          tension={0.5}
        />

        {/* 화살표 */}
        <Arrow
          points={[20, 320, 150, 320]}
          stroke="#1d4ed8"
          fill="#1d4ed8"
          strokeWidth={2}
          pointerLength={10}
          pointerWidth={8}
        />

        {/* 정삼각형 (RegularPolygon, sides=3) */}
        <RegularPolygon x={260} y={310} sides={3} radius={40} fill="#f97316" />

        {/* 별 */}
        <Star
          x={380} y={310}
          numPoints={5}
          innerRadius={20}
          outerRadius={40}
          fill="#eab308"
        />
      </Layer>
    </Stage>
  )
}

💡 **Line**의 활용 범위: closed prop으로 닫힌 다각형을, tension prop으로 부드러운 곡선을 그릴 수 있습니다. 하나의 컴포넌트로 직선·꺾은선·곡선·다각형을 모두 표현할 수 있습니다.

모든 도형에 공통 적용되는 스타일 props

typescript
<Rect
  x={50} y={50} width={200} height={100}

  // 채우기
  fill="#3b82f6"
  opacity={0.8}

  // 테두리
  stroke="#1d4ed8"
  strokeWidth={2}

  // 그림자
  shadowColor="rgba(0,0,0,0.3)"
  shadowBlur={10}
  shadowOffsetX={4}
  shadowOffsetY={4}

  // 회전 / 변환
  rotation={15}           // 도(degree) 단위
  scaleX={1.2}
  offsetX={100}           // 회전 기준점 이동
/>

🖱️ 이벤트 시스템

Konva의 모든 도형은 이벤트 핸들러를 직접 연결할 수 있습니다. DOM 이벤트와 비슷한 방식이라 직관적입니다.

지원하는 이벤트 종류

이벤트설명
onClick / onTap클릭 (모바일 탭)
onDblClick더블클릭
onMouseEnter / onMouseLeave호버 인/아웃
onMouseMove마우스 이동
onMouseDown / onMouseUp마우스 버튼
onContextMenu우클릭 컨텍스트 메뉴
onDragStart / onDragMove / onDragEnd드래그 이벤트

이벤트 핸들러 예시

typescript
import { useState } from 'react'
import { Stage, Layer, Circle, Text } from 'react-konva'

function InteractiveCircle() {
  const [label, setLabel] = useState('도형 위에 마우스를 올려보세요')
  const [color, setColor] = useState('#3b82f6')

  return (
    <Stage width={500} height={300}>
      <Layer>
        <Text x={10} y={10} text={label} fontSize={16} fill="#374151" />
        <Circle
          x={250} y={180} radius={60}
          fill={color}
          // 호버 시 색상 변경 + 커서 변경
          onMouseEnter={(e) => {
            setColor('#1d4ed8')
            setLabel('호버 중!')
            // 캔버스 커서 스타일 변경
            e.target.getStage()!.container().style.cursor = 'pointer'
          }}
          onMouseLeave={(e) => {
            setColor('#3b82f6')
            setLabel('도형 위에 마우스를 올려보세요')
            e.target.getStage()!.container().style.cursor = 'default'
          }}
          // 클릭 이벤트
          onClick={() => setLabel('클릭됨!')}
          // 우클릭
          onContextMenu={(e) => {
            e.evt.preventDefault() // 브라우저 기본 메뉴 방지
            setLabel('우클릭됨!')
          }}
        />
      </Layer>
    </Stage>
  )
}

⚠️ 커서 변경 주의: Konva 도형은 <canvas> 위에 그려지므로 CSS cursor 스타일이 적용되지 않습니다. 대신 e.target.getStage().container().style.cursor로 캔버스 DOM 요소의 커서를 직접 변경해야 합니다.


🖐️ 드래그 & 드롭

Konva에서 드래그&드롭을 활성화하는 방법은 매우 간단합니다. draggable prop 하나면 됩니다.

기본 드래그

typescript
import { useState } from 'react'
import { Stage, Layer, Rect } from 'react-konva'

function DraggableBox() {
  const [pos, setPos] = useState({ x: 50, y: 50 })

  return (
    <Stage width={500} height={400}>
      <Layer>
        <Rect
          x={pos.x}
          y={pos.y}
          width={100}
          height={100}
          fill="#3b82f6"
          draggable                          // ← 이것만 추가하면 드래그 활성화
          onDragEnd={(e) => {               // 드래그 종료 시 위치 저장
            setPos({ x: e.target.x(), y: e.target.y() })
          }}
        />
      </Layer>
    </Stage>
  )
}

드래그 중 시각 피드백

typescript
import { useState } from 'react'
import { Stage, Layer, Star, Text } from 'react-konva'

type ShapeItem = { id: string; x: number; y: number; isDragging: boolean }

const initialItems: ShapeItem[] = [
  { id: 'a', x: 100, y: 100, isDragging: false },
  { id: 'b', x: 250, y: 150, isDragging: false },
  { id: 'c', x: 400, y: 100, isDragging: false },
]

function DraggableStars() {
  const [items, setItems] = useState(initialItems)

  const handleDragStart = (id: string) => {
    setItems((prev) =>
      prev.map((item) => ({ ...item, isDragging: item.id === id }))
    )
  }

  const handleDragEnd = (id: string, x: number, y: number) => {
    setItems((prev) =>
      prev.map((item) =>
        item.id === id ? { ...item, x, y, isDragging: false } : item
      )
    )
  }

  return (
    <Stage width={600} height={300}>
      <Layer>
        <Text text="별을 드래그해보세요" x={10} y={10} fontSize={16} />
        {items.map((item) => (
          <Star
            key={item.id}
            x={item.x}
            y={item.y}
            numPoints={5}
            innerRadius={20}
            outerRadius={40}
            fill="#eab308"
            draggable
            // 드래그 중일 때 크기와 그림자로 시각 피드백
            scaleX={item.isDragging ? 1.3 : 1}
            scaleY={item.isDragging ? 1.3 : 1}
            shadowBlur={item.isDragging ? 15 : 0}
            shadowColor="rgba(0,0,0,0.4)"
            onDragStart={() => handleDragStart(item.id)}
            onDragEnd={(e) => handleDragEnd(item.id, e.target.x(), e.target.y())}
          />
        ))}
      </Layer>
    </Stage>
  )
}

드래그 영역 제한 (dragBoundFunc)

typescript
<Rect
  x={100} y={100} width={80} height={80}
  fill="#10b981"
  draggable
  // 스테이지 경계 안으로만 이동 가능하도록 제한
  dragBoundFunc={(pos) => ({
    x: Math.max(0, Math.min(pos.x, 500 - 80)),
    y: Math.max(0, Math.min(pos.y, 400 - 80)),
  })}
/>

{/* 수평 방향으로만 이동 */}
<Rect
  x={100} y={250} width={80} height={40}
  fill="#f59e0b"
  draggable
  dragBoundFunc={function(pos) {
    return { x: pos.x, y: this.absolutePosition().y }
  }}
/>

💡 dragBoundFunc: 드래그할 때마다 호출되며, 반환한 { x, y } 값으로 도형의 위치가 결정됩니다. 경계 제한, 그리드 스냅, 축 고정 등 다양한 제약을 구현할 수 있습니다.


🧱 Konva 핵심 개념: Stage / Layer / Shape

Konva는 HTML5 Canvas를 추상화한 라이브러리입니다. react-konva는 Konva를 React 컴포넌트 방식으로 사용할 수 있게 해주는 바인딩입니다.

계층 구조는 다음과 같습니다:

javascript
Stage (캔버스 전체 영역)
  └── Layer (도형 그룹)
        └── Shape (Rect, Circle, Text, Group 등)
  • Stage: HTML <canvas> DOM 요소. 실제 픽셀 크기(width, height)와 전역 변환(scaleX, scaleY, x, y)을 담당합니다.
  • Layer: 도형들을 묶는 논리 그룹. 레이어별로 별도 <canvas>가 생성되어 독립적으로 렌더링됩니다.
  • Shape: Rect, Circle, Text, Line, Group 등 실제로 그려지는 요소입니다.

기본 사용 예시:

typescript
import { Stage, Layer, Rect, Text } from 'react-konva'

function SimpleCanvas() {
  return (
    <Stage width={800} height={600}>
      <Layer>
        <Rect x={10} y={10} width={200} height={100} fill="#3b82f6" />
        <Text x={20} y={20} text="Hello Konva" fontSize={18} fill="white" />
      </Layer>
    </Stage>
  )
}

⚠️ 주의: Stage의 width/height는 실제 DOM 픽셀 크기입니다. scaleX/scaleY는 내부 좌표계 전체를 스케일합니다. 이 두 개념을 구분하는 것이 핵심입니다.


🖥️ 반응형 핵심: 가상 좌표계 + Stage Scale 패턴

캔버스를 반응형으로 만드는 방법은 여러 가지가 있습니다. 가장 실용적인 패턴은 가상 좌표계(virtual coordinate system) 를 고정하고, Stage의 scaleX/scaleY로 화면에 맞게 스케일하는 방식입니다.

💡 이 패턴의 핵심 아이디어 모든 도형은 고정된 가상 픽셀 공간(예: 1000px 기준)에서 좌표를 계산하고, Stage가 이 공간을 실제 화면 크기에 맞게 자동으로 늘리거나 줄입니다.

이 방식의 장점:

  • 개별 도형이 반응형을 신경 쓸 필요가 없습니다
  • 컨테이너 크기가 변해도 도형 코드를 수정할 필요가 없습니다
  • 비율(aspect ratio)이 자동으로 유지됩니다

스케일 계산 유틸리티

typescript
const VIRTUAL_WIDTH = 1000  // 가상 캔버스 기준 너비
const CANVAS_PADDING = 30   // 여백

function calcCanvasScale(
  containerW: number,
  containerH: number,
  mapW?: number,
  mapH?: number,
) {
  // 맵 비율에 맞는 가상 높이 계산
  const virtualH = mapW && mapH
    ? VIRTUAL_WIDTH * (mapH / mapW)
    : 600

  // 컨테이너에 맞는 스케일 계산 (패딩 제외)
  const scaleX = (containerW - CANVAS_PADDING * 2) / VIRTUAL_WIDTH
  const scaleY = (containerH - CANVAS_PADDING * 2) / virtualH

  // 비율 유지를 위해 더 작은 스케일 사용
  const scale = Math.min(scaleX, scaleY)

  return {
    scaleX: scale,
    scaleY: scale,
    offsetX: CANVAS_PADDING,
    offsetY: CANVAS_PADDING,
  }
}

Stage에 적용

typescript
<Stage
  width={containerWidth}
  height={containerHeight}
  scaleX={scaleX}
  scaleY={scaleY}
  x={offsetX}
  y={offsetY}
>
  <Layer>
    {/* 도형은 가상 좌표(0~1000)로만 계산 */}
    <Rect x={100} y={50} width={300} height={200} fill="#3b82f6" />
  </Layer>
</Stage>

Rect의 x={100}은 가상 픽셀 기준입니다. Stage의 scaleX가 이를 실제 화면 픽셀로 자동 변환합니다.


📏 실제 단위 → 픽셀 변환

실제 데이터가 미터, 킬로미터 같은 단위를 사용할 때는 이를 가상 픽셀로 변환해야 합니다.

예를 들어 지도의 가로 폭이 50m이고, 가상 캔버스 너비가 1000px이라면:

typescript
const VIRTUAL_WIDTH = 1000

function calcPixelsPerUnit(mapWidthInMeters: number) {
  // 1미터당 가상 픽셀 수
  return VIRTUAL_WIDTH / mapWidthInMeters
}

// 사용 예시
const pxPerMeter = calcPixelsPerUnit(50) // → 20

// 맵에서 x=3m, y=4m 위치, 5m x 6m 크기인 영역의 픽셀 좌표
const region = {
  x: 3 * pxPerMeter,      // → 60
  y: 4 * pxPerMeter,      // → 80
  width: 5 * pxPerMeter,  // → 100
  height: 6 * pxPerMeter, // → 120
}

이 변환 함수를 컴포넌트 외부에 분리해 두면, 좌표 계산 로직이 UI 렌더링과 명확히 분리됩니다.


📦 영역 컴포넌트 구현 (Rect + Text + Group)

실제로 맵에 영역을 그릴 때는 Group으로 관련 도형들을 묶어 관리합니다.

typescript
import { Group, Rect, Text } from 'react-konva'

interface RegionData {
  layout: { x: number; y: number; width: number; height: number } // 미터 단위
  name: string
  color: string
}

interface RegionShapeProps {
  region: RegionData
  pxPerUnit: number  // 단위당 픽셀 수
}

const LABEL_OFFSET = 16
const BORDER_STROKE = 2
const FILL_ALPHA = 0.15
const INSET = 3

function RegionShape({ region, pxPerUnit }: RegionShapeProps) {
  const { x: xUnit, y: yUnit, width: wUnit, height: hUnit } = region.layout

  // 미터 → 가상 픽셀 변환
  const px = xUnit * pxPerUnit
  const py = yUnit * pxPerUnit
  const pw = wUnit * pxPerUnit
  const ph = hUnit * pxPerUnit

  return (
    <Group>
      {/* 채우기 사각형 */}
      <Rect
        x={px + INSET}
        y={py + INSET}
        width={pw - INSET * 2}
        height={ph - INSET * 2}
        fill={region.color}
        opacity={FILL_ALPHA}
      />
      {/* 테두리 사각형 (별도 레이어처럼 분리) */}
      <Rect
        x={px + INSET}
        y={py + INSET}
        width={pw - INSET * 2}
        height={ph - INSET * 2}
        stroke={region.color}
        strokeWidth={BORDER_STROKE}
        fill="transparent"
      />
      {/* 라벨 */}
      <Text
        x={px + LABEL_OFFSET}
        y={py + LABEL_OFFSET}
        text={region.name}
        fontSize={18}
        fontStyle="bold"
        fill={region.color}
      />
    </Group>
  )
}

🔲 INSET 패턴: 인접 도형 경계선 겹침 해결

맵처럼 영역들이 인접해 있는 경우, 경계선이 서로 겹쳐 두 배로 두꺼워 보이는 문제가 생깁니다.

javascript
❌ 문제: 인접한 두 영역의 테두리가 겹침
┌──────┐┌──────┐
│  A   ││  B   │  ← 중간 경계선이 2px × 2 = 4px처럼 보임
└──────┘└──────┘

✅ 해결: 각 영역을 INSET만큼 안쪽으로 축소
┌──────┐ ┌──────┐
│  A  │   │  B  │  ← 간격이 생겨 자연스러운 경계
└──────┘ └──────┘

구현은 간단합니다. 모든 Rect의 x, y에 INSET을 더하고, width, height에서 INSET * 2를 빼면 됩니다:

typescript
const INSET = 3  // 가상 픽셀 단위

<Rect
  x={px + INSET}
  y={py + INSET}
  width={pw - INSET * 2}
  height={ph - INSET * 2}
  fill={color}
/>

💡 CSS의 box-sizing: border-box와 유사한 개념을 캔버스에 직접 적용한 패턴입니다. Canvas API에는 CSS box model이 없으므로 직접 계산해야 합니다.


📐 ResizeObserver로 반응형 컨테이너 크기 감지

Stage는 고정 픽셀 크기가 필요합니다. 컨테이너 크기를 동적으로 감지하려면 ResizeObserver를 사용합니다.

typescript
import { useEffect, useRef, useState } from 'react'

function useContainerDimensions() {
  const ref = useRef<HTMLDivElement>(null)
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 })

  useEffect(() => {
    const el = ref.current
    if (!el) return

    const observer = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect
      setDimensions({ width, height })
    })

    observer.observe(el)

    // 초기 크기 즉시 설정
    const rect = el.getBoundingClientRect()
    setDimensions({ width: rect.width, height: rect.height })

    return () => observer.disconnect()
  }, [])

  return { ref, ...dimensions }
}

💡 getBoundingClientRect()로 초기 크기를 즉시 설정하는 이유는 ResizeObserver가 첫 콜백을 발화하기 전에 크기가 0인 채로 렌더링되는 것을 방지하기 위함입니다.


🗂️ 다중 레이어로 오버레이 구성

Konva의 Layer는 별도의 <canvas>로 분리되어 독립적으로 업데이트됩니다. 이 특성을 활용해 기본 맵과 오버레이를 분리할 수 있습니다.

typescript
<Stage width={width} height={height} scaleX={scale} scaleY={scale}>
  {/* 레이어 1: 기본 맵 영역 (정적, 자주 변경 안 됨) */}
  <Layer>
    {regions.map((region) => (
      <RegionShape key={region.id} region={region} pxPerUnit={pxPerUnit} />
    ))}
  </Layer>

  {/* 레이어 2: 동적 오버레이 (자주 업데이트) */}
  <Layer>
    {regions.map((region) => renderOverlay?.(region, pxPerUnit))}
  </Layer>
</Stage>

✅ 성능 팁: 자주 변경되는 요소를 별도 레이어에 분리하면, 해당 레이어만 재렌더링되어 성능이 향상됩니다. 정적인 배경과 동적인 마커를 레이어로 분리하는 것이 좋은 패턴입니다.


🔧 전체 조합: 완성된 2D 맵 컴포넌트

위 패턴들을 모두 조합한 완성 예시입니다:

typescript
import { Layer, Stage } from 'react-konva'

const VIRTUAL_WIDTH = 1000
const CANVAS_PADDING = 30

function calcPxPerUnit(mapWidthInUnits: number) {
  return VIRTUAL_WIDTH / mapWidthInUnits
}

function calcScale(containerW: number, containerH: number, mapW: number, mapH: number) {
  const virtualH = VIRTUAL_WIDTH * (mapH / mapW)
  const sx = (containerW - CANVAS_PADDING * 2) / VIRTUAL_WIDTH
  const sy = (containerH - CANVAS_PADDING * 2) / virtualH
  const scale = Math.min(sx, sy)
  return { scaleX: scale, scaleY: scale, offsetX: CANVAS_PADDING, offsetY: CANVAS_PADDING }
}

interface MapData {
  widthInMeters: number
  heightInMeters: number
  regions: RegionData[]
}

interface MapViewerProps {
  map: MapData
  renderOverlay?: (region: RegionData, pxPerUnit: number) => React.ReactNode
}

function MapViewer({ map, renderOverlay }: MapViewerProps) {
  const { ref, width, height } = useContainerDimensions()

  const pxPerUnit = calcPxPerUnit(map.widthInMeters)
  const { scaleX, scaleY, offsetX, offsetY } = calcScale(
    width, height,
    map.widthInMeters, map.heightInMeters
  )

  const aspectRatio = map.widthInMeters / map.heightInMeters

  return (
    <div
      ref={ref}
      style={{ width: '100%', aspectRatio: String(aspectRatio) }}
    >
      {width > 0 && height > 0 && (
        <Stage
          width={width}
          height={height}
          scaleX={scaleX}
          scaleY={scaleY}
          x={offsetX}
          y={offsetY}
        >
          <Layer>
            {map.regions.map((region) => (
              <RegionShape
                key={region.id}
                region={region}
                pxPerUnit={pxPerUnit}
              />
            ))}
          </Layer>
          {renderOverlay && (
            <Layer>
              {map.regions.map((region) => renderOverlay(region, pxPerUnit))}
            </Layer>
          )}
        </Stage>
      )}
    </div>
  )
}

📌 핵심 패턴 정리

패턴설명효과
가상 좌표계 + Stage scale모든 도형은 고정 가상 픽셀로 계산반응형 자동 처리
단위 → 픽셀 변환 함수pxPerUnit = VIRTUAL_WIDTH / 실제너비실측 데이터를 좌표로 변환
INSET 패턴각 도형을 안쪽으로 축소인접 경계선 겹침 방지
ResizeObserver컨테이너 크기 동적 감지리사이즈 대응
다중 Layer정적/동적 요소 분리렌더링 성능 최적화
CSS aspectRatio컨테이너 비율 고정맵 비율 보장

✅ 요약: react-konva에서 반응형 맵을 구현할 때의 핵심은 "도형은 가상 좌표로, 변환은 Stage가"입니다. 개별 도형이 화면 크기를 알 필요 없이, Stage의 scale만 조정하면 전체가 자동으로 맞춰집니다.


📦 설치

bash
npm install konva react-konva

⚠️ react-konva는 React 18 기준으로 react-konva@18.x를 사용하세요. React 버전과 react-konva 메이저 버전을 맞춰야 합니다.