open-plant 1.4.3 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <p align="center">
6
6
  WebGL2 기반 고성능 WSI(Whole Slide Image) 뷰어 라이브러리<br/>
7
- 고사양 PC가 아니어도, 고작, 고~오작 iPhone 15에서 수백만 cell을 부드럽게 렌더링
7
+ 고사양 PC가 아니어도, 고작, 고~오작 iPhone 15에서 수백~수천만 cell을 부드럽게 렌더링
8
8
  </p>
9
9
 
10
10
  <p align="center">
@@ -29,6 +29,78 @@ https://github.com/user-attachments/assets/5a6b5deb-7442-4389-908f-bf2c69348824
29
29
  범용 시각화 프레임워크 위에 병리 뷰어를 올리면 추상화 비용을 그대로 떠안게 됩니다.
30
30
  Open Plant는 WSI 렌더링 **한 가지만** 하도록 설계되었고, 그래서 아래가 가능합니다.
31
31
 
32
+ ### Open Plant vs deck.gl vs OpenLayers — 숫자로 증명하는 압도적 차이
33
+
34
+ 같은 **합성 포인트 데이터**(랜덤 2D 좌표 + 16-term 팔레트)를 **3개 엔진의 각각 최적 경로**로 나란히 측정했습니다. (Apple M-시리즈, Chrome 실측)
35
+
36
+ - **deck.gl v9** — `ScatterplotLayer` binary accessor (`data.attributes`에 TypedArray 직전달, JS 객체 0개)
37
+ - **OpenLayers v10** — `WebGLVectorLayer` + `RenderFeature` (가장 가벼운 Feature 모델)
38
+ - 벤치마크 소스 & 재현: [`benchmark/`](./benchmark/)
39
+
40
+ #### 렌더 프레임 타임 (ms) — 실측
41
+
42
+ | 포인트 수 | Open Plant avg | deck.gl avg | 배율 | OpenLayers avg | 배율 |
43
+ |---|---|---|---|---|---|
44
+ | **500K** | **6.04 ms** | 69.81 ms | 11.6× | 17.06 ms | 2.8× |
45
+ | **1M** | **11.17 ms** | 139.72 ms | 12.5× | 18.43 ms | 1.7× |
46
+ | **2M** | **12.64 ms** | 265.96 ms | 21.0× | 32.04 ms | 2.5× |
47
+ | **5M** | **29.24 ms** | 639.59 ms | 21.9× | — | — |
48
+ | **10M** | **61.45 ms** | 1,203.25 ms | 19.6× | — | — |
49
+
50
+ > Open Plant는 모든 구간에서 deck.gl 대비 **12~22×** 빠릅니다. 2M까지 avg **12ms대**(~80fps), 5M에서 **29ms**(~34fps), 10M에서도 **61ms**(~16fps)를 유지합니다. deck.gl은 1M에서 이미 **140ms/frame** — 7fps도 안 나옵니다.
51
+ > OpenLayers는 2M까지는 측정 가능하지만 5M 이상에서는 Feature 빌드 단계에서 실패합니다.
52
+
53
+ #### First draw (초기화 + 첫 렌더)
54
+
55
+ | 포인트 수 | Open Plant | deck.gl | OpenLayers |
56
+ |---|---|---|---|
57
+ | **500K** | 48.1 ms | **4.3 ms** | 466.3 ms |
58
+ | **1M** | 22.6 ms | **20.9 ms** | 1,151.1 ms |
59
+ | **2M** | 26.8 ms | **15.6 ms** | 2,442.8 ms |
60
+ | **5M** | 44.5 ms | **16.7 ms** | — |
61
+ | **10M** | 90.0 ms | **21.5 ms** | — |
62
+
63
+ > deck.gl binary accessor는 first draw가 4~21ms로 가장 빠르지만, 이후 **프레임마다** 인스턴스 파이프라인 비용이 누적되어 sustained fps가 급락합니다.
64
+ > OpenLayers는 first draw에 RenderFeature 빌드 + WebGL 버퍼 구성이 포함되어, 2M에서 **2.4초**, 5M 이상은 실행 불가.
65
+
66
+ #### p99 프레임 타임 (worst-case jank)
67
+
68
+ | 포인트 수 | Open Plant p99 | deck.gl p99 | OpenLayers p99 |
69
+ |---|---|---|---|
70
+ | **500K** | **12.78 ms** | 119.40 ms | 41.70 ms |
71
+ | **1M** | **13.96 ms** | 287.30 ms | 48.00 ms |
72
+ | **2M** | **15.74 ms** | 322.20 ms | 109.40 ms |
73
+ | **5M** | **37.00 ms** | 1,384.30 ms | — |
74
+ | **10M** | **76.09 ms** | 2,495.20 ms | — |
75
+
76
+ #### 메모리 & 파이프라인
77
+
78
+ | 지표 | Open Plant | deck.gl (binary) | OpenLayers (RenderFeature) |
79
+ |---|---|---|---|
80
+ | **GPU 버퍼/점** | **10 B** (xy + palette idx) | 16 B (xyz + RGBA) | ~320 B (RenderFeature obj) |
81
+ | **Draw calls** | **1** (`gl.POINTS`) | N (인스턴스 배치 분할) | N |
82
+ | **JS 객체** | **0개** | **0개** | count개 RenderFeature |
83
+ | **빌드 비용 (2M)** | **0 ms** | ~16 ms | **152 ms** |
84
+ | **색상 변경** | 팔레트 텍스처 **64 B** 재업로드 | 전체 컬러 버퍼 재전송 | 스타일 재평가 + 버퍼 리빌드 |
85
+ | **hit-index** | **0 ms** main (Worker 오프로드) | 메인 스레드 피킹 빌드 | 메인 스레드 피킹 |
86
+
87
+ #### 왜 이렇게 차이가 나는가
88
+
89
+ | Open Plant 설계 | 범용 엔진이 버릴 수 없는 비용 |
90
+ |---|---|
91
+ | WSI 전용 직교 투영 → 투영 분기 없음 | 지리 좌표·투영·줌 레벨 범용 처리 |
92
+ | 팔레트 인덱스 1장 → 색상 버퍼 자체가 없음 | 피처별 RGBA + 스타일 콜백/파이프라인 |
93
+ | `gl.POINTS` 1-draw → 인스턴스 분할 없음 | 레이어 추상화 + 데이터 분할 |
94
+ | TypedArray 직통 GPU → JS 객체 0개 | Feature/RenderFeature 객체 모델 |
95
+ | hit-index/ROI 클립 전량 Worker | 메인 스레드 피킹 + 스타일 resolve |
96
+
97
+ deck.gl binary accessor(JS 객체 0개)를 써도 **1M 포인트에서 140ms/frame** — 7fps도 안 나옵니다.
98
+ OpenLayers는 가장 가벼운 `RenderFeature`를 써도 **5M 이상에서 실행 자체가 불가능**.
99
+ **Open Plant는 10M 포인트를 61ms/frame(~16fps)로 렌더** — deck.gl 대비 **19.6×** 빠릅니다.
100
+
101
+ > **직접 재현:** `cd benchmark && npm i && npx vite` → 브라우저에서 포인트 수 선택. Open Plant은 GPU timer query(또는 readPixels fence) 측정, deck.gl/OL은 rAF-to-rAF 측정.
102
+ > 내부 최적화 히스토리: [`perf-optimization-report.md`](./perf-optimization-report.md) · 운영 기본값: [`performance-optimization.md`](./performance-optimization.md)
103
+
32
104
  ### 모바일 실전 성능 (iPhone 15)
33
105
 
34
106
  Open Plant는 “고사양 PC에서만 빠른 뷰어”가 아니라, iPhone 15 같은 일반 플래그십 모바일에서도
@@ -45,8 +117,8 @@ Open Plant는 `Float32Array`(x, y) 8바이트 + `Uint16Array`(palette index) 2
45
117
 
46
118
  ### 프래그먼트 셰이더 안에서 끝나는 링 렌더링
47
119
 
48
- `gl.POINTS` + `gl_PointCoord`로 원형 마스킹하고, ring width를 `clamp(3.0 / pointSize, 0.12, 0.62)`로 줌에 따라 적응시킵니다.
49
- 안티앨리어싱도 `smoothstep(1.5 / pointSize)` 기반으로 프래그먼트 셰이더 안에서 처리하기 때문에 하드웨어 MSAA를 끌 수 있습니다.
120
+ `gl.POINTS` + `gl_PointCoord`로 원형 마스킹하고, ring 두께는 `uPointStrokeScale * mix(0.18, 0.35, smoothstep(3.0, 16.0, uPointSize))`로 줌·스케일에 따라 적응합니다.
121
+ 가장자리 안티앨리어싱은 `aa = 1.5 / max(1.0, uPointSize)`와 `smoothstep`으로 프래그먼트 셰이더 안에서 처리합니다.
50
122
  고배율에서는 얇은 링으로 개별 세포를 구분하고, 저배율에서는 두꺼운 링으로 밀집 영역이 묻히지 않습니다.
51
123
  별도 geometry 없이 draw call **1회**로 전체 포인트를 그립니다.
52
124
 
@@ -77,7 +149,7 @@ draw mode에 진입하면 `setPointerCapture`로 입력을 독점해 팬(드래
77
149
  | **회전 인터랙션** | `WsiViewState.rotationDeg`, `Ctrl/Cmd + drag` 회전, `resetRotation` 경로 |
78
150
  | **줌 범위 제어 + 전환 애니메이션** | `minZoom`/`maxZoom` clamp + `viewTransition`(duration/easing) |
79
151
  | **배율 스냅 줌** | `zoomSnaps`, `zoomSnapFitAsMin`으로 wheel/더블클릭을 표준 배율 단계에 맞춰 이동 |
80
- | **포인트 오버레이** | WebGL2 `gl.POINTS`로 수십, 수백만 포인트를 팔레트 텍스처 기반 컬러링. 파싱된 TypedArray만 입력 |
152
+ | **포인트 오버레이** | WebGL2 `gl.POINTS` + 팔레트 텍스처. `WsiPointData`: `positions`/`paletteIndices` 필수, 선택 `fillModes`, `ids`, `drawIndices`(부분 draw) |
81
153
  | **포인트 크기 커스터마이즈** | `pointSizeByZoom` 객체로 zoom별 셀(px) 크기 지정 + 내부 선형 보간 |
82
154
  | **포인트 내부 채움 제어** | `pointInnerFillOpacity`로 ring 내부 채움 강도 제어 |
83
155
  | **포인트 렌더 모드 제어** | `pointData.fillModes`로 ring/solid 렌더링 제어 |
@@ -91,10 +163,10 @@ draw mode에 진입하면 `setPointerCapture`로 입력을 독점해 팬(드래
91
163
  | **ROI 포인트 클리핑** | `clipMode`: `sync` / `worker` / `hybrid-webgpu` (실험) |
92
164
  | **ROI 통계 API** | `computeRoiPointGroups()` + `onRoiPointGroups` 콜백 |
93
165
  | **ROI 커스텀 오버레이** | `resolveRegionStrokeStyle`, `overlayShapes` |
94
- | **포인트 Hit-Test** | `onPointHover`, `onPointClick`, `getCellByCoordinatesRef`로 좌표→cell 매핑 |
166
+ | **포인트 Hit-Test** | `PointLayer`의 `onHover`/`onClick`, `ref.queryAt(coord)`로 좌표→cell 매핑 |
95
167
  | **WebGPU 연산 경로** | WebGPU capability 체크 + ROI bbox prefilter compute(실험) |
96
168
  | **오버뷰 미니맵** | 썸네일 + 현재 뷰포트 인디케이터, 클릭/드래그 네비게이션 |
97
- | **React 바인딩** | `<WsiViewerCanvas>`, `<DrawLayer>`, `<OverviewMap>` 컴포넌트 제공 |
169
+ | **React 바인딩** | `<WsiViewer>` + 레이어(`PointLayer`, `RegionLayer`, `DrawingLayer` 등), `useViewerContext`, `<DrawLayer>`, `<OverviewMap>`, `<TileViewerCanvas>` |
98
170
  | **좌표 변환** | `screenToWorld()` / `worldToScreen()` 양방향 좌표 변환 |
99
171
  | **인증 지원** | Bearer 토큰 패스스루로 프라이빗 타일/포인트 엔드포인트 접근 |
100
172
 
@@ -107,253 +179,183 @@ npm run dev:example
107
179
 
108
180
  브라우저에서 `http://localhost:5174` 접속.
109
181
 
182
+ 같은 LAN의 다른 기기에서 접속하려면 `example/vite.config.ts`에 `server.host: true`가 설정되어 있어야 하며(현재 예제 기본값), 방화벽에서 해당 포트를 허용합니다.
183
+
184
+ ## 좌표계 (카메라)
185
+
186
+ - **World**: WSI 이미지 픽셀 좌표 (원점은 이미지 정의에 따름, 일반적으로 좌상단).
187
+ - **Screen**: 캔버스 내부 좌표 (`OrthoCamera`의 viewport = 캔버스 CSS 픽셀 × devicePixelRatio에 맞춘 내부 크기).
188
+ - **Clip (NDC)**: WebGL 클립 공간 −1~1. `OrthoCamera.getMatrix()`가 World → Clip 3×3 동차 변환을 반환하며, 타일/포인트 vertex 셰이더의 `uCamera`에 전달됩니다.
189
+ - **JS 변환**: `screenToWorld` / `worldToScreen`은 휠 줌 피벗, 히트 테스트 등 CPU 측 단일 점 변환에 사용됩니다.
190
+
110
191
  ## Project Structure
111
192
 
112
193
  ```
113
194
  src/
114
- ├── core/ # WebGL2 저수준 렌더링 엔진
115
- │ ├── gl-utils.ts # 셰이더 컴파일, 프로그램 링킹
116
- │ ├── ortho-camera.ts # 2D 직교 카메라 (translate + zoom)
117
- └── m1-tile-renderer.ts # 기본 타일 렌더러
118
- ├── wsi/ # WSI 전용 로직
119
- ├── wsi-tile-renderer.ts # 멀티 티어 타일 + 포인트 렌더러
120
- │ ├── point-clip.ts # ROI 포인트 클리핑
121
- │ ├── point-clip-worker-client.ts # ROI 워커 클리핑 클라이언트
122
- │ ├── point-clip-hybrid.ts # WebGPU + polygon 하이브리드 클리핑(실험)
123
- │ ├── point-hit-index-worker-client.ts # 포인트 공간 인덱스 워커 클라이언트
124
- │ ├── point-hit-index-worker-protocol.ts # 인덱스 워커 메시지 프로토콜
125
- │ ├── webgpu.ts # WebGPU capability/compute 유틸
126
- │ ├── image-info.ts # 이미지 메타데이터 정규화
127
- └── utils.ts # 팔레트, 색상, 토큰 유틸리티
195
+ ├── core/
196
+ │ ├── gl-utils.ts # 셰이더 컴파일·프로그램 링크
197
+ │ ├── ortho-camera.ts # 2D 직교 카메라 (pan / zoom / rotation, World↔Screen, getMatrix)
198
+ ├── m1-tile-renderer.ts # 단순 타일 그리드 데모용 렌더러
199
+ │ └── types.ts
200
+ ├── wsi/
201
+ │ ├── wsi-tile-renderer.ts # WebGL2 멀티티어 타일 + 포인트, 입력, 애니메이션
202
+ │ ├── wsi-render-pass.ts # 프레임당 draw 순서: fallback 타일 → visible 타일 → 포인트
203
+ │ ├── wsi-shaders.ts # 타일·포인트 GLSL 프로그램 초기화
204
+ │ ├── wsi-point-data.ts # 포인트 VBO 업로드 (positions / terms / fillModes / drawIndices)
205
+ │ ├── wsi-interaction.ts # 포인터·휠·스냅 이벤트 처리
206
+ │ ├── wsi-input-handlers.ts # interaction lock 등 래핑
207
+ │ ├── wsi-zoom-snap.ts # 배율 스냅 애니메이션
208
+ ├── wsi-view-ops.ts # zoomBy, fit, clamp 등 뷰 수학
209
+ │ ├── wsi-view-animation.ts # 일반 뷰 보간 애니메이션
210
+ │ ├── wsi-tile-visibility.ts # 뷰 바운드·타일 가시성
211
+ │ ├── wsi-tile-cache.ts # 타일 텍스처 LRU 트림
212
+ │ ├── wsi-canvas-lifecycle.ts # 컨텍스트 lost/restored
213
+ │ ├── tile-scheduler.ts # 타일 fetch 큐, createImageBitmap, 재시도
214
+ │ ├── point-clip.ts # 메인스레드 ROI 클리핑
215
+ │ ├── point-clip-worker-client.ts / point-clip-worker-protocol.ts
216
+ │ ├── point-clip-hybrid.ts # WebGPU bbox prefilter (실험)
217
+ │ ├── point-hit-index-*.ts # 포인트 공간 해시 인덱스 (워커)
218
+ │ ├── roi-geometry.ts / roi-term-stats.ts / brush-stroke.ts
219
+ │ ├── image-info.ts / types.ts / utils.ts / wkt.ts / webgpu.ts / constants.ts
220
+ │ └── …
128
221
  ├── workers/
129
- │ ├── roi-clip-worker.ts # ROI point-in-polygon worker
130
- │ └── point-hit-index-worker.ts # 포인트 공간 인덱스 빌드 worker
131
- └── react/ # React 컴포넌트
132
- ├── wsi-viewer-canvas.tsx # 전체 기능 WSI 뷰어
133
- ├── draw-layer.tsx # 드로잉 오버레이
134
- └── overview-map.tsx # 미니맵
222
+ │ ├── roi-clip-worker.ts
223
+ │ └── point-hit-index-worker.ts
224
+ └── react/
225
+ ├── wsi-viewer.tsx # WsiTileRenderer + ViewerContext (권장 엔트리)
226
+ ├── viewer-context.ts # rendererRef, worldToScreen, 오버레이 등록
227
+ ├── point-layer.tsx # 포인트 데이터·클리핑·히트 테스트
228
+ ├── region-layer.tsx # ROI Canvas2D
229
+ ├── drawing-layer.tsx # 드로잉 툴 진입 시 오버레이
230
+ ├── draw-layer.tsx # 실제 Canvas2D 드로잉 구현
231
+ ├── patch-layer.tsx / overlay-layer.tsx
232
+ ├── overview-map.tsx
233
+ ├── tile-viewer-canvas.tsx # 타일만 있는 경량 뷰어
234
+ ├── use-point-clipping.ts / use-point-hit-test.ts / …
235
+ └── wsi-viewer-canvas-types.ts # 포인트/포인터 이벤트 타입
135
236
  ```
136
237
 
137
238
  ## React Components
138
239
 
139
- ### `<WsiViewerCanvas>`
140
-
141
- 전체 기능을 갖춘 WSI 뷰어 컴포넌트. 실사용 시 대부분의 기능은 이 컴포넌트 하나로 제어합니다.
142
-
143
- ```jsx
144
- import { WsiViewerCanvas } from "open-plant";
145
-
146
- <WsiViewerCanvas
147
- source={imageSource}
148
- viewState={viewState}
149
- imageColorSettings={{ brightness: 0, contrast: 0, saturation: 0 }}
150
- ctrlDragRotate
151
- rotationResetNonce={rotationResetNonce}
152
- minZoom={0.25} // 미지정 시 fitZoom * 0.5
153
- maxZoom={1} // 미지정 시 fitZoom * 8
154
- viewTransition={{ duration: 300 }}
155
- zoomSnaps={[1.25, 2.5, 5, 10, 20, 40]}
156
- zoomSnapFitAsMin
157
- authToken={bearerToken}
158
- pointData={pointPayload}
159
- pointPalette={termPalette.colors}
160
- pointSizeByZoom={{
161
- 1: 2.8,
162
- 6: 8.4,
163
- 10: 17.5,
164
- 12: 28,
165
- }}
166
- pointInnerFillOpacity={0.15}
167
- clipPointsToRois
168
- clipMode="worker"
169
- onClipStats={(s) => console.log(s.mode, s.durationMs)}
170
- drawTool={drawTool}
171
- drawFillColor="transparent"
172
- activeRegionId={selectedRoiId} // controlled: 외부에서 active ROI 제어
173
- onActiveRegionChange={setSelectedRoiId} // 내부 클릭/탭 선택 변경 알림
174
- resolveRegionLabelStyle={({ zoom }) => ({
175
- offsetY: zoom > 4 ? -20 : -10,
176
- })}
177
- autoLiftRegionLabelAtMaxZoom
178
- drawAreaTooltip={{
179
- enabled: true,
180
- cursorOffset: { x: 16, y: -24 },
181
- format: (areaMm2) => `${areaMm2.toFixed(3)} mm²`,
182
- }}
183
- brushOptions={{
184
- radius: 32, // HTML/CSS px (zoom이 바뀌어도 화면에서 고정)
185
- edgeDetail: 1.6, // 값이 클수록 더 둥글고 섬세한 브러시 경계
186
- edgeSmoothing: 2, // 계단 현상 감소(0~4)
187
- clickSelectRoi: true, // brush에서 "클릭만" 하면 ROI 선택 우선
188
- }}
189
- stampOptions={{
190
- rectangleAreaMm2: 2,
191
- circleAreaMm2: 0.2, // HPF 예시
192
- rectanglePixelSize: 4096,
193
- }}
194
- patchRegions={patchRegions}
195
- patchStrokeStyle={{ color: "#8ad8ff", lineDash: [10, 8], width: 2 }}
196
- customLayers={[
197
- {
198
- id: "patch-labels",
199
- render: ({ worldToScreen }) => {
200
- /* host overlay */
201
- },
202
- },
203
- ]}
204
- onPointerWorldMove={(e) => console.log(e.coordinate)}
205
- onRoiPointGroups={(stats) => console.log(stats.groups)}
206
- onDrawComplete={(result) => {
207
- if (result.intent === "roi") handleRoi(result);
208
- if (result.intent === "brush") handleBrush(result);
209
- }}
210
- onPatchComplete={(patch) => {
211
- // stamp-rectangle-4096px 전용
212
- handlePatch(patch);
213
- }}
214
- overviewMapConfig={{
215
- show: true,
216
- options: {
217
- viewportBorderStyle: "dash",
218
- viewportBorderColor: "rgba(255, 106, 61, 0.95)",
219
- viewportFillColor: "rgba(255, 106, 61, 0.08)",
220
- },
221
- }}
222
- onViewStateChange={handleViewChange}
223
- onStats={setStats}
224
- />
225
- ```
226
-
227
- #### 동작 규약 (중요)
228
-
229
- - `mpp`(microns per pixel)는 스탬프 mm² 환산에 사용됩니다. 미지정 시 물리 크기는 근사치입니다.
230
- - `imageColorSettings`는 타일 레이어에만 적용됩니다. 포인트/ROI/드로잉은 영향받지 않습니다.
231
- - ROI hit-test는 **contour + nametag 영역** 기준입니다. ROI 내부 fill은 클릭/hover 영역에서 제외됩니다.
232
- - `activeRegionId`를 주면 controlled mode, 생략하면 uncontrolled mode로 동작합니다.
233
- - `minZoom`/`maxZoom`은 휠/더블클릭/`setViewState`/`fitToImage` 전 경로에 동일 clamp가 적용됩니다.
234
- - `viewTransition`은 `setViewState`/`fitToImage`/`zoomBy` 전환에 적용되며 `duration` 최대값은 `2000ms`입니다.
235
- - `zoomSnaps`는 배율(magnification) 배열 입력이며 `source.mpp`를 기준으로 내부 zoom 값으로 정규화됩니다.
236
- - `zoomSnapFitAsMin=true`이면 snap-out 경로에서 fit zoom을 하한으로 취급합니다.
237
- - `drawFillColor` 기본값은 `transparent`입니다.
238
- - `brushOptions.radius`는 HTML/CSS px 기준이며, 줌이 바뀌어도 on-screen 크기는 고정됩니다.
239
- - `brushOptions.clickSelectRoi=true`이면 브러시 탭(드래그 없음) 시 ROI를 먼저 선택하고, ROI 외부 탭은 일반 브러시 결과를 반환합니다.
240
- - `autoLiftRegionLabelAtMaxZoom=true`이면 `maxZoom` 도달 시 라벨이 위로 `20px` 애니메이션 이동하고, 이탈 시 원위치로 내려옵니다.
241
- - `drawAreaTooltip.enabled=true`이면 freehand/rectangle/circular 그리기 중 커서 근처에 실시간 면적(mm²)을 표시합니다.
242
- - `roiRegions[].coordinates`는 ring / polygon(with holes) / multipolygon을 모두 지원합니다.
240
+ **v1.4+** 공개 API는 `<WsiViewer>`와 **자식 레이어 컴포지션**입니다.
241
+ 과거 단일 컴포넌트 `WsiViewerCanvas`는 **이 저장소·npm 패키지에서 제거**되었습니다. props 매핑표는 [`docs/migration-1.4.0.md`](./docs/migration-1.4.0.md)와 [정적 문서](https://frorong.github.io/open-plant/)를 참고하세요.
243
242
 
244
- #### WsiViewerCanvas Props by concern
243
+ ### `<WsiViewer>`
245
244
 
246
- **View / Camera**
245
+ `WsiTileRenderer` + WebGL 캔버스 + Canvas2D 오버레이 + `ViewerContextProvider`를 구성합니다. 타일·카메라·줌 스냅·포인터 world 스트림 등은 여기서 제어하고, 포인트/ROI/드로잉은 **자식 레이어**로 붙입니다.
247
246
 
248
247
  | Prop | Type | Notes |
249
248
  |---|---|---|
250
- | `source` | `WsiImageSource \| null` | 필수 입력 메타데이터 |
251
- | `viewState` | `Partial<WsiViewState> \| null` | 외부 제어 시점 |
252
- | `onViewStateChange` | `(next) => void` | 내부 변경 통지 |
253
- | `fitNonce` | `number` | 변경 fit 재실행 |
254
- | `rotationResetNonce` | `number` | 변경회전 0도 |
249
+ | `source` | `WsiImageSource \| null` | 필수 메타데이터 |
250
+ | `viewState` | `Partial<WsiViewState> \| null` | 외부 제어 |
251
+ | `onViewStateChange` | `(next: WsiViewState) => void` | 변경 통지 |
252
+ | `imageColorSettings` | `WsiImageColorSettings \| null` | 타일에만 적용 (BC/S) |
253
+ | `fitNonce` | `number` | 증가fit 재실행 |
254
+ | `rotationResetNonce` | `number` | 증가 시 회전 0° |
255
+ | `authToken` | `string` | 타일 fetch Bearer 등 |
255
256
  | `ctrlDragRotate` | `boolean` | 기본 `true` |
256
257
  | `minZoom` / `maxZoom` | `number` | 미지정 시 `fitZoom*0.5` / `fitZoom*8` |
257
- | `viewTransition` | `{ duration?: number; easing?: (t)=>number }` | 기본 즉시 반영(duration 0) |
258
- | `zoomSnaps` | `number[]` | 확대 배율 스냅 목록(배율 단위) |
259
- | `zoomSnapFitAsMin` | `boolean` | 스냅 아웃 시 fit zoom을 하한으로 취급할지 여부 |
260
- | `authToken` | `string` | 타일/포인트 요청 인증 |
261
- | `overviewMapConfig` | `OverviewMapConfig` | 미니맵 표시/옵션 |
258
+ | `viewTransition` | `WsiViewTransitionOptions` | `setViewState`/`fitToImage` 전환 |
259
+ | `zoomSnaps` | `number[]` | 배율 배열 `mpp` 기준 내부 zoom으로 정규화 |
260
+ | `zoomSnapFitAsMin` | `boolean` | 스냅 아웃 시 fit을 하한으로 |
261
+ | `onStats` | `(WsiRenderStats) => void` | 프레임 통계 |
262
+ | `onTileError` | `(WsiTileErrorEvent) => void` | 타일 로드 실패 |
263
+ | `onContextLost` / `onContextRestored` | `() => void` | WebGL 컨텍스트 |
264
+ | `onPointerWorldMove` | `(PointerWorldMoveEvent) => void` | 포인터 world 좌표 |
265
+ | `debugOverlay` | `boolean` | 간단한 디버그 패널 |
266
+ | `className` / `style` | — | 루트 컨테이너 |
267
+ | `children` | `ReactNode` | 아래 레이어 컴포넌트 |
268
+
269
+ ```tsx
270
+ import {
271
+ DrawingLayer,
272
+ OverviewMap,
273
+ OverlayLayer,
274
+ PatchLayer,
275
+ PointLayer,
276
+ RegionLayer,
277
+ useViewerContext,
278
+ WsiViewer,
279
+ } from "open-plant";
280
+
281
+ <WsiViewer source={source} viewState={vs} onViewStateChange={setVs} authToken={token} zoomSnaps={[...]} zoomSnapFitAsMin>
282
+ <PointLayer data={pointData} palette={palette} sizeByZoom={sizes} clipEnabled clipToRegions={rois} clipMode="worker" />
283
+ <RegionLayer regions={rois} ... />
284
+ <DrawingLayer tool={drawTool} ... />
285
+ <PatchLayer regions={patches} ... />
286
+ <OverlayLayer shapes={overlayShapes} />
287
+ </WsiViewer>
288
+ ```
262
289
 
263
- **Tile / Point / Clip**
290
+ 미니맵은 보통 `WsiViewer` **밖**에서 `useViewerContext()`로 `rendererRef`를 넘기거나, 예제처럼 래퍼 컴포넌트로 `<OverviewMap projectorRef={rendererRef} invalidateRef={overviewInvalidateRef} ... />`를 둡니다 (`example/src/App.tsx` 참고).
264
291
 
265
- | Prop | Type | Notes |
266
- |---|---|---|
267
- | `imageColorSettings` | `WsiImageColorSettings \| null` | brightness/contrast/saturation 입력 범위 `[-100, 100]` |
268
- | `pointData` | `WsiPointData \| null` | `positions`, `paletteIndices` 필수 |
269
- | `pointPalette` | `Uint8Array \| null` | RGBA 팔레트 텍스처 |
270
- | `pointSizeByZoom` | `Record<number, number>` | continuous zoom stop |
271
- | `pointStrokeScale` | `number` | point ring 두께 스케일 |
272
- | `pointInnerFillOpacity` | `number` | 포인트 내부 채움 불투명도 (`0..1`) |
273
- | `clipPointsToRois` | `boolean` | ROI 외부 포인트 필터 |
274
- | `clipMode` | `"sync" \| "worker" \| "hybrid-webgpu"` | 기본 `"worker"` |
275
- | `onClipStats` | `(event) => void` | clip 실행 통계 |
276
- | `onRoiPointGroups` | `(stats) => void` | ROI term 통계 |
277
- | `roiPaletteIndexToTermId` | `ReadonlyMap<number,string> \| readonly string[]` | ROI term 매핑 |
278
-
279
- **ROI / Draw / Overlay**
292
+ ### 레이어 컴포넌트 (요약)
280
293
 
281
- | Prop | Type | Notes |
282
- |---|---|---|
283
- | `roiRegions` / `roiPolygons` | `WsiRegion[]` / `DrawRegionCoordinates[]` | 영속 ROI 입력 |
284
- | `patchRegions` | `WsiRegion[]` | patch 전용 표시 채널 |
285
- | `interactionLock` | `boolean` | pan/zoom 잠금 |
286
- | `drawTool` | `DrawTool` | 기본 `"cursor"` |
287
- | `stampOptions` | `StampOptions` | mm² / 고정 px stamp 크기 |
288
- | `brushOptions` | `BrushOptions` | 브러시 궤적/커서/탭 선택 |
289
- | `drawFillColor` | `string` | draw preview fill, 기본 `transparent` |
290
- | `regionStrokeStyle` / `regionStrokeHoverStyle` / `regionStrokeActiveStyle` | `Partial<RegionStrokeStyle>` | ROI 외곽선 스타일 |
291
- | `patchStrokeStyle` | `Partial<RegionStrokeStyle>` | patch 선 스타일 |
292
- | `resolveRegionStrokeStyle` | `RegionStrokeStyleResolver` | 상태별 동적 stroke |
293
- | `regionLabelStyle` | `Partial<RegionLabelStyle>` | 기본 배지 스타일 override |
294
- | `regionLabelAnchor` | `RegionLabelAnchorMode` | 라벨 anchor 모드 (`top-center`, `centroid`) |
295
- | `resolveRegionLabelStyle` | `RegionLabelStyleResolver` | 줌/region별 동적 라벨 스타일 |
296
- | `autoLiftRegionLabelAtMaxZoom` | `boolean` | max zoom 도달 시 라벨 auto-lift |
297
- | `clampRegionLabelToViewport` | `boolean` | 라벨 화면 경계 clamp 적용 |
298
- | `drawAreaTooltip` | `DrawAreaTooltipOptions` | draw 중 실시간 mm² tooltip |
299
- | `overlayShapes` | `DrawOverlayShape[]` | 커스텀 도형/반전 마스크 |
300
- | `customLayers` | `WsiCustomLayer[]` | host React 오버레이 슬롯 |
301
- | `activeRegionId` | `string \| number \| null` | controlled active ROI |
302
-
303
- **Events / Refs**
294
+ | 컴포넌트 | 역할 |
295
+ |---|---|
296
+ | **PointLayer** | `WsiPointData` + 팔레트, zoom별 크기, ROI 클리핑(`clipMode`: `sync` / `worker` / `hybrid-webgpu`). `ref` `queryAt(coord)` imperative hit |
297
+ | **RegionLayer** | ROI contour/라벨 Canvas2D, hover/active, hit-test |
298
+ | **DrawingLayer** | 드로잉 모드 진입·휠 전달 등; 내부에서 **DrawLayer** 사용 |
299
+ | **DrawLayer** | 실제 freehand/rect/circle/brush/stamp 구현 |
300
+ | **PatchLayer** | 패치 ROI 전용 스트로크 |
301
+ | **OverlayLayer** | `DrawOverlayShape` 반전 채움 |
304
302
 
305
- | Prop | Type | Notes |
306
- |---|---|---|
307
- | `onStats` | `(stats: WsiRenderStats) => void` | 프레임 통계 |
308
- | `onTileError` | `(event: WsiTileErrorEvent) => void` | 타일 로드 실패 |
309
- | `onContextLost` / `onContextRestored` | `() => void` | WebGL 컨텍스트 이벤트 |
310
- | `onPointerWorldMove` | `(event) => void` | world 좌표 포인터 스트림 |
311
- | `onPointHover` / `onPointClick` | `(event) => void` | 포인트 hit 이벤트 |
312
- | `getCellByCoordinatesRef` | `MutableRefObject<(coord)=>PointHitEvent \| null>` | imperative 좌표 hit-test |
313
- | `onRegionHover` / `onRegionClick` | `(event) => void` | region hit 이벤트 |
314
- | `onActiveRegionChange` | `(regionId) => void` | active 변경 통지 |
315
- | `onDrawComplete` | `(result: DrawResult) => void` | `intent: "roi" \| "patch" \| "brush"` |
316
- | `onPatchComplete` | `(result: PatchDrawResult) => void` | `stamp-rectangle-4096px` 전용 |
317
- | `className` / `style` | `string` / `CSSProperties` | 컨테이너 스타일 |
318
-
319
- ### `<DrawLayer>`
320
-
321
- 독립 오버레이 드로잉 컴포넌트입니다. `WsiViewerCanvas` 내부에서 자동 사용되지만, 필요하면 별도로 직접 사용할 수 있습니다.
322
-
323
- - 지원 툴: `freehand`, `rectangle`, `circular`, `brush`, `stamp-*`
324
- - 브러시는 화면 픽셀 기준 반경 + `edgeDetail`/`edgeSmoothing` 옵션을 사용합니다.
325
- - `Esc`로 현재 드로잉 세션을 취소할 수 있습니다.
303
+ ### `useViewerContext()`
304
+
305
+ `rendererRef`, `rendererSerial`, `source`, `worldToScreen`, `registerDrawOverlay`, `overviewInvalidateRef`, interaction lock 커스텀 오버레이·미니맵에서 사용합니다.
306
+
307
+ ### `<DrawLayer>` (단독 사용)
308
+
309
+ `WsiViewer` 없이도 마운트 가능합니다. 툴: `freehand`, `rectangle`, `circular`, `brush`, `stamp-*`. 브러시는 화면 px 반경 + `edgeDetail` / `edgeSmoothing`. `Esc`로 세션 취소.
326
310
 
327
311
  ### `<OverviewMap>`
328
312
 
329
- 현재 뷰포트를 표시하는 인터랙티브 미니맵입니다. `overviewMapConfig.show`를 `true`로 설정하면 `WsiViewerCanvas`에 함께 렌더링됩니다.
313
+ 뷰포트 인디케이터 + (옵션) 썸네일 타일. `WsiViewer`에 prop으로 붙지 않고 **`projectorRef`에 `WsiTileRenderer` ref**를 넘겨 동기화합니다.
330
314
 
331
315
  | Option | Type | Notes |
332
316
  |---|---|---|
333
317
  | `width` / `height` | `number` | 미니맵 캔버스 크기 |
334
- | `margin`, `position`, `borderRadius` | `number`, enum | 배치/모서리 |
335
- | `backgroundColor`, `borderColor` | `string` | 배경/테두리 |
336
- | `viewportBorderStyle` | `"stroke" \| "dash"` | 현재 뷰포트 스타일 |
337
- | `viewportBorderColor`, `viewportFillColor` | `string` | 현재 뷰포트 선/채움 |
338
- | `interactive` | `boolean` | 클릭/드래그로 recenter |
339
- | `showThumbnail`, `maxThumbnailTiles` | `boolean`, `number` | 썸네일 렌더링 제어 |
340
- | `onClose`, `closeIcon`, `closeButtonStyle` | callback / `ReactNode` / `CSSProperties` | 닫기 버튼 UI 옵션 |
318
+ | `margin`, `position`, `borderRadius` | | 배치 |
319
+ | `backgroundColor`, `borderColor` | `string` | 배경/테두리 |
320
+ | `viewportBorderStyle` | `"stroke" \| "dash"` | 뷰포트 테두리 |
321
+ | `viewportBorderColor`, `viewportFillColor` | `string` | 뷰포트 선/채움 |
322
+ | `interactive` | `boolean` | 클릭/드래그로 이동 |
323
+ | `showThumbnail`, `maxThumbnailTiles` | | 썸네일 |
324
+ | `onClose`, `closeIcon`, `closeButtonStyle` | | 닫기 UI |
325
+
326
+ ### `<TileViewerCanvas>`
327
+
328
+ 타일만 필요한 경량 뷰어 (포인트/ROI 없음).
329
+
330
+ ### 동작 규약 (공통)
331
+
332
+ - `mpp`는 스탬프 mm² 환산 등에 사용; 없으면 물리 크기는 근사치입니다.
333
+ - `imageColorSettings`는 **타일**에만 적용됩니다. 포인트/ROI/드로잉은 별도입니다.
334
+ - ROI hit-test는 **contour + nametag** 기준입니다 (내부 fill 제외).
335
+ - `minZoom`/`maxZoom`은 휠·더블클릭·`setViewState`·`fitToImage` 전 경로에 동일하게 clamp됩니다.
336
+ - `zoomSnaps` 입력은 **배율**이며 `source.mpp`로 내부 zoom으로 변환됩니다.
337
+ - `brushOptions.radius`는 CSS px, 줌과 무관하게 화면에서 고정 크기입니다.
338
+ - `roiRegions[].coordinates`는 ring / polygon(holes) / multipolygon을 지원합니다.
341
339
 
342
340
  ## API
343
341
 
342
+ `src/index.ts` 기준 공개 export 요약입니다.
343
+
344
344
  | Export | 설명 |
345
345
  |---|---|
346
- | `WsiViewerCanvas`, `DrawLayer`, `OverviewMap`, `TileViewerCanvas` | React 컴포넌트 |
347
- | `WsiTileRenderer`, `M1TileRenderer`, `TileScheduler` | 렌더러/스케줄러 클래스 |
348
- | `normalizeImageInfo`, `toTileUrl`, `toRoiGeometry`, `parseWkt` | 이미지/좌표 유틸 |
349
- | `buildTermPalette`, `calcScaleResolution`, `calcScaleLength`, `toBearerToken`, `clamp` | 공통 유틸 |
350
- | `filterPointDataByPolygons`, `filterPointDataByPolygonsInWorker`, `filterPointDataByPolygonsHybrid` | ROI 포인트 클리핑 |
351
- | `filterPointIndicesByPolygons`, `filterPointIndicesByPolygonsInWorker`, `terminateRoiClipWorker` | 인덱스 기반 클리핑/워커 관리 |
352
- | `buildPointSpatialIndexAsync`, `lookupCellIndex`, `terminatePointHitIndexWorker` | 포인트 공간 인덱스 (워커) |
346
+ | `WsiViewer`, `PointLayer`, `RegionLayer`, `DrawingLayer`, `DrawLayer`, `PatchLayer`, `OverlayLayer`, `OverviewMap`, `TileViewerCanvas` | React |
347
+ | `useViewerContext` | 컨텍스트 (`rendererRef`, `worldToScreen`, …) |
348
+ | `WsiTileRenderer`, `M1TileRenderer`, `TileScheduler` | 코어 렌더러·타일 |
349
+ | `normalizeImageInfo`, `toTileUrl`, `toRoiGeometry`, `parseWkt` | 이미지/ROI/WKT |
350
+ | `buildTermPalette`, `calcScaleResolution`, `calcScaleLength`, `toBearerToken`, `clamp`, `hexToRgba`, `isSameViewState` | 유틸 |
351
+ | `filterPointDataByPolygons`, `filterPointIndicesByPolygons`, `filterPointDataByPolygonsInWorker`, `filterPointIndicesByPolygonsInWorker`, `terminateRoiClipWorker`, `filterPointDataByPolygonsHybrid` | ROI 클리핑 |
352
+ | `buildPointSpatialIndexAsync`, `lookupCellIndex`, `terminatePointHitIndexWorker` | 포인트 공간 인덱스(워커) |
353
353
  | `computeRoiPointGroups` | ROI term 통계 |
354
- | `getWebGpuCapabilities`, `prefilterPointsByBoundsWebGpu` | WebGPU capability/연산(실험) |
355
- | `closeRing`, `createRectangle`, `createCircle` | 도형 유틸 |
356
- | 타입 export (`WsiViewerCanvasProps`, `WsiImageSource`, `WsiPointData`, `WsiViewTransitionOptions` ) | TypeScript 통합용 공개 타입 |
354
+ | `getWebGpuCapabilities`, `prefilterPointsByBoundsWebGpu` | WebGPU(실험) |
355
+ | `closeRing`, `createRectangle`, `createCircle` | 도형 |
356
+ | 타입 (`WsiViewerProps`, `WsiImageSource`, `WsiPointData`, `WsiViewState`, `DrawTool`, `PointHitEvent`, …) | TS |
357
+
358
+ 레거시 `WsiViewerCanvas` / `WsiViewerCanvasProps`는 패키지에 **포함되지 않습니다**.
357
359
 
358
360
  ## Scripts
359
361